From cc929687aec5c65b58122fe1ed1da9b5f684f11f Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 19 Jul 2022 10:27:25 +0200 Subject: [PATCH] Deck-specific Limits (#1955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 4ee7be1e10c4e8c49bb20de3bf45ac18b5e2d4f6. * Revert "Add DeckLimitsDialog" This reverts commit eb0e2a62d33df0b518d9204a27b09e97966ce82a. * 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 0775814989e8cb486483d06727b1af266bb4513a. * Add Tabs component for daily limits * Add borders to tabs component * Revert "Add borders to tabs component" This reverts commit aaaf5538932540f944d92725c63bb04cfe97ea14. * Implement tabbed limits properly * Add comment to translations * Update rslib/src/decks/limits.rs Co-authored-by: Damien Elmes * 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 --- ftl/core/deck-config.ftl | 11 +++ proto/anki/deckconfig.proto | 12 +++ proto/anki/decks.proto | 10 +- pylib/anki/exporting.py | 2 + rslib/build/protobuf.rs | 4 + rslib/src/backend/deckconfig.rs | 1 + rslib/src/deckconfig/update.rs | 51 +++++++++- rslib/src/decks/limits.rs | 77 ++++++++++++--- rslib/src/decks/schema11.rs | 36 ++++++- rslib/src/import_export/gather.rs | 8 +- .../package/apkg/import/decks.rs | 4 + rslib/src/notetype/schema11.rs | 2 +- ts/deck-options/ConfigSelector.svelte | 8 +- ts/deck-options/DailyLimits.svelte | 99 +++++++++++++++++-- ts/deck-options/DeckOptionsPage.svelte | 6 +- ts/deck-options/SaveButton.svelte | 1 + ts/deck-options/TabbedValue.svelte | 83 ++++++++++++++++ ts/deck-options/lib.ts | 48 +++++++++ 18 files changed, 432 insertions(+), 31 deletions(-) create mode 100644 ts/deck-options/TabbedValue.svelte diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index f42cf7900..af0007bab 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -35,6 +35,17 @@ deck-config-limit-new-bound-by-reviews = deck-config-limit-interday-bound-by-reviews = The review limit also affects interday learning cards. When applying the limit, 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 diff --git a/proto/anki/deckconfig.proto b/proto/anki/deckconfig.proto index 57b56212d..f4e40015e 100644 --- a/proto/anki/deckconfig.proto +++ b/proto/anki/deckconfig.proto @@ -144,9 +144,20 @@ message DeckConfigsForUpdate { uint32 use_count = 2; } 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; int64 config_id = 2; repeated int64 parent_config_ids = 3; + Limits limits = 4; } repeated ConfigWithExtra all_config = 1; @@ -167,4 +178,5 @@ message UpdateDeckConfigsRequest { repeated int64 removed_config_ids = 3; bool apply_to_children = 4; string card_state_customizer = 5; + DeckConfigsForUpdate.CurrentDeck.Limits limits = 6; } diff --git a/proto/anki/decks.proto b/proto/anki/decks.proto index 3409acc97..97c4dfb7d 100644 --- a/proto/anki/decks.proto +++ b/proto/anki/decks.proto @@ -64,13 +64,21 @@ message Deck { bytes other = 255; } message Normal { + message DayLimit { + uint32 limit = 1; + uint32 today = 2; + } int64 config_id = 1; uint32 extend_new = 2; uint32 extend_review = 3; string description = 4; 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 SearchTerm { diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py index dbfbc44b0..7e61aaa35 100644 --- a/pylib/anki/exporting.py +++ b/pylib/anki/exporting.py @@ -270,6 +270,8 @@ class AnkiExporter(Exporter): # scheduling not included, so reset deck settings to default d = dict(d) d["conf"] = 1 + d["reviewLimit"] = d["newLimit"] = None + d["reviewLimitToday"] = d["newLimitToday"] = None self.dst.decks.update(d) # copy used deck confs for dc in self.src.decks.all_config(): diff --git a/rslib/build/protobuf.rs b/rslib/build/protobuf.rs index 55252fd00..4535be3e1 100644 --- a/rslib/build/protobuf.rs +++ b/rslib/build/protobuf.rs @@ -105,6 +105,10 @@ pub fn write_backend_proto_rs() { "Deck.Filtered.SearchTerm.Order", "#[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("CsvMetadata.Delimiter", "#[derive(strum::EnumIter)]") .type_attribute( diff --git a/rslib/src/backend/deckconfig.rs b/rslib/src/backend/deckconfig.rs index 1ebc08231..5c3953902 100644 --- a/rslib/src/backend/deckconfig.rs +++ b/rslib/src/backend/deckconfig.rs @@ -89,6 +89,7 @@ impl From for UpdateDeckConfigsRequest { removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(), apply_to_children: c.apply_to_children, card_state_customizer: c.card_state_customizer, + limits: c.limits.unwrap_or_default(), } } } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 36e5061d3..ed735f8bf 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -10,8 +10,12 @@ use std::{ use crate::{ config::StringKey, + decks::NormalDeck, pb, - pb::deck_configs_for_update::{ConfigWithExtra, CurrentDeck}, + pb::{ + deck::normal::DayLimit, + deck_configs_for_update::{current_deck::Limits, ConfigWithExtra, CurrentDeck}, + }, prelude::*, search::{JoinSearches, SearchNode}, }; @@ -24,6 +28,7 @@ pub struct UpdateDeckConfigsRequest { pub removed_config_ids: Vec, pub apply_to_children: bool, pub card_state_customizer: String, + pub limits: Limits, } impl Collection { @@ -84,15 +89,18 @@ impl Collection { fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result { let deck = self.get_deck(deck)?.ok_or(AnkiError::NotFound)?; + let normal = deck.normal()?; + let today = self.timing_today()?.days_elapsed; Ok(CurrentDeck { name: deck.human_name(), - config_id: deck.normal()?.config_id, + config_id: normal.config_id, parent_config_ids: self .parent_config_ids(&deck)? .into_iter() .map(Into::into) .collect(), + limits: Some(normal.to_limits(today)), }) } @@ -147,6 +155,7 @@ impl Collection { // loop through all normal decks let usn = self.usn()?; + let today = self.timing_today()?.days_elapsed; let selected_config = input.configs.last().unwrap(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { @@ -165,6 +174,7 @@ impl Collection { { let mut updated = deck.clone(); 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)?; selected_config.id } 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, new_limit: Option, 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)] mod test { use super::*; @@ -279,6 +325,7 @@ mod test { removed_config_ids: vec![], apply_to_children: false, card_state_customizer: "".to_string(), + limits: Limits::default(), }; assert!(!col.update_deck_configs(input.clone())?.changes.had_change()); diff --git a/rslib/src/decks/limits.rs b/rslib/src/decks/limits.rs index 5120e0d1b..3356cb2e9 100644 --- a/rslib/src/decks/limits.rs +++ b/rslib/src/decks/limits.rs @@ -5,12 +5,43 @@ use std::{collections::HashMap, iter::Peekable}; use id_tree::{InsertBehavior, Node, NodeId, Tree}; -use super::Deck; +use super::{Deck, NormalDeck}; use crate::{ deckconfig::{DeckConfig, DeckConfigId}, + pb::decks::deck::normal::DayLimit, 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 { + 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 { + 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 { + 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 { + self.new_limit_today + .and_then(|day_limit| day_limit.limit(today)) + } +} + +impl DayLimit { + pub fn limit(&self, today: u32) -> Option { + (self.today == today).then(|| self.limit) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct RemainingLimits { pub review: u32, @@ -19,19 +50,37 @@ pub(crate) struct RemainingLimits { impl RemainingLimits { pub(crate) fn new(deck: &Deck, config: Option<&DeckConfig>, today: u32, v3: bool) -> Self { - config - .map(|config| { - let (new_today, mut rev_today) = deck.new_rev_counts(today); - if v3 { - // any reviewed new cards contribute to the review limit - rev_today += new_today; - } - RemainingLimits { - review: ((config.inner.reviews_per_day as i32) - rev_today).max(0) as u32, - new: ((config.inner.new_per_day as i32) - new_today).max(0) as u32, - } - }) - .unwrap_or_default() + if let Ok(normal) = deck.normal() { + 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); + if v3 { + // any reviewed new cards contribute to the review limit + rev_today += new_today; + } + + Self { + review: (review_limit as i32 - rev_today).max(0) as u32, + new: (new_limit as i32 - new_today).max(0) as u32, + } } pub(crate) fn cap_to(&mut self, limits: RemainingLimits) { diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index 873a076b4..d9ad494cc 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -9,6 +9,7 @@ use serde_tuple::Serialize_tuple; use super::{DeckCommon, FilteredDeck, FilteredSearchTerm, NormalDeck}; use crate::{ + pb::decks::deck::normal::DayLimit, prelude::*, serde::{default_on_invalid, deserialize_bool_from_anything, deserialize_number_from_string}, }; @@ -116,6 +117,14 @@ pub struct NormalDeckSchema11 { extend_new: i32, #[serde(default, deserialize_with = "default_on_invalid")] extend_rev: i32, + #[serde(default, deserialize_with = "default_on_invalid")] + review_limit: Option, + #[serde(default, deserialize_with = "default_on_invalid")] + new_limit: Option, + #[serde(default, deserialize_with = "default_on_invalid")] + review_limit_today: Option, + #[serde(default, deserialize_with = "default_on_invalid")] + new_limit_today: Option, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] @@ -226,6 +235,10 @@ impl Default for NormalDeckSchema11 { conf: 1, extend_new: 0, extend_rev: 0, + review_limit: None, + new_limit: None, + review_limit_today: None, + new_limit_today: None, } } } @@ -298,6 +311,10 @@ impl From for NormalDeck { extend_review: deck.extend_rev.max(0) as u32, markdown_description: deck.common.markdown_description, 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 for DeckSchema11 { conf: norm.config_id, extend_new: norm.extend_new 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(), }), DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 { @@ -352,11 +373,12 @@ impl From for DeckSchema11 { impl From for DeckCommonSchema11 { fn from(deck: Deck) -> Self { - let other: HashMap = if deck.common.other.is_empty() { + let mut other: HashMap = if deck.common.other.is_empty() { Default::default() } else { serde_json::from_slice(&deck.common.other).unwrap_or_default() }; + clear_other_duplicates(&mut other); DeckCommonSchema11 { id: deck.id, mtime: deck.mtime_secs, @@ -383,6 +405,18 @@ impl From for DeckCommonSchema11 { } } +/// See [crate::deckconfig::schema11::clear_other_duplicates()]. +fn clear_other_duplicates(other: &mut HashMap) { + for key in [ + "reviewLimit", + "newLimit", + "reviewLimitToday", + "newLimitToday", + ] { + other.remove(key); + } +} + impl From<&Deck> for DeckTodaySchema11 { fn from(deck: &Deck) -> Self { let day = deck.common.last_day_studied as i32; diff --git a/rslib/src/import_export/gather.rs b/rslib/src/import_export/gather.rs index b73de1bba..d615ee40f 100644 --- a/rslib/src/import_export/gather.rs +++ b/rslib/src/import_export/gather.rs @@ -77,7 +77,7 @@ impl ExchangeData { fn remove_scheduling_information(&mut self, col: &Collection) { self.remove_system_tags(); - self.reset_deck_config_ids(); + self.reset_deck_config_ids_and_limits(); 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() { if let Ok(normal_mut) = deck.normal_mut() { 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 { // filtered decks are reset at import time for legacy reasons } diff --git a/rslib/src/import_export/package/apkg/import/decks.rs b/rslib/src/import_export/package/apkg/import/decks.rs index c9717963f..49c81a304 100644 --- a/rslib/src/import_export/package/apkg/import/decks.rs +++ b/rslib/src/import_export/package/apkg/import/decks.rs @@ -163,6 +163,10 @@ impl NormalDeck { if other.config_id != 1 { 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); } } diff --git a/rslib/src/notetype/schema11.rs b/rslib/src/notetype/schema11.rs index f1e6b9911..5db53d56b 100644 --- a/rslib/src/notetype/schema11.rs +++ b/rslib/src/notetype/schema11.rs @@ -159,8 +159,8 @@ impl From for NotetypeSchema11 { } } +/// See [crate::deckconfig::schema11::clear_other_duplicates()]. fn clear_other_field_duplicates(other: &mut HashMap) { - // see `clear_other_duplicates()` in `deckconfig/schema11.rs` for key in &["description"] { other.remove(*key); } diff --git a/ts/deck-options/ConfigSelector.svelte b/ts/deck-options/ConfigSelector.svelte index 699df67fc..58484a684 100644 --- a/ts/deck-options/ConfigSelector.svelte +++ b/ts/deck-options/ConfigSelector.svelte @@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> + @@ -57,9 +141,10 @@ + diff --git a/ts/deck-options/DeckOptionsPage.svelte b/ts/deck-options/DeckOptionsPage.svelte index 59a62a966..46616a77c 100644 --- a/ts/deck-options/DeckOptionsPage.svelte +++ b/ts/deck-options/DeckOptionsPage.svelte @@ -54,9 +54,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export const timerOptions = {}; export const audioOptions = {}; export const advancedOptions = {}; + + let onPresetChange: () => void; - +
- + diff --git a/ts/deck-options/SaveButton.svelte b/ts/deck-options/SaveButton.svelte index 71cbb1da9..d2d3653ce 100644 --- a/ts/deck-options/SaveButton.svelte +++ b/ts/deck-options/SaveButton.svelte @@ -43,6 +43,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html if (confirm(withCollapsedWhitespace(msg))) { try { state.removeCurrentConfig(); + dispatch("remove"); } catch (err) { alert(err); } diff --git a/ts/deck-options/TabbedValue.svelte b/ts/deck-options/TabbedValue.svelte new file mode 100644 index 000000000..3490d3dde --- /dev/null +++ b/ts/deck-options/TabbedValue.svelte @@ -0,0 +1,83 @@ + + + +
    + {#each tabs as tab, idx} +
  • + {tab.title} +
  • + {/each} +
+ + diff --git a/ts/deck-options/lib.ts b/ts/deck-options/lib.ts index c3a3d02a0..9428bf356 100644 --- a/ts/deck-options/lib.ts +++ b/ts/deck-options/lib.ts @@ -35,6 +35,7 @@ export class DeckOptionsState { readonly parentLimits: Readable; readonly cardStateCustomizer: Writable; readonly currentDeck: DeckConfig.DeckConfigsForUpdate.CurrentDeck; + readonly deckLimits: Writable; readonly defaults: DeckConfig.DeckConfig.Config; readonly addonComponents: Writable; readonly v3Scheduler: boolean; @@ -68,6 +69,7 @@ export class DeckOptionsState { this.v3Scheduler = data.v3Scheduler; this.haveAddons = data.haveAddons; 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 // selected one at display time @@ -190,6 +192,7 @@ export class DeckOptionsState { configs, applyToChildren, cardStateCustomizer: get(this.cardStateCustomizer), + limits: get(this.deckLimits), }; } @@ -309,3 +312,48 @@ function bytesToObject(bytes: Uint8Array): Record { 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); + } +}