diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index cc4b46834..9aea1069b 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -108,6 +108,7 @@ message QueuedCards { cards.Card card = 1; Queue queue = 2; SchedulingStates states = 3; + SchedulingContext context = 4; } repeated QueuedCard cards = 1; @@ -282,6 +283,16 @@ message CustomStudyRequest { } } +message SchedulingContext { + string deck_name = 1; + uint64 seed = 2; +} + +message SchedulingStatesWithContext { + SchedulingStates states = 1; + SchedulingContext context = 2; +} + message CustomStudyDefaultsRequest { int64 deck_id = 1; } diff --git a/pylib/anki/scheduler/v3.py b/pylib/anki/scheduler/v3.py index 6854542bc..38af43a90 100644 --- a/pylib/anki/scheduler/v3.py +++ b/pylib/anki/scheduler/v3.py @@ -30,6 +30,8 @@ from anki.utils import int_time QueuedCards = scheduler_pb2.QueuedCards SchedulingState = scheduler_pb2.SchedulingState SchedulingStates = scheduler_pb2.SchedulingStates +SchedulingContext = scheduler_pb2.SchedulingContext +SchedulingStatesWithContext = scheduler_pb2.SchedulingStatesWithContext CardAnswer = scheduler_pb2.CardAnswer diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 9eb5a880d..b0a6298e2 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -27,6 +27,7 @@ import aqt.operations from anki import hooks from anki.collection import OpChanges from anki.decks import UpdateDeckConfigs +from anki.scheduler.v3 import SchedulingStatesWithContext from anki.scheduler_pb2 import SchedulingStates from anki.utils import dev_mode from aqt.changenotetype import ChangeNotetypeDialog @@ -408,11 +409,11 @@ def update_deck_configs() -> bytes: return b"" -def get_scheduling_states() -> bytes: - if states := aqt.mw.reviewer.get_scheduling_states(): - return states.SerializeToString() - else: - return b"" +def get_scheduling_states_with_context() -> bytes: + return SchedulingStatesWithContext( + states=aqt.mw.reviewer.get_scheduling_states(), + context=aqt.mw.reviewer.get_scheduling_context(), + ).SerializeToString() def set_scheduling_states() -> bytes: @@ -451,7 +452,7 @@ post_handler_list = [ congrats_info, get_deck_configs_for_update, update_deck_configs, - get_scheduling_states, + get_scheduling_states_with_context, set_scheduling_states, change_notetype, import_csv, diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index b4da9e885..4f548a913 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -19,7 +19,7 @@ from anki.collection import Config, OpChanges, OpChangesWithCount from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import CardAnswer, QueuedCards from anki.scheduler.v3 import Scheduler as V3Scheduler -from anki.scheduler.v3 import SchedulingStates +from anki.scheduler.v3 import SchedulingContext, SchedulingStates from anki.tags import MARKED_TAG from anki.types import assert_exhaustive from aqt import AnkiQt, gui_hooks @@ -83,6 +83,7 @@ class V3CardInfo: queued_cards: QueuedCards states: SchedulingStates + context: SchedulingContext @staticmethod def from_queue(queued_cards: QueuedCards) -> V3CardInfo: @@ -90,8 +91,7 @@ class V3CardInfo: states = top_card.states states.current.custom_data = top_card.card.custom_data return V3CardInfo( - queued_cards=queued_cards, - states=states, + queued_cards=queued_cards, states=states, context=top_card.context ) def top_card(self) -> QueuedCards.QueuedCard: @@ -143,6 +143,7 @@ class Reviewer: self.bottom = BottomBar(mw, mw.bottomWeb) self._card_info = ReviewerCardInfo(self.mw) self._previous_card_info = PreviousReviewerCardInfo(self.mw) + self._states_mutated = True hooks.card_did_leech.append(self.onLeech) def show(self) -> None: @@ -267,8 +268,12 @@ class Reviewer: def get_scheduling_states(self) -> SchedulingStates | None: if v3 := self._v3: return v3.states - else: - return None + return None + + def get_scheduling_context(self) -> SchedulingContext | None: + if v3 := self._v3: + return v3.context + return None def set_scheduling_states(self, key: str, states: SchedulingStates) -> None: if key != self._state_mutation_key: @@ -278,9 +283,16 @@ class Reviewer: v3.states = states def _run_state_mutation_hook(self) -> None: + def on_eval(result: Any) -> None: + if result is None: + # eval failed, usually a syntax error + self._states_mutated = True + if self._v3 and (js := self._state_mutation_js): - self.web.eval( - f"anki.mutateNextCardStates('{self._state_mutation_key}', (states, customData) => {{ {js} }})" + self._states_mutated = False + self.web.evalWithCallback( + RUN_STATE_MUTATION.format(key=self._state_mutation_key, js=js), + on_eval, ) # Audio @@ -546,6 +558,8 @@ class Reviewer: play_clicked_audio(url, self.card) elif url.startswith("updateToolbar"): self.mw.toolbarWeb.update_background_image() + elif url == "statesMutated": + self._states_mutated = True else: print("unrecognized anki link:", url) @@ -692,6 +706,9 @@ time = %(time)d; self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime)) def _showEaseButtons(self) -> None: + if not self._states_mutated: + self.mw.progress.single_shot(50, self._showEaseButtons) + return middle = self._answerButtons() self.bottom.web.eval(f"showAnswer({json.dumps(middle)});") @@ -1034,3 +1051,9 @@ time = %(time)d; onDelete = delete_current_note onMark = toggle_mark_on_current_note setFlag = set_flag_on_current_card + + +RUN_STATE_MUTATION = """ +anki.mutateNextCardStates('{key}', async (states, customData, ctx) => {{ {js} }}) + .finally(() => bridgeCommand('statesMutated')); +""" diff --git a/rslib/src/backend/scheduler/answering.rs b/rslib/src/backend/scheduler/answering.rs index 5dfeb944f..ffbfe3dfb 100644 --- a/rslib/src/backend/scheduler/answering.rs +++ b/rslib/src/backend/scheduler/answering.rs @@ -42,6 +42,7 @@ impl From for pb::scheduler::queued_cards::QueuedCard { Self { card: Some(queued_card.card.into()), states: Some(queued_card.states.into()), + context: Some(queued_card.context), queue: match queued_card.kind { crate::scheduler::queue::QueueEntryKind::New => { pb::scheduler::queued_cards::Queue::New diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 9c36797b1..0d739be81 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -184,6 +184,13 @@ impl Card { .max(1) as u32; (remaining != new_remaining).then_some(new_remaining) } + + /// Supposedly unique across all reviews in the collection. + pub fn review_seed(&self) -> u64 { + (self.id.0 as u64) + .rotate_left(8) + .wrapping_add(self.reps as u64) + } } impl Collection { diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs index 5a86965dd..594e73f28 100644 --- a/rslib/src/scheduler/queue/mod.rs +++ b/rslib/src/scheduler/queue/mod.rs @@ -21,6 +21,7 @@ pub(crate) use main::MainQueueEntryKind; use self::undo::QueueUpdate; use super::states::SchedulingStates; use super::timing::SchedTimingToday; +use crate::pb::scheduler::SchedulingContext; use crate::prelude::*; use crate::timestamp::TimestampSecs; @@ -56,6 +57,7 @@ pub struct QueuedCard { pub card: Card, pub kind: QueueEntryKind, pub states: SchedulingStates, + pub context: SchedulingContext, } #[derive(Debug)] @@ -116,6 +118,7 @@ impl Collection { let next_states = self.get_scheduling_states(card.id)?; Ok(QueuedCard { + context: SchedulingContext::new(self, &card)?, card, states: next_states, kind: entry.kind(), @@ -131,6 +134,18 @@ impl Collection { } } +impl SchedulingContext { + fn new(col: &mut Collection, card: &Card) -> Result { + Ok(Self { + deck_name: col + .get_deck(card.deck_id)? + .or_not_found(card.deck_id)? + .human_name(), + seed: card.review_seed(), + }) + } +} + impl CardQueues { /// An iterator over the card queues, in the order the cards will /// be presented. diff --git a/ts/reviewer/answering.ts b/ts/reviewer/answering.ts index b57712dce..f13397d6a 100644 --- a/ts/reviewer/answering.ts +++ b/ts/reviewer/answering.ts @@ -11,9 +11,9 @@ interface CustomDataStates { easy: Record; } -async function getSchedulingStates(): Promise { - return Scheduler.SchedulingStates.decode( - await postRequest("/_anki/getSchedulingStates", ""), +async function getSchedulingStatesWithContext(): Promise { + return Scheduler.SchedulingStatesWithContext.decode( + await postRequest("/_anki/getSchedulingStatesWithContext", ""), ); } @@ -53,11 +53,16 @@ function packCustomData( export async function mutateNextCardStates( key: string, - mutator: (states: Scheduler.SchedulingStates, customData: CustomDataStates) => void, + mutator: ( + states: Scheduler.SchedulingStates, + customData: CustomDataStates, + ctx: Scheduler.SchedulingContext, + ) => Promise, ): Promise { - const states = await getSchedulingStates(); + const statesWithContext = await getSchedulingStatesWithContext(); + const states = statesWithContext.states!; const customData = unpackCustomData(states); - mutator(states, customData); + await mutator(states, customData, statesWithContext.context!); packCustomData(states, customData); await setSchedulingStates(key, states); }