mirror of
https://github.com/ankitects/anki.git
synced 2026-01-12 21:44:01 -05:00
Feat/per-deck desired retention
This commit is contained in:
parent
51cf09daf3
commit
eef199830e
8 changed files with 126 additions and 23 deletions
|
|
@ -219,6 +219,8 @@ message DeckConfigsForUpdate {
|
||||||
bool review_today_active = 5;
|
bool review_today_active = 5;
|
||||||
// Whether new_today applies to today or a past day.
|
// Whether new_today applies to today or a past day.
|
||||||
bool new_today_active = 6;
|
bool new_today_active = 6;
|
||||||
|
// Deck-specific desired retention override
|
||||||
|
optional float desired_retention = 7;
|
||||||
}
|
}
|
||||||
string name = 1;
|
string name = 1;
|
||||||
int64 config_id = 2;
|
int64 config_id = 2;
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ message Deck {
|
||||||
optional uint32 new_limit = 7;
|
optional uint32 new_limit = 7;
|
||||||
DayLimit review_limit_today = 8;
|
DayLimit review_limit_today = 8;
|
||||||
DayLimit new_limit_today = 9;
|
DayLimit new_limit_today = 9;
|
||||||
|
// Deck-specific desired retention override
|
||||||
|
optional float desired_retention = 10;
|
||||||
|
|
||||||
reserved 12 to 15;
|
reserved 12 to 15;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits {
|
||||||
.new_limit_today
|
.new_limit_today
|
||||||
.map(|limit| limit.today == today)
|
.map(|limit| limit.today == today)
|
||||||
.unwrap_or_default(),
|
.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;
|
deck.new_limit = limits.new;
|
||||||
update_day_limit(&mut deck.review_limit_today, limits.review_today, today);
|
update_day_limit(&mut deck.review_limit_today, limits.review_today, today);
|
||||||
update_day_limit(&mut deck.new_limit_today, limits.new_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<DayLimit>, new_limit: Option<u32>, today: u32) {
|
fn update_day_limit(day_limit: &mut Option<DayLimit>, new_limit: Option<u32>, today: u32) {
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,7 @@ impl From<NormalDeckSchema11> for NormalDeck {
|
||||||
new_limit: deck.new_limit,
|
new_limit: deck.new_limit,
|
||||||
review_limit_today: deck.review_limit_today,
|
review_limit_today: deck.review_limit_today,
|
||||||
new_limit_today: deck.new_limit_today,
|
new_limit_today: deck.new_limit_today,
|
||||||
|
desired_retention: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,15 @@ impl Collection {
|
||||||
.get_deck(card.deck_id)?
|
.get_deck(card.deck_id)?
|
||||||
.or_not_found(card.deck_id)?;
|
.or_not_found(card.deck_id)?;
|
||||||
let config = self.home_deck_config(deck.config_id(), card.original_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_enabled = self.get_config_bool(BoolKey::Fsrs);
|
||||||
let fsrs_next_states = if fsrs_enabled {
|
let fsrs_next_states = if fsrs_enabled {
|
||||||
let params = config.fsrs_params();
|
let params = config.fsrs_params();
|
||||||
|
|
@ -473,13 +482,13 @@ impl Collection {
|
||||||
};
|
};
|
||||||
Some(fsrs.next_states(
|
Some(fsrs.next_states(
|
||||||
card.memory_state.map(Into::into),
|
card.memory_state.map(Into::into),
|
||||||
config.inner.desired_retention,
|
desired_retention,
|
||||||
days_elapsed,
|
days_elapsed,
|
||||||
)?)
|
)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
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 =
|
let fsrs_short_term_with_steps =
|
||||||
self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled);
|
self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled);
|
||||||
let fsrs_allow_short_term = if fsrs_enabled {
|
let fsrs_allow_short_term = if fsrs_enabled {
|
||||||
|
|
@ -662,6 +671,46 @@ pub(crate) mod test {
|
||||||
col.get_scheduling_states(card_id).unwrap().current
|
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
|
// make sure the 'current' state for a card matches the
|
||||||
// state we applied to it
|
// state we applied to it
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,15 @@ impl Collection {
|
||||||
.storage
|
.storage
|
||||||
.get_deck_config(conf_id)?
|
.get_deck_config(conf_id)?
|
||||||
.or_not_found(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 historical_retention = config.inner.historical_retention;
|
||||||
let params = config.fsrs_params();
|
let params = config.fsrs_params();
|
||||||
let decay = get_decay_from_params(params);
|
let decay = get_decay_from_params(params);
|
||||||
|
|
|
||||||
|
|
@ -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 SwitchRow from "$lib/components/SwitchRow.svelte";
|
||||||
|
|
||||||
import GlobalLabel from "./GlobalLabel.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 SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
|
||||||
import Warning from "./Warning.svelte";
|
import Warning from "./Warning.svelte";
|
||||||
import ParamsInputRow from "./ParamsInputRow.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,
|
UpdateDeckConfigsMode,
|
||||||
} from "@generated/anki/deck_config_pb";
|
} from "@generated/anki/deck_config_pb";
|
||||||
import type Modal from "bootstrap/js/dist/modal";
|
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 state: DeckOptionsState;
|
||||||
export let openHelpModal: (String) => void;
|
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 defaults = state.defaults;
|
||||||
const fsrsReschedule = state.fsrsReschedule;
|
const fsrsReschedule = state.fsrsReschedule;
|
||||||
const daysSinceLastOptimization = state.daysSinceLastOptimization;
|
const daysSinceLastOptimization = state.daysSinceLastOptimization;
|
||||||
|
const limits = state.deckLimits;
|
||||||
|
|
||||||
$: lastOptimizationWarning =
|
$: lastOptimizationWarning =
|
||||||
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
|
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
|
||||||
let desiredRetentionFocused = false;
|
let desiredRetentionFocused = false;
|
||||||
let desiredRetentionEverFocused = false;
|
let desiredRetentionEverFocused = false;
|
||||||
let optimized = false;
|
let optimized = false;
|
||||||
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
|
|
||||||
$: if (desiredRetentionFocused) {
|
$: if (desiredRetentionFocused) {
|
||||||
desiredRetentionEverFocused = true;
|
desiredRetentionEverFocused = true;
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
$: computing = computingParams || checkingParams;
|
$: computing = computingParams || checkingParams;
|
||||||
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
|
||||||
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
|
$: roundedRetention = Number(effectiveDesiredRetention.toFixed(2));
|
||||||
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
|
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
|
||||||
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
|
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||||
|
|
@ -85,9 +88,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
|
$: 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({
|
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
|
||||||
params: fsrsParams($config),
|
params: fsrsParams($config),
|
||||||
desiredRetention: $config.desiredRetention,
|
desiredRetention: effectiveDesiredRetention,
|
||||||
newLimit: $config.newPerDay,
|
newLimit: $config.newPerDay,
|
||||||
reviewLimit: $config.reviewsPerDay,
|
reviewLimit: $config.reviewsPerDay,
|
||||||
maxInterval: $config.maximumReviewInterval,
|
maxInterval: $config.maximumReviewInterval,
|
||||||
|
|
@ -301,18 +328,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
let simulatorModal: Modal;
|
let simulatorModal: Modal;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpinBoxFloatRow
|
<DynamicallySlottable slotHost={Item} api={{}}>
|
||||||
bind:value={$config.desiredRetention}
|
<Item>
|
||||||
|
<SpinBoxFloatRow
|
||||||
|
bind:value={desiredRetentionValue}
|
||||||
defaultValue={defaults.desiredRetention}
|
defaultValue={defaults.desiredRetention}
|
||||||
min={0.7}
|
min={0.7}
|
||||||
max={0.99}
|
max={0.99}
|
||||||
percentage={true}
|
percentage={true}
|
||||||
bind:focused={desiredRetentionFocused}
|
bind:focused={desiredRetentionFocused}
|
||||||
>
|
>
|
||||||
|
<TabbedValue
|
||||||
|
slot="tabs"
|
||||||
|
tabs={desiredRetentionTabs}
|
||||||
|
bind:value={desiredRetentionValue}
|
||||||
|
/>
|
||||||
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
||||||
{tr.deckConfigDesiredRetention()}
|
{tr.deckConfigDesiredRetention()}
|
||||||
</SettingTitle>
|
</SettingTitle>
|
||||||
</SpinBoxFloatRow>
|
</SpinBoxFloatRow>
|
||||||
|
</Item>
|
||||||
|
</DynamicallySlottable>
|
||||||
|
|
||||||
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
|
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
|
||||||
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
|
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,12 @@
|
||||||
<slot />
|
<slot />
|
||||||
</Col>
|
</Col>
|
||||||
<Col --col-size={6} breakpoint="xs">
|
<Col --col-size={6} breakpoint="xs">
|
||||||
|
<Row class="flex-grow-1">
|
||||||
|
<slot name="tabs" />
|
||||||
<ConfigInput>
|
<ConfigInput>
|
||||||
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
|
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
|
||||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||||
</ConfigInput>
|
</ConfigInput>
|
||||||
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue