diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 9dae49c6a..55291ee5f 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -219,6 +219,8 @@ message DeckConfigsForUpdate { bool review_today_active = 5; // Whether new_today applies to today or a past day. bool new_today_active = 6; + // Deck-specific desired retention override + optional float desired_retention = 7; } string name = 1; int64 config_id = 2; diff --git a/proto/anki/decks.proto b/proto/anki/decks.proto index bcd206b06..b244eb4a1 100644 --- a/proto/anki/decks.proto +++ b/proto/anki/decks.proto @@ -83,6 +83,8 @@ message Deck { optional uint32 new_limit = 7; DayLimit review_limit_today = 8; DayLimit new_limit_today = 9; + // Deck-specific desired retention override + optional float desired_retention = 10; reserved 12 to 15; } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 9eb3b595f..bcbbd59bf 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -409,6 +409,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { .new_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), + desired_retention: deck.desired_retention, } } @@ -417,6 +418,7 @@ fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) { deck.new_limit = limits.new; update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today); + deck.desired_retention = limits.desired_retention; } fn update_day_limit(day_limit: &mut Option, new_limit: Option, today: u32) { diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index e10820ca1..5cd4094f0 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -325,6 +325,7 @@ impl From for NormalDeck { new_limit: deck.new_limit, review_limit_today: deck.review_limit_today, new_limit_today: deck.new_limit_today, + desired_retention: None, } } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index bfe0eafaf..e7be9fa43 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -444,6 +444,15 @@ impl Collection { .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; + + // Get deck-specific desired retention if available, otherwise use config + // default + let desired_retention = if let Some(deck_dr) = deck.normal()?.desired_retention { + deck_dr + } else { + config.inner.desired_retention + }; + let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_next_states = if fsrs_enabled { let params = config.fsrs_params(); @@ -473,13 +482,13 @@ impl Collection { }; Some(fsrs.next_states( card.memory_state.map(Into::into), - config.inner.desired_retention, + desired_retention, days_elapsed, )?) } else { None }; - let desired_retention = fsrs_enabled.then_some(config.inner.desired_retention); + let desired_retention = fsrs_enabled.then_some(desired_retention); let fsrs_short_term_with_steps = self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); let fsrs_allow_short_term = if fsrs_enabled { @@ -662,6 +671,46 @@ pub(crate) mod test { col.get_scheduling_states(card_id).unwrap().current } + // Test that deck-specific desired retention is used when available + #[test] + fn deck_specific_desired_retention() -> Result<()> { + let mut col = Collection::new(); + + // Enable FSRS + col.set_config_bool(BoolKey::Fsrs, true, false)?; + + // Create a deck with specific desired retention + let deck_id = DeckId(1); + let deck = col.get_deck(deck_id)?.unwrap(); + let mut deck_clone = (*deck).clone(); + if let DeckKind::Normal(ref mut normal) = deck_clone.kind { + normal.desired_retention = Some(0.85); // Set deck-specific desired + // retention + } + col.update_deck(&mut deck_clone)?; + + // Create a card in this deck + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, deck_id)?; + + // Get the card using search_cards + let cards = col.search_cards(note.id, SortMode::NoOrder)?; + let card = col.storage.get_card(cards[0])?.unwrap(); + + // Test that the card state updater uses deck-specific desired retention + let updater = col.card_state_updater(card)?; + + // Print debug information + println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs)); + println!("Desired retention: {:?}", updater.desired_retention); + + // Verify that the desired retention is from the deck, not the config + assert_eq!(updater.desired_retention, Some(0.85)); + + Ok(()) + } + // make sure the 'current' state for a card matches the // state we applied to it #[test] diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index b2640fa3e..d5b2dbe2a 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -205,7 +205,15 @@ impl Collection { .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; - let desired_retention = config.inner.desired_retention; + + // Get deck-specific desired retention if available, otherwise use config + // default + let desired_retention = if let Some(deck_dr) = deck.normal()?.desired_retention { + deck_dr + } else { + config.inner.desired_retention + }; + let historical_retention = config.inner.historical_retention; let params = config.fsrs_params(); let decay = get_decay_from_params(params); diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index cfdea341c..b3b55c8db 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -21,7 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import SwitchRow from "$lib/components/SwitchRow.svelte"; import GlobalLabel from "./GlobalLabel.svelte"; - import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; + import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import Warning from "./Warning.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte"; @@ -32,6 +32,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html UpdateDeckConfigsMode, } from "@generated/anki/deck_config_pb"; import type Modal from "bootstrap/js/dist/modal"; + import TabbedValue from "./TabbedValue.svelte"; + import Item from "$lib/components/Item.svelte"; + import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -42,13 +45,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const defaults = state.defaults; const fsrsReschedule = state.fsrsReschedule; const daysSinceLastOptimization = state.daysSinceLastOptimization; + const limits = state.deckLimits; $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; let desiredRetentionFocused = false; let desiredRetentionEverFocused = false; let optimized = false; - const startingDesiredRetention = $config.desiredRetention.toFixed(2); $: if (desiredRetentionFocused) { desiredRetentionEverFocused = true; } @@ -63,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; - $: roundedRetention = Number($config.desiredRetention.toFixed(2)); + $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2)); $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); let timeoutId: ReturnType | undefined = undefined; @@ -85,9 +88,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; + // Create tabs for desired retention + const desiredRetentionTabs: ValueTab[] = [ + new ValueTab( + tr.deckConfigSharedPreset(), + $config.desiredRetention, + (value) => ($config.desiredRetention = value!), + $config.desiredRetention, + null, + ), + new ValueTab( + tr.deckConfigDeckOnly(), + $limits.desiredRetention ?? null, + (value) => ($limits.desiredRetention = value ?? undefined), + null, + null, + ), + ]; + + let desiredRetentionValue = $config.desiredRetention; + + // Get the effective desired retention value (deck-specific if set, otherwise config default) + $: effectiveDesiredRetention = $limits.desiredRetention ?? $config.desiredRetention; + const startingDesiredRetention = effectiveDesiredRetention.toFixed(2); + $: simulateFsrsRequest = new SimulateFsrsReviewRequest({ params: fsrsParams($config), - desiredRetention: $config.desiredRetention, + desiredRetention: effectiveDesiredRetention, newLimit: $config.newPerDay, reviewLimit: $config.reviewsPerDay, maxInterval: $config.maximumReviewInterval, @@ -301,18 +328,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let simulatorModal: Modal; - - openHelpModal("desiredRetention")}> - {tr.deckConfigDesiredRetention()} - - + + + + + openHelpModal("desiredRetention")}> + {tr.deckConfigDesiredRetention()} + + + + diff --git a/ts/routes/deck-options/SpinBoxFloatRow.svelte b/ts/routes/deck-options/SpinBoxFloatRow.svelte index 5aa93bd30..3b16f32d8 100644 --- a/ts/routes/deck-options/SpinBoxFloatRow.svelte +++ b/ts/routes/deck-options/SpinBoxFloatRow.svelte @@ -23,9 +23,12 @@ - - - - + + + + + + +