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;
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;
}

View file

@ -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

View file

@ -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,

View file

@ -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'));
"""

View file

@ -42,6 +42,7 @@ impl From<QueuedCard> 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

View file

@ -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 {

View file

@ -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<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 {
/// An iterator over the card queues, in the order the cards will
/// be presented.

View file

@ -11,9 +11,9 @@ interface CustomDataStates {
easy: Record<string, unknown>;
}
async function getSchedulingStates(): Promise<Scheduler.SchedulingStates> {
return Scheduler.SchedulingStates.decode(
await postRequest("/_anki/getSchedulingStates", ""),
async function getSchedulingStatesWithContext(): Promise<Scheduler.SchedulingStatesWithContext> {
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<void>,
): Promise<void> {
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);
}