Ensure state mutator runs after card is rendered (#2421)

* Ensure state mutator runs after card is rendered

* Ensure ease buttons only show when states are ready

* Pass context into states mutator

* Revert queuing of state mutator hook

Now that context data is exposed users shouldn't rely on the question
having been rendered anymore.

* Use callbacks instead of signals and timeout

... to track whether the states mutator ran or failed.

* Make mutator async

* Remove State enum

* Reduce requests and compute seed on backend
This commit is contained in:
RumovZ 2023-03-16 07:31:00 +01:00 committed by GitHub
parent de9e2cfc40
commit bd88c6d352
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 84 additions and 19 deletions

View file

@ -108,6 +108,7 @@ message QueuedCards {
cards.Card card = 1; cards.Card card = 1;
Queue queue = 2; Queue queue = 2;
SchedulingStates states = 3; SchedulingStates states = 3;
SchedulingContext context = 4;
} }
repeated QueuedCard cards = 1; 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 { message CustomStudyDefaultsRequest {
int64 deck_id = 1; int64 deck_id = 1;
} }

View file

@ -30,6 +30,8 @@ from anki.utils import int_time
QueuedCards = scheduler_pb2.QueuedCards QueuedCards = scheduler_pb2.QueuedCards
SchedulingState = scheduler_pb2.SchedulingState SchedulingState = scheduler_pb2.SchedulingState
SchedulingStates = scheduler_pb2.SchedulingStates SchedulingStates = scheduler_pb2.SchedulingStates
SchedulingContext = scheduler_pb2.SchedulingContext
SchedulingStatesWithContext = scheduler_pb2.SchedulingStatesWithContext
CardAnswer = scheduler_pb2.CardAnswer CardAnswer = scheduler_pb2.CardAnswer

View file

@ -27,6 +27,7 @@ import aqt.operations
from anki import hooks from anki import hooks
from anki.collection import OpChanges from anki.collection import OpChanges
from anki.decks import UpdateDeckConfigs from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext
from anki.scheduler_pb2 import SchedulingStates from anki.scheduler_pb2 import SchedulingStates
from anki.utils import dev_mode from anki.utils import dev_mode
from aqt.changenotetype import ChangeNotetypeDialog from aqt.changenotetype import ChangeNotetypeDialog
@ -408,11 +409,11 @@ def update_deck_configs() -> bytes:
return b"" return b""
def get_scheduling_states() -> bytes: def get_scheduling_states_with_context() -> bytes:
if states := aqt.mw.reviewer.get_scheduling_states(): return SchedulingStatesWithContext(
return states.SerializeToString() states=aqt.mw.reviewer.get_scheduling_states(),
else: context=aqt.mw.reviewer.get_scheduling_context(),
return b"" ).SerializeToString()
def set_scheduling_states() -> bytes: def set_scheduling_states() -> bytes:
@ -451,7 +452,7 @@ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
update_deck_configs, update_deck_configs,
get_scheduling_states, get_scheduling_states_with_context,
set_scheduling_states, set_scheduling_states,
change_notetype, change_notetype,
import_csv, import_csv,

View file

@ -19,7 +19,7 @@ from anki.collection import Config, OpChanges, OpChangesWithCount
from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.base import ScheduleCardsAsNew
from anki.scheduler.v3 import CardAnswer, QueuedCards from anki.scheduler.v3 import CardAnswer, QueuedCards
from anki.scheduler.v3 import Scheduler as V3Scheduler 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.tags import MARKED_TAG
from anki.types import assert_exhaustive from anki.types import assert_exhaustive
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -83,6 +83,7 @@ class V3CardInfo:
queued_cards: QueuedCards queued_cards: QueuedCards
states: SchedulingStates states: SchedulingStates
context: SchedulingContext
@staticmethod @staticmethod
def from_queue(queued_cards: QueuedCards) -> V3CardInfo: def from_queue(queued_cards: QueuedCards) -> V3CardInfo:
@ -90,8 +91,7 @@ class V3CardInfo:
states = top_card.states states = top_card.states
states.current.custom_data = top_card.card.custom_data states.current.custom_data = top_card.card.custom_data
return V3CardInfo( return V3CardInfo(
queued_cards=queued_cards, queued_cards=queued_cards, states=states, context=top_card.context
states=states,
) )
def top_card(self) -> QueuedCards.QueuedCard: def top_card(self) -> QueuedCards.QueuedCard:
@ -143,6 +143,7 @@ class Reviewer:
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
self._card_info = ReviewerCardInfo(self.mw) self._card_info = ReviewerCardInfo(self.mw)
self._previous_card_info = PreviousReviewerCardInfo(self.mw) self._previous_card_info = PreviousReviewerCardInfo(self.mw)
self._states_mutated = True
hooks.card_did_leech.append(self.onLeech) hooks.card_did_leech.append(self.onLeech)
def show(self) -> None: def show(self) -> None:
@ -267,8 +268,12 @@ class Reviewer:
def get_scheduling_states(self) -> SchedulingStates | None: def get_scheduling_states(self) -> SchedulingStates | None:
if v3 := self._v3: if v3 := self._v3:
return v3.states 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: def set_scheduling_states(self, key: str, states: SchedulingStates) -> None:
if key != self._state_mutation_key: if key != self._state_mutation_key:
@ -278,9 +283,16 @@ class Reviewer:
v3.states = states v3.states = states
def _run_state_mutation_hook(self) -> None: 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): if self._v3 and (js := self._state_mutation_js):
self.web.eval( self._states_mutated = False
f"anki.mutateNextCardStates('{self._state_mutation_key}', (states, customData) => {{ {js} }})" self.web.evalWithCallback(
RUN_STATE_MUTATION.format(key=self._state_mutation_key, js=js),
on_eval,
) )
# Audio # Audio
@ -546,6 +558,8 @@ class Reviewer:
play_clicked_audio(url, self.card) play_clicked_audio(url, self.card)
elif url.startswith("updateToolbar"): elif url.startswith("updateToolbar"):
self.mw.toolbarWeb.update_background_image() self.mw.toolbarWeb.update_background_image()
elif url == "statesMutated":
self._states_mutated = True
else: else:
print("unrecognized anki link:", url) print("unrecognized anki link:", url)
@ -692,6 +706,9 @@ time = %(time)d;
self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime)) self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime))
def _showEaseButtons(self) -> None: def _showEaseButtons(self) -> None:
if not self._states_mutated:
self.mw.progress.single_shot(50, self._showEaseButtons)
return
middle = self._answerButtons() middle = self._answerButtons()
self.bottom.web.eval(f"showAnswer({json.dumps(middle)});") self.bottom.web.eval(f"showAnswer({json.dumps(middle)});")
@ -1034,3 +1051,9 @@ time = %(time)d;
onDelete = delete_current_note onDelete = delete_current_note
onMark = toggle_mark_on_current_note onMark = toggle_mark_on_current_note
setFlag = set_flag_on_current_card setFlag = set_flag_on_current_card
RUN_STATE_MUTATION = """
anki.mutateNextCardStates('{key}', async (states, customData, ctx) => {{ {js} }})
.finally(() => bridgeCommand('statesMutated'));
"""

View file

@ -42,6 +42,7 @@ impl From<QueuedCard> for pb::scheduler::queued_cards::QueuedCard {
Self { Self {
card: Some(queued_card.card.into()), card: Some(queued_card.card.into()),
states: Some(queued_card.states.into()), states: Some(queued_card.states.into()),
context: Some(queued_card.context),
queue: match queued_card.kind { queue: match queued_card.kind {
crate::scheduler::queue::QueueEntryKind::New => { crate::scheduler::queue::QueueEntryKind::New => {
pb::scheduler::queued_cards::Queue::New pb::scheduler::queued_cards::Queue::New

View file

@ -184,6 +184,13 @@ impl Card {
.max(1) as u32; .max(1) as u32;
(remaining != new_remaining).then_some(new_remaining) (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 { impl Collection {

View file

@ -21,6 +21,7 @@ pub(crate) use main::MainQueueEntryKind;
use self::undo::QueueUpdate; use self::undo::QueueUpdate;
use super::states::SchedulingStates; use super::states::SchedulingStates;
use super::timing::SchedTimingToday; use super::timing::SchedTimingToday;
use crate::pb::scheduler::SchedulingContext;
use crate::prelude::*; use crate::prelude::*;
use crate::timestamp::TimestampSecs; use crate::timestamp::TimestampSecs;
@ -56,6 +57,7 @@ pub struct QueuedCard {
pub card: Card, pub card: Card,
pub kind: QueueEntryKind, pub kind: QueueEntryKind,
pub states: SchedulingStates, pub states: SchedulingStates,
pub context: SchedulingContext,
} }
#[derive(Debug)] #[derive(Debug)]
@ -116,6 +118,7 @@ impl Collection {
let next_states = self.get_scheduling_states(card.id)?; let next_states = self.get_scheduling_states(card.id)?;
Ok(QueuedCard { Ok(QueuedCard {
context: SchedulingContext::new(self, &card)?,
card, card,
states: next_states, states: next_states,
kind: entry.kind(), kind: entry.kind(),
@ -131,6 +134,18 @@ impl Collection {
} }
} }
impl SchedulingContext {
fn new(col: &mut Collection, card: &Card) -> Result<Self> {
Ok(Self {
deck_name: col
.get_deck(card.deck_id)?
.or_not_found(card.deck_id)?
.human_name(),
seed: card.review_seed(),
})
}
}
impl CardQueues { impl CardQueues {
/// An iterator over the card queues, in the order the cards will /// An iterator over the card queues, in the order the cards will
/// be presented. /// be presented.

View file

@ -11,9 +11,9 @@ interface CustomDataStates {
easy: Record<string, unknown>; easy: Record<string, unknown>;
} }
async function getSchedulingStates(): Promise<Scheduler.SchedulingStates> { async function getSchedulingStatesWithContext(): Promise<Scheduler.SchedulingStatesWithContext> {
return Scheduler.SchedulingStates.decode( return Scheduler.SchedulingStatesWithContext.decode(
await postRequest("/_anki/getSchedulingStates", ""), await postRequest("/_anki/getSchedulingStatesWithContext", ""),
); );
} }
@ -53,11 +53,16 @@ function packCustomData(
export async function mutateNextCardStates( export async function mutateNextCardStates(
key: string, key: string,
mutator: (states: Scheduler.SchedulingStates, customData: CustomDataStates) => void, mutator: (
states: Scheduler.SchedulingStates,
customData: CustomDataStates,
ctx: Scheduler.SchedulingContext,
) => Promise<void>,
): Promise<void> { ): Promise<void> {
const states = await getSchedulingStates(); const statesWithContext = await getSchedulingStatesWithContext();
const states = statesWithContext.states!;
const customData = unpackCustomData(states); const customData = unpackCustomData(states);
mutator(states, customData); await mutator(states, customData, statesWithContext.context!);
packCustomData(states, customData); packCustomData(states, customData);
await setSchedulingStates(key, states); await setSchedulingStates(key, states);
} }