Deck-specific Limits (#1955)

* Add deck-specific limits to DeckNormal

* Add deck-specific limits to schema11

* Add DeckLimitsDialog

* deck_limits_qt6.py needs to be a symlink

* Clear duplicate deck setting keys on downgrade

* Export deck limits when exporting with scheduling

* Revert "deck_limits_qt6.py needs to be a symlink"

This reverts commit 4ee7be1e10.

* Revert "Add DeckLimitsDialog"

This reverts commit eb0e2a62d3.

* Add day limits to DeckNormal

* Add deck and day limits mock to deck options

* Revert "Add deck and day limits mock to deck options"

This reverts commit 0775814989.

* Add Tabs component for daily limits

* Add borders to tabs component

* Revert "Add borders to tabs component"

This reverts commit aaaf553893.

* Implement tabbed limits properly

* Add comment to translations

* Update rslib/src/decks/limits.rs

Co-authored-by: Damien Elmes <dae@users.noreply.github.com>

* Fix camel case in clear_other_duplicates()

* day_limit → current_limit

* Also import day limits

* Remember last used day limits

* Add day limits to schema 11

* Tweak comment (dae)

* Exclude day limit in export (dae)

* Tweak tab wording (dae)

* Update preset limits on preset change

* Explain tabs in tooltip (dae)

* Omit deck and today limits if v2 is enabled

* Preserve deck limit when switching to today limit
This commit is contained in:
RumovZ 2022-07-19 10:27:25 +02:00 committed by GitHub
parent e0368a3858
commit cc929687ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 432 additions and 31 deletions

View file

@ -35,6 +35,17 @@ deck-config-limit-new-bound-by-reviews =
deck-config-limit-interday-bound-by-reviews = deck-config-limit-interday-bound-by-reviews =
The review limit also affects interday learning cards. When applying the limit, The review limit also affects interday learning cards. When applying the limit,
interday learning cards are fetched first, then reviews, and finally new cards. interday learning cards are fetched first, then reviews, and finally new cards.
deck-config-tab-description =
- `Preset`: The limit is shared with all decks using this preset.
- `This deck`: The limit is specific to this deck.
- `Today only`: Make a temporary change to this deck's limit.
## Daily limit tabs: please try to keep these as short as the English version,
## as longer text will not fit on small screens.
deck-config-shared-preset = Preset
deck-config-deck-only = This deck
deck-config-today-only = Today only
## New Cards section ## New Cards section

View file

@ -144,9 +144,20 @@ message DeckConfigsForUpdate {
uint32 use_count = 2; uint32 use_count = 2;
} }
message CurrentDeck { message CurrentDeck {
message Limits {
optional uint32 review = 1;
optional uint32 new = 2;
optional uint32 review_today = 3;
optional uint32 new_today = 4;
// Whether review_today applies to today or a past day.
bool review_today_active = 5;
// Whether new_today applies to today or a past day.
bool new_today_active = 6;
}
string name = 1; string name = 1;
int64 config_id = 2; int64 config_id = 2;
repeated int64 parent_config_ids = 3; repeated int64 parent_config_ids = 3;
Limits limits = 4;
} }
repeated ConfigWithExtra all_config = 1; repeated ConfigWithExtra all_config = 1;
@ -167,4 +178,5 @@ message UpdateDeckConfigsRequest {
repeated int64 removed_config_ids = 3; repeated int64 removed_config_ids = 3;
bool apply_to_children = 4; bool apply_to_children = 4;
string card_state_customizer = 5; string card_state_customizer = 5;
DeckConfigsForUpdate.CurrentDeck.Limits limits = 6;
} }

View file

@ -64,13 +64,21 @@ message Deck {
bytes other = 255; bytes other = 255;
} }
message Normal { message Normal {
message DayLimit {
uint32 limit = 1;
uint32 today = 2;
}
int64 config_id = 1; int64 config_id = 1;
uint32 extend_new = 2; uint32 extend_new = 2;
uint32 extend_review = 3; uint32 extend_review = 3;
string description = 4; string description = 4;
bool markdown_description = 5; bool markdown_description = 5;
optional uint32 review_limit = 6;
optional uint32 new_limit = 7;
DayLimit review_limit_today = 8;
DayLimit new_limit_today = 9;
reserved 6 to 11; reserved 12 to 15;
} }
message Filtered { message Filtered {
message SearchTerm { message SearchTerm {

View file

@ -270,6 +270,8 @@ class AnkiExporter(Exporter):
# scheduling not included, so reset deck settings to default # scheduling not included, so reset deck settings to default
d = dict(d) d = dict(d)
d["conf"] = 1 d["conf"] = 1
d["reviewLimit"] = d["newLimit"] = None
d["reviewLimitToday"] = d["newLimitToday"] = None
self.dst.decks.update(d) self.dst.decks.update(d)
# copy used deck confs # copy used deck confs
for dc in self.src.decks.all_config(): for dc in self.src.decks.all_config():

View file

@ -105,6 +105,10 @@ pub fn write_backend_proto_rs() {
"Deck.Filtered.SearchTerm.Order", "Deck.Filtered.SearchTerm.Order",
"#[derive(strum::EnumIter)]", "#[derive(strum::EnumIter)]",
) )
.type_attribute(
"Deck.Normal.DayLimit",
"#[derive(Copy, serde_derive::Deserialize, serde_derive::Serialize)]",
)
.type_attribute("HelpPageLinkRequest.HelpPage", "#[derive(strum::EnumIter)]") .type_attribute("HelpPageLinkRequest.HelpPage", "#[derive(strum::EnumIter)]")
.type_attribute("CsvMetadata.Delimiter", "#[derive(strum::EnumIter)]") .type_attribute("CsvMetadata.Delimiter", "#[derive(strum::EnumIter)]")
.type_attribute( .type_attribute(

View file

@ -89,6 +89,7 @@ impl From<pb::UpdateDeckConfigsRequest> for UpdateDeckConfigsRequest {
removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(), removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(),
apply_to_children: c.apply_to_children, apply_to_children: c.apply_to_children,
card_state_customizer: c.card_state_customizer, card_state_customizer: c.card_state_customizer,
limits: c.limits.unwrap_or_default(),
} }
} }
} }

View file

@ -10,8 +10,12 @@ use std::{
use crate::{ use crate::{
config::StringKey, config::StringKey,
decks::NormalDeck,
pb, pb,
pb::deck_configs_for_update::{ConfigWithExtra, CurrentDeck}, pb::{
deck::normal::DayLimit,
deck_configs_for_update::{current_deck::Limits, ConfigWithExtra, CurrentDeck},
},
prelude::*, prelude::*,
search::{JoinSearches, SearchNode}, search::{JoinSearches, SearchNode},
}; };
@ -24,6 +28,7 @@ pub struct UpdateDeckConfigsRequest {
pub removed_config_ids: Vec<DeckConfigId>, pub removed_config_ids: Vec<DeckConfigId>,
pub apply_to_children: bool, pub apply_to_children: bool,
pub card_state_customizer: String, pub card_state_customizer: String,
pub limits: Limits,
} }
impl Collection { impl Collection {
@ -84,15 +89,18 @@ impl Collection {
fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result<CurrentDeck> { fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result<CurrentDeck> {
let deck = self.get_deck(deck)?.ok_or(AnkiError::NotFound)?; let deck = self.get_deck(deck)?.ok_or(AnkiError::NotFound)?;
let normal = deck.normal()?;
let today = self.timing_today()?.days_elapsed;
Ok(CurrentDeck { Ok(CurrentDeck {
name: deck.human_name(), name: deck.human_name(),
config_id: deck.normal()?.config_id, config_id: normal.config_id,
parent_config_ids: self parent_config_ids: self
.parent_config_ids(&deck)? .parent_config_ids(&deck)?
.into_iter() .into_iter()
.map(Into::into) .map(Into::into)
.collect(), .collect(),
limits: Some(normal.to_limits(today)),
}) })
} }
@ -147,6 +155,7 @@ impl Collection {
// loop through all normal decks // loop through all normal decks
let usn = self.usn()?; let usn = self.usn()?;
let today = self.timing_today()?.days_elapsed;
let selected_config = input.configs.last().unwrap(); let selected_config = input.configs.last().unwrap();
for deck in self.storage.get_all_decks()? { for deck in self.storage.get_all_decks()? {
if let Ok(normal) = deck.normal() { if let Ok(normal) = deck.normal() {
@ -165,6 +174,7 @@ impl Collection {
{ {
let mut updated = deck.clone(); let mut updated = deck.clone();
updated.normal_mut()?.config_id = selected_config.id.0; updated.normal_mut()?.config_id = selected_config.id.0;
updated.normal_mut()?.update_limits(&input.limits, today);
self.update_deck_inner(&mut updated, deck, usn)?; self.update_deck_inner(&mut updated, deck, usn)?;
selected_config.id selected_config.id
} else { } else {
@ -223,6 +233,42 @@ impl Collection {
} }
} }
impl NormalDeck {
fn to_limits(&self, today: u32) -> Limits {
Limits {
review: self.review_limit,
new: self.new_limit,
review_today: self.review_limit_today.map(|limit| limit.limit),
new_today: self.new_limit_today.map(|limit| limit.limit),
review_today_active: self
.review_limit_today
.map(|limit| limit.today == today)
.unwrap_or_default(),
new_today_active: self
.new_limit_today
.map(|limit| limit.today == today)
.unwrap_or_default(),
}
}
fn update_limits(&mut self, limits: &Limits, today: u32) {
self.review_limit = limits.review;
self.new_limit = limits.new;
update_day_limit(&mut self.review_limit_today, limits.review_today, today);
update_day_limit(&mut self.new_limit_today, limits.new_today, today);
}
}
fn update_day_limit(day_limit: &mut Option<DayLimit>, new_limit: Option<u32>, today: u32) {
if let Some(limit) = new_limit {
day_limit.replace(DayLimit { limit, today });
} else if let Some(limit) = day_limit {
// instead of setting to None, only make sure today is in the past,
// thus preserving last used value
limit.today = limit.today.min(today - 1);
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -279,6 +325,7 @@ mod test {
removed_config_ids: vec![], removed_config_ids: vec![],
apply_to_children: false, apply_to_children: false,
card_state_customizer: "".to_string(), card_state_customizer: "".to_string(),
limits: Limits::default(),
}; };
assert!(!col.update_deck_configs(input.clone())?.changes.had_change()); assert!(!col.update_deck_configs(input.clone())?.changes.had_change());

View file

@ -5,12 +5,43 @@ use std::{collections::HashMap, iter::Peekable};
use id_tree::{InsertBehavior, Node, NodeId, Tree}; use id_tree::{InsertBehavior, Node, NodeId, Tree};
use super::Deck; use super::{Deck, NormalDeck};
use crate::{ use crate::{
deckconfig::{DeckConfig, DeckConfigId}, deckconfig::{DeckConfig, DeckConfigId},
pb::decks::deck::normal::DayLimit,
prelude::*, prelude::*,
}; };
impl NormalDeck {
/// The deck's review limit for today, or its regular one, if any is configured.
pub fn current_review_limit(&self, today: u32) -> Option<u32> {
self.review_limit_today(today).or(self.review_limit)
}
/// The deck's new limit for today, or its regular one, if any is configured.
pub fn current_new_limit(&self, today: u32) -> Option<u32> {
self.new_limit_today(today).or(self.new_limit)
}
/// The deck's review limit for today.
pub fn review_limit_today(&self, today: u32) -> Option<u32> {
self.review_limit_today
.and_then(|day_limit| day_limit.limit(today))
}
/// The deck's new limit for today.
pub fn new_limit_today(&self, today: u32) -> Option<u32> {
self.new_limit_today
.and_then(|day_limit| day_limit.limit(today))
}
}
impl DayLimit {
pub fn limit(&self, today: u32) -> Option<u32> {
(self.today == today).then(|| self.limit)
}
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct RemainingLimits { pub(crate) struct RemainingLimits {
pub review: u32, pub review: u32,
@ -19,19 +50,37 @@ pub(crate) struct RemainingLimits {
impl RemainingLimits { impl RemainingLimits {
pub(crate) fn new(deck: &Deck, config: Option<&DeckConfig>, today: u32, v3: bool) -> Self { pub(crate) fn new(deck: &Deck, config: Option<&DeckConfig>, today: u32, v3: bool) -> Self {
config if let Ok(normal) = deck.normal() {
.map(|config| { if let Some(config) = config {
return Self::new_for_normal_deck(deck, today, v3, normal, config);
}
}
Self::default()
}
fn new_for_normal_deck(
deck: &Deck,
today: u32,
v3: bool,
normal: &NormalDeck,
config: &DeckConfig,
) -> RemainingLimits {
let review_limit = normal
.current_review_limit(today)
.unwrap_or(config.inner.reviews_per_day);
let new_limit = normal
.current_new_limit(today)
.unwrap_or(config.inner.new_per_day);
let (new_today, mut rev_today) = deck.new_rev_counts(today); let (new_today, mut rev_today) = deck.new_rev_counts(today);
if v3 { if v3 {
// any reviewed new cards contribute to the review limit // any reviewed new cards contribute to the review limit
rev_today += new_today; rev_today += new_today;
} }
RemainingLimits {
review: ((config.inner.reviews_per_day as i32) - rev_today).max(0) as u32, Self {
new: ((config.inner.new_per_day as i32) - new_today).max(0) as u32, review: (review_limit as i32 - rev_today).max(0) as u32,
new: (new_limit as i32 - new_today).max(0) as u32,
} }
})
.unwrap_or_default()
} }
pub(crate) fn cap_to(&mut self, limits: RemainingLimits) { pub(crate) fn cap_to(&mut self, limits: RemainingLimits) {

View file

@ -9,6 +9,7 @@ use serde_tuple::Serialize_tuple;
use super::{DeckCommon, FilteredDeck, FilteredSearchTerm, NormalDeck}; use super::{DeckCommon, FilteredDeck, FilteredSearchTerm, NormalDeck};
use crate::{ use crate::{
pb::decks::deck::normal::DayLimit,
prelude::*, prelude::*,
serde::{default_on_invalid, deserialize_bool_from_anything, deserialize_number_from_string}, serde::{default_on_invalid, deserialize_bool_from_anything, deserialize_number_from_string},
}; };
@ -116,6 +117,14 @@ pub struct NormalDeckSchema11 {
extend_new: i32, extend_new: i32,
#[serde(default, deserialize_with = "default_on_invalid")] #[serde(default, deserialize_with = "default_on_invalid")]
extend_rev: i32, extend_rev: i32,
#[serde(default, deserialize_with = "default_on_invalid")]
review_limit: Option<u32>,
#[serde(default, deserialize_with = "default_on_invalid")]
new_limit: Option<u32>,
#[serde(default, deserialize_with = "default_on_invalid")]
review_limit_today: Option<DayLimit>,
#[serde(default, deserialize_with = "default_on_invalid")]
new_limit_today: Option<DayLimit>,
} }
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
@ -226,6 +235,10 @@ impl Default for NormalDeckSchema11 {
conf: 1, conf: 1,
extend_new: 0, extend_new: 0,
extend_rev: 0, extend_rev: 0,
review_limit: None,
new_limit: None,
review_limit_today: None,
new_limit_today: None,
} }
} }
} }
@ -298,6 +311,10 @@ impl From<NormalDeckSchema11> for NormalDeck {
extend_review: deck.extend_rev.max(0) as u32, extend_review: deck.extend_rev.max(0) as u32,
markdown_description: deck.common.markdown_description, markdown_description: deck.common.markdown_description,
description: deck.common.desc, description: deck.common.desc,
review_limit: deck.review_limit,
new_limit: deck.new_limit,
review_limit_today: deck.review_limit_today,
new_limit_today: deck.new_limit_today,
} }
} }
} }
@ -332,6 +349,10 @@ impl From<Deck> for DeckSchema11 {
conf: norm.config_id, conf: norm.config_id,
extend_new: norm.extend_new as i32, extend_new: norm.extend_new as i32,
extend_rev: norm.extend_review as i32, extend_rev: norm.extend_review as i32,
review_limit: norm.review_limit,
new_limit: norm.new_limit,
review_limit_today: norm.review_limit_today,
new_limit_today: norm.new_limit_today,
common: deck.into(), common: deck.into(),
}), }),
DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 { DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 {
@ -352,11 +373,12 @@ impl From<Deck> for DeckSchema11 {
impl From<Deck> for DeckCommonSchema11 { impl From<Deck> for DeckCommonSchema11 {
fn from(deck: Deck) -> Self { fn from(deck: Deck) -> Self {
let other: HashMap<String, Value> = if deck.common.other.is_empty() { let mut other: HashMap<String, Value> = if deck.common.other.is_empty() {
Default::default() Default::default()
} else { } else {
serde_json::from_slice(&deck.common.other).unwrap_or_default() serde_json::from_slice(&deck.common.other).unwrap_or_default()
}; };
clear_other_duplicates(&mut other);
DeckCommonSchema11 { DeckCommonSchema11 {
id: deck.id, id: deck.id,
mtime: deck.mtime_secs, mtime: deck.mtime_secs,
@ -383,6 +405,18 @@ impl From<Deck> for DeckCommonSchema11 {
} }
} }
/// See [crate::deckconfig::schema11::clear_other_duplicates()].
fn clear_other_duplicates(other: &mut HashMap<String, Value>) {
for key in [
"reviewLimit",
"newLimit",
"reviewLimitToday",
"newLimitToday",
] {
other.remove(key);
}
}
impl From<&Deck> for DeckTodaySchema11 { impl From<&Deck> for DeckTodaySchema11 {
fn from(deck: &Deck) -> Self { fn from(deck: &Deck) -> Self {
let day = deck.common.last_day_studied as i32; let day = deck.common.last_day_studied as i32;

View file

@ -77,7 +77,7 @@ impl ExchangeData {
fn remove_scheduling_information(&mut self, col: &Collection) { fn remove_scheduling_information(&mut self, col: &Collection) {
self.remove_system_tags(); self.remove_system_tags();
self.reset_deck_config_ids(); self.reset_deck_config_ids_and_limits();
self.reset_cards(col); self.reset_cards(col);
} }
@ -91,10 +91,14 @@ impl ExchangeData {
} }
} }
fn reset_deck_config_ids(&mut self) { fn reset_deck_config_ids_and_limits(&mut self) {
for deck in self.decks.iter_mut() { for deck in self.decks.iter_mut() {
if let Ok(normal_mut) = deck.normal_mut() { if let Ok(normal_mut) = deck.normal_mut() {
normal_mut.config_id = 1; normal_mut.config_id = 1;
normal_mut.review_limit = None;
normal_mut.review_limit_today = None;
normal_mut.new_limit = None;
normal_mut.new_limit_today = None;
} else { } else {
// filtered decks are reset at import time for legacy reasons // filtered decks are reset at import time for legacy reasons
} }

View file

@ -163,6 +163,10 @@ impl NormalDeck {
if other.config_id != 1 { if other.config_id != 1 {
self.config_id = other.config_id; self.config_id = other.config_id;
} }
self.review_limit = other.review_limit.or(self.review_limit);
self.new_limit = other.new_limit.or(self.new_limit);
self.review_limit_today = other.review_limit_today.or(self.review_limit_today);
self.new_limit_today = other.new_limit_today.or(self.new_limit_today);
} }
} }

View file

@ -159,8 +159,8 @@ impl From<Notetype> for NotetypeSchema11 {
} }
} }
/// See [crate::deckconfig::schema11::clear_other_duplicates()].
fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) { fn clear_other_field_duplicates(other: &mut HashMap<String, Value>) {
// see `clear_other_duplicates()` in `deckconfig/schema11.rs`
for key in &["description"] { for key in &["description"] {
other.remove(*key); other.remove(*key);
} }

View file

@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
import { getContext } from "svelte"; import { createEventDispatcher, getContext } from "svelte";
import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonToolbar from "../components/ButtonToolbar.svelte"; import ButtonToolbar from "../components/ButtonToolbar.svelte";
@ -20,6 +20,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let state: DeckOptionsState; export let state: DeckOptionsState;
const configList = state.configList; const configList = state.configList;
const dispatch = createEventDispatcher();
const dispatchPresetChange = () => dispatch("presetchange");
function configLabel(entry: ConfigListEntry): string { function configLabel(entry: ConfigListEntry): string {
const count = tr.deckConfigUsedByDecks({ decks: entry.useCount }); const count = tr.deckConfigUsedByDecks({ decks: entry.useCount });
@ -28,12 +30,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function blur(event: Event): void { function blur(event: Event): void {
state.setCurrentIndex(parseInt((event.target! as HTMLSelectElement).value)); state.setCurrentIndex(parseInt((event.target! as HTMLSelectElement).value));
dispatchPresetChange();
} }
function onAddConfig(text: string): void { function onAddConfig(text: string): void {
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.length) { if (trimmed.length) {
state.addConfig(trimmed); state.addConfig(trimmed);
dispatchPresetChange();
} }
} }
@ -41,6 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.length) { if (trimmed.length) {
state.cloneConfig(trimmed); state.cloneConfig(trimmed);
dispatchPresetChange();
} }
} }
@ -107,6 +112,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:add={promptToAdd} on:add={promptToAdd}
on:clone={promptToClone} on:clone={promptToClone}
on:rename={promptToRename} on:rename={promptToRename}
on:remove={dispatchPresetChange}
/> />
</ButtonToolbar> </ButtonToolbar>
</StickyContainer> </StickyContainer>

View file

@ -7,14 +7,34 @@
import Item from "../components/Item.svelte"; import Item from "../components/Item.svelte";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import { ValueTab } from "./lib";
import SpinBoxRow from "./SpinBoxRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte";
import TabbedValue from "./TabbedValue.svelte";
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; export let api: Record<string, never>;
export function onPresetChange() {
newTabs[0] = new ValueTab(
tr.deckConfigSharedPreset(),
$config.newPerDay,
(value) => ($config.newPerDay = value!),
$config.newPerDay,
null,
);
reviewTabs[0] = new ValueTab(
tr.deckConfigSharedPreset(),
$config.reviewsPerDay,
(value) => ($config.reviewsPerDay = value!),
$config.reviewsPerDay,
null,
);
}
const config = state.currentConfig; const config = state.currentConfig;
const limits = state.deckLimits;
const defaults = state.defaults; const defaults = state.defaults;
const parentLimits = state.parentLimits; const parentLimits = state.parentLimits;
@ -24,28 +44,92 @@
"\n\n" + "\n\n" +
tr.deckConfigLimitInterdayBoundByReviews() + tr.deckConfigLimitInterdayBoundByReviews() +
"\n\n" + "\n\n" +
tr.deckConfigLimitDeckV3() tr.deckConfigLimitDeckV3() +
"\n\n" +
tr.deckConfigTabDescription()
: ""; : "";
$: newCardsGreaterThanParent = $: newCardsGreaterThanParent =
!state.v3Scheduler && $config.newPerDay > $parentLimits.newCards !state.v3Scheduler && newValue > $parentLimits.newCards
? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards }) ? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards })
: ""; : "";
$: reviewsTooLow = $: reviewsTooLow =
Math.min(9999, $config.newPerDay * 10) > $config.reviewsPerDay Math.min(9999, newValue * 10) > reviewsValue
? tr.deckConfigReviewsTooLow({ ? tr.deckConfigReviewsTooLow({
cards: $config.newPerDay, cards: newValue,
expected: Math.min(9999, $config.newPerDay * 10), expected: Math.min(9999, newValue * 10),
}) })
: ""; : "";
const newTabs: ValueTab[] = [
new ValueTab(
tr.deckConfigSharedPreset(),
$config.newPerDay,
(value) => ($config.newPerDay = value!),
$config.newPerDay,
null,
),
].concat(
state.v3Scheduler
? [
new ValueTab(
tr.deckConfigDeckOnly(),
$limits.new ?? null,
(value) => ($limits.new = value),
null,
null,
),
new ValueTab(
tr.deckConfigTodayOnly(),
$limits.newTodayActive ? $limits.newToday ?? null : null,
(value) => ($limits.newToday = value),
null,
$limits.newToday ?? null,
),
]
: [],
);
const reviewTabs: ValueTab[] = [
new ValueTab(
tr.deckConfigSharedPreset(),
$config.reviewsPerDay,
(value) => ($config.reviewsPerDay = value!),
$config.reviewsPerDay,
null,
),
].concat(
state.v3Scheduler
? [
new ValueTab(
tr.deckConfigDeckOnly(),
$limits.review ?? null,
(value) => ($limits.review = value),
null,
null,
),
new ValueTab(
tr.deckConfigTodayOnly(),
$limits.reviewTodayActive ? $limits.reviewToday ?? null : null,
(value) => ($limits.reviewToday = value),
null,
$limits.reviewToday ?? null,
),
]
: [],
);
let reviewsValue: number;
let newValue: number;
</script> </script>
<TitledContainer title={tr.deckConfigDailyLimits()}> <TitledContainer title={tr.deckConfigDailyLimits()}>
<DynamicallySlottable slotHost={Item} {api}> <DynamicallySlottable slotHost={Item} {api}>
<TabbedValue tabs={newTabs} bind:value={newValue} />
<Item> <Item>
<SpinBoxRow <SpinBoxRow
bind:value={$config.newPerDay} bind:value={newValue}
defaultValue={defaults.newPerDay} defaultValue={defaults.newPerDay}
markdownTooltip={tr.deckConfigNewLimitTooltip() + v3Extra} markdownTooltip={tr.deckConfigNewLimitTooltip() + v3Extra}
> >
@ -57,9 +141,10 @@
<Warning warning={newCardsGreaterThanParent} /> <Warning warning={newCardsGreaterThanParent} />
</Item> </Item>
<TabbedValue tabs={reviewTabs} bind:value={reviewsValue} />
<Item> <Item>
<SpinBoxRow <SpinBoxRow
bind:value={$config.reviewsPerDay} bind:value={reviewsValue}
defaultValue={defaults.reviewsPerDay} defaultValue={defaults.reviewsPerDay}
markdownTooltip={tr.deckConfigReviewLimitTooltip() + v3Extra} markdownTooltip={tr.deckConfigReviewLimitTooltip() + v3Extra}
> >

View file

@ -54,9 +54,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const timerOptions = {}; export const timerOptions = {};
export const audioOptions = {}; export const audioOptions = {};
export const advancedOptions = {}; export const advancedOptions = {};
let onPresetChange: () => void;
</script> </script>
<ConfigSelector {state} /> <ConfigSelector {state} on:presetchange={onPresetChange} />
<div class="deck-options-page"> <div class="deck-options-page">
<Container <Container
@ -68,7 +70,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<DynamicallySlottable slotHost={Item} api={options}> <DynamicallySlottable slotHost={Item} api={options}>
<Item> <Item>
<Row class="row-columns"> <Row class="row-columns">
<DailyLimits {state} api={dailyLimits} /> <DailyLimits {state} api={dailyLimits} bind:onPresetChange />
</Row> </Row>
</Item> </Item>

View file

@ -43,6 +43,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if (confirm(withCollapsedWhitespace(msg))) { if (confirm(withCollapsedWhitespace(msg))) {
try { try {
state.removeCurrentConfig(); state.removeCurrentConfig();
dispatch("remove");
} catch (err) { } catch (err) {
alert(err); alert(err);
} }

View file

@ -0,0 +1,83 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
/* This component accepts an array of tabs and a value. Whenever a tab is
activated, its last used value is applied to its provided setter and the
component's value. Whenever it's deactivated, its setter is called with its
disabledValue. */
import type { ValueTab } from "./lib";
export let tabs: ValueTab[];
export let value: number;
let activeTab = lastSetTab();
$: onTabChanged(activeTab);
$: value = tabs[activeTab].value ?? 0;
$: tabs[activeTab].setValue(value);
function lastSetTab(): number {
const revIdx = tabs
.slice()
.reverse()
.findIndex((tab) => tab.value !== null);
return revIdx === -1 ? 0 : tabs.length - revIdx - 1;
}
function onTabChanged(newTab: number) {
for (const [idx, tab] of tabs.entries()) {
if (newTab === idx) {
tab.enable(value);
} else if (newTab > idx) {
/* antecedent tabs are obscured, so we can preserve their original values */
tab.reset();
} else {
/* but subsequent tabs would obscure, so they must be nulled */
tab.disable();
}
}
}
const handleClick = (tabValue: number) => () => (activeTab = tabValue);
</script>
<ul>
{#each tabs as tab, idx}
<li class={activeTab === idx ? "active" : ""}>
<span on:click={handleClick(idx)}>{tab.title}</span>
</li>
{/each}
</ul>
<style lang="scss">
ul {
display: flex;
flex-wrap: wrap;
padding-left: 0;
margin-top: 1rem;
margin-bottom: 0.5rem;
list-style: none;
border-bottom: 1px solid var(--border);
}
span {
border: 1px solid transparent;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
display: block;
padding: 0.25rem 1rem;
cursor: pointer;
margin: 0 8px -1px 0;
color: var(--disabled);
}
li.active > span {
border-color: var(--border) var(--border) var(--window-bg);
color: var(--text-fg);
}
span:hover {
color: var(--text-fg);
}
</style>

View file

@ -35,6 +35,7 @@ export class DeckOptionsState {
readonly parentLimits: Readable<ParentLimits>; readonly parentLimits: Readable<ParentLimits>;
readonly cardStateCustomizer: Writable<string>; readonly cardStateCustomizer: Writable<string>;
readonly currentDeck: DeckConfig.DeckConfigsForUpdate.CurrentDeck; readonly currentDeck: DeckConfig.DeckConfigsForUpdate.CurrentDeck;
readonly deckLimits: Writable<DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits>;
readonly defaults: DeckConfig.DeckConfig.Config; readonly defaults: DeckConfig.DeckConfig.Config;
readonly addonComponents: Writable<DynamicSvelteComponent[]>; readonly addonComponents: Writable<DynamicSvelteComponent[]>;
readonly v3Scheduler: boolean; readonly v3Scheduler: boolean;
@ -68,6 +69,7 @@ export class DeckOptionsState {
this.v3Scheduler = data.v3Scheduler; this.v3Scheduler = data.v3Scheduler;
this.haveAddons = data.haveAddons; this.haveAddons = data.haveAddons;
this.cardStateCustomizer = writable(data.cardStateCustomizer); this.cardStateCustomizer = writable(data.cardStateCustomizer);
this.deckLimits = writable(data.currentDeck?.limits ?? createLimits());
// decrement the use count of the starting item, as we'll apply +1 to currently // decrement the use count of the starting item, as we'll apply +1 to currently
// selected one at display time // selected one at display time
@ -190,6 +192,7 @@ export class DeckOptionsState {
configs, configs,
applyToChildren, applyToChildren,
cardStateCustomizer: get(this.cardStateCustomizer), cardStateCustomizer: get(this.cardStateCustomizer),
limits: get(this.deckLimits),
}; };
} }
@ -309,3 +312,48 @@ function bytesToObject(bytes: Uint8Array): Record<string, unknown> {
return obj; return obj;
} }
export function createLimits(): DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits {
return DeckConfig.DeckConfigsForUpdate.CurrentDeck.Limits.create({});
}
export class ValueTab {
readonly title: string;
value: number | null;
private setter: (value: number | null) => void;
private disabledValue: number | null;
private startValue: number | null;
private initialValue: number | null;
constructor(
title: string,
value: number | null,
setter: (value: number | null) => void,
disabledValue: number | null,
startValue: number | null,
) {
this.title = title;
this.value = this.initialValue = value;
this.setter = setter;
this.disabledValue = disabledValue;
this.startValue = startValue;
}
reset(): void {
this.setter(this.initialValue);
}
disable(): void {
this.setter(this.disabledValue);
}
enable(fallbackValue: number): void {
this.value = this.value ?? this.startValue ?? fallbackValue;
this.setter(this.value);
}
setValue(value: number): void {
this.value = value;
this.setter(value);
}
}