Enable state-dependent custom scheduling data (#2049)

* Enable state-dependent custom scheduling data

* Next(Card)States -> SchedulingStates

The fact that `current` was included in `next` always bothered me,
and custom data is part of the card state, so that was a bit confusing
too.

* Store custom_data in SchedulingState

* Make custom_data optional when answering

Avoids having to send it 4 extra times to the frontend, and avoids the
legacy answerCard() API clobbering the stored data.

Co-authored-by: Damien Elmes <gpg@ankiweb.net>
This commit is contained in:
RumovZ 2022-09-05 08:48:01 +02:00 committed by GitHub
parent 0bcb3a3564
commit e39fb74e82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 157 additions and 134 deletions

View file

@ -37,8 +37,8 @@ service SchedulerService {
rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges); rpc SetDueDate(SetDueDateRequest) returns (collection.OpChanges);
rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount); rpc SortCards(SortCardsRequest) returns (collection.OpChangesWithCount);
rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount); rpc SortDeck(SortDeckRequest) returns (collection.OpChangesWithCount);
rpc GetNextCardStates(cards.CardId) returns (NextCardStates); rpc GetSchedulingStates(cards.CardId) returns (SchedulingStates);
rpc DescribeNextStates(NextCardStates) returns (generic.StringList); rpc DescribeNextStates(SchedulingStates) returns (generic.StringList);
rpc StateIsLeech(SchedulingState) returns (generic.Bool); rpc StateIsLeech(SchedulingState) returns (generic.Bool);
rpc UpgradeScheduler(generic.Empty) returns (generic.Empty); rpc UpgradeScheduler(generic.Empty) returns (generic.Empty);
rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges); rpc CustomStudy(CustomStudyRequest) returns (collection.OpChanges);
@ -92,6 +92,10 @@ message SchedulingState {
Normal normal = 1; Normal normal = 1;
Filtered filtered = 2; Filtered filtered = 2;
} }
// The backend does not populate this field in GetQueuedCards; the front-end
// is expected to populate it based on the provided Card. If it's not set when
// answering a card, the existing custom data will not be updated.
optional string custom_data = 3;
} }
message QueuedCards { message QueuedCards {
@ -103,7 +107,7 @@ message QueuedCards {
message QueuedCard { message QueuedCard {
cards.Card card = 1; cards.Card card = 1;
Queue queue = 2; Queue queue = 2;
NextCardStates next_states = 3; SchedulingStates states = 3;
} }
repeated QueuedCard cards = 1; repeated QueuedCard cards = 1;
@ -218,7 +222,7 @@ message SortDeckRequest {
bool randomize = 2; bool randomize = 2;
} }
message NextCardStates { message SchedulingStates {
SchedulingState current = 1; SchedulingState current = 1;
SchedulingState again = 2; SchedulingState again = 2;
SchedulingState hard = 3; SchedulingState hard = 3;
@ -240,7 +244,6 @@ message CardAnswer {
Rating rating = 4; Rating rating = 4;
int64 answered_at_millis = 5; int64 answered_at_millis = 5;
uint32 milliseconds_taken = 6; uint32 milliseconds_taken = 6;
string custom_data = 7;
} }
message CustomStudyRequest { message CustomStudyRequest {
@ -304,9 +307,3 @@ message RepositionDefaultsResponse {
bool random = 1; bool random = 1;
bool shift = 2; bool shift = 2;
} }
// Data required to support the v3 scheduler's custom scheduling feature
message CustomScheduling {
NextCardStates states = 1;
string custom_data = 2;
}

View file

@ -29,9 +29,8 @@ from anki.utils import int_time
QueuedCards = scheduler_pb2.QueuedCards QueuedCards = scheduler_pb2.QueuedCards
SchedulingState = scheduler_pb2.SchedulingState SchedulingState = scheduler_pb2.SchedulingState
NextStates = scheduler_pb2.NextCardStates SchedulingStates = scheduler_pb2.SchedulingStates
CardAnswer = scheduler_pb2.CardAnswer CardAnswer = scheduler_pb2.CardAnswer
CustomScheduling = scheduler_pb2.CustomScheduling
class Scheduler(SchedulerBaseWithLegacy): class Scheduler(SchedulerBaseWithLegacy):
@ -54,7 +53,7 @@ class Scheduler(SchedulerBaseWithLegacy):
fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only
) )
def describe_next_states(self, next_states: NextStates) -> Sequence[str]: def describe_next_states(self, next_states: SchedulingStates) -> Sequence[str]:
"Labels for each of the answer buttons." "Labels for each of the answer buttons."
return self.col._backend.describe_next_states(next_states) return self.col._backend.describe_next_states(next_states)
@ -65,8 +64,7 @@ class Scheduler(SchedulerBaseWithLegacy):
self, self,
*, *,
card: Card, card: Card,
states: NextStates, states: SchedulingStates,
custom_data: str,
rating: CardAnswer.Rating.V, rating: CardAnswer.Rating.V,
) -> CardAnswer: ) -> CardAnswer:
"Build input for answer_card()." "Build input for answer_card()."
@ -85,7 +83,6 @@ class Scheduler(SchedulerBaseWithLegacy):
card_id=card.id, card_id=card.id,
current_state=states.current, current_state=states.current,
new_state=new_state, new_state=new_state,
custom_data=custom_data,
rating=rating, rating=rating,
answered_at_millis=int_time(1000), answered_at_millis=int_time(1000),
milliseconds_taken=card.time_taken(capped=False), milliseconds_taken=card.time_taken(capped=False),
@ -150,7 +147,7 @@ class Scheduler(SchedulerBaseWithLegacy):
def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str: def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str:
"Return the next interval for CARD as a string." "Return the next interval for CARD as a string."
states = self.col._backend.get_next_card_states(card.id) states = self.col._backend.get_scheduling_states(card.id)
return self.col._backend.describe_next_states(states)[ease - 1] return self.col._backend.describe_next_states(states)[ease - 1]
# Answering a card (legacy API) # Answering a card (legacy API)
@ -168,11 +165,9 @@ class Scheduler(SchedulerBaseWithLegacy):
else: else:
raise Exception("invalid ease") raise Exception("invalid ease")
states = self.col._backend.get_next_card_states(card.id) states = self.col._backend.get_scheduling_states(card.id)
changes = self.answer_card( changes = self.answer_card(
self.build_answer( self.build_answer(card=card, states=states, rating=rating)
card=card, states=states, custom_data=card.custom_data, rating=rating
)
) )
# tests assume card will be mutated, so we need to reload it # tests assume card will be mutated, so we need to reload it
@ -224,7 +219,7 @@ class Scheduler(SchedulerBaseWithLegacy):
def nextIvl(self, card: Card, ease: int) -> Any: def nextIvl(self, card: Card, ease: int) -> Any:
"Don't use this - it is only required by tests, and will be moved in the future." "Don't use this - it is only required by tests, and will be moved in the future."
states = self.col._backend.get_next_card_states(card.id) states = self.col._backend.get_scheduling_states(card.id)
if ease == BUTTON_ONE: if ease == BUTTON_ONE:
new_state = states.again new_state = states.again
elif ease == BUTTON_TWO: elif ease == BUTTON_TWO:

View file

@ -27,7 +27,7 @@ from anki import hooks
from anki._vendor import stringcase from anki._vendor import stringcase
from anki.collection import OpChanges from anki.collection import OpChanges
from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs from anki.decks import DeckConfigsForUpdate, UpdateDeckConfigs
from anki.scheduler.v3 import CustomScheduling 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
from aqt.deckoptions import DeckOptionsDialog from aqt.deckoptions import DeckOptionsDialog
@ -412,18 +412,18 @@ def update_deck_configs() -> bytes:
return b"" return b""
def get_custom_scheduling() -> bytes: def get_scheduling_states() -> bytes:
if scheduling := aqt.mw.reviewer.get_custom_scheduling(): if states := aqt.mw.reviewer.get_scheduling_states():
return scheduling.SerializeToString() return states.SerializeToString()
else: else:
return b"" return b""
def set_custom_scheduling() -> bytes: def set_scheduling_states() -> bytes:
key = request.headers.get("key", "") key = request.headers.get("key", "")
input = CustomScheduling() states = SchedulingStates()
input.ParseFromString(request.data) states.ParseFromString(request.data)
aqt.mw.reviewer.set_custom_scheduling(key, input) aqt.mw.reviewer.set_scheduling_states(key, states)
return b"" return b""
@ -455,8 +455,8 @@ post_handler_list = [
congrats_info, congrats_info,
get_deck_configs_for_update, get_deck_configs_for_update,
update_deck_configs, update_deck_configs,
get_custom_scheduling, get_scheduling_states,
set_custom_scheduling, set_scheduling_states,
change_notetype, change_notetype,
import_csv, import_csv,
] ]

View file

@ -17,8 +17,9 @@ from anki import hooks
from anki.cards import Card, CardId from anki.cards import Card, CardId
from anki.collection import Config, OpChanges, OpChangesWithCount 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, CustomScheduling, NextStates, 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.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
@ -74,22 +75,23 @@ def replay_audio(card: Card, question_side: bool) -> None:
@dataclass @dataclass
class V3CardInfo: class V3CardInfo:
"""2021 test scheduler info. """Stores the top of the card queue for the v3 scheduler.
next_states is copied from the top card on initialization, and can be This includes current and potential next states of the displayed card,
mutated to alter the default scheduling. which may be mutated by a user's custom scheduling.
""" """
queued_cards: QueuedCards queued_cards: QueuedCards
next_states: NextStates states: SchedulingStates
custom_data: str
@staticmethod @staticmethod
def from_queue(queued_cards: QueuedCards) -> V3CardInfo: def from_queue(queued_cards: QueuedCards) -> V3CardInfo:
top_card = queued_cards.cards[0]
states = top_card.states
states.current.custom_data = top_card.card.custom_data
return V3CardInfo( return V3CardInfo(
queued_cards=queued_cards, queued_cards=queued_cards,
next_states=queued_cards.cards[0].next_states, states=states,
custom_data=queued_cards.cards[0].card.custom_data,
) )
def top_card(self) -> QueuedCards.QueuedCard: def top_card(self) -> QueuedCards.QueuedCard:
@ -262,19 +264,18 @@ class Reviewer:
self.card = Card(self.mw.col, backend_card=self._v3.top_card().card) self.card = Card(self.mw.col, backend_card=self._v3.top_card().card)
self.card.start_timer() self.card.start_timer()
def get_custom_scheduling(self) -> CustomScheduling | None: def get_scheduling_states(self) -> SchedulingStates | None:
if v3 := self._v3: if v3 := self._v3:
return CustomScheduling(states=v3.next_states, custom_data=v3.custom_data) return v3.states
else: else:
return None return None
def set_custom_scheduling(self, key: str, scheduling: CustomScheduling) -> None: def set_scheduling_states(self, key: str, states: SchedulingStates) -> None:
if key != self._state_mutation_key: if key != self._state_mutation_key:
return return
if v3 := self._v3: if v3 := self._v3:
v3.next_states = scheduling.states v3.states = states
v3.custom_data = scheduling.custom_data
def _run_state_mutation_hook(self) -> None: def _run_state_mutation_hook(self) -> None:
if self._v3 and (js := self._state_mutation_js): if self._v3 and (js := self._state_mutation_js):
@ -436,8 +437,7 @@ class Reviewer:
if (v3 := self._v3) and (sched := cast(V3Scheduler, self.mw.col.sched)): if (v3 := self._v3) and (sched := cast(V3Scheduler, self.mw.col.sched)):
answer = sched.build_answer( answer = sched.build_answer(
card=self.card, card=self.card,
states=v3.next_states, states=v3.states,
custom_data=v3.custom_data,
rating=v3.rating_from_ease(ease), rating=v3.rating_from_ease(ease),
) )
@ -771,7 +771,7 @@ time = %(time)d;
if v3 := self._v3: if v3 := self._v3:
assert isinstance(self.mw.col.sched, V3Scheduler) assert isinstance(self.mw.col.sched, V3Scheduler)
labels = self.mw.col.sched.describe_next_states(v3.next_states) labels = self.mw.col.sched.describe_next_states(v3.states)
else: else:
labels = None labels = None

View file

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::mem;
use crate::{ use crate::{
pb, pb,
prelude::*, prelude::*,
@ -11,15 +13,17 @@ use crate::{
}; };
impl From<pb::CardAnswer> for CardAnswer { impl From<pb::CardAnswer> for CardAnswer {
fn from(answer: pb::CardAnswer) -> Self { fn from(mut answer: pb::CardAnswer) -> Self {
let mut new_state = mem::take(&mut answer.new_state).unwrap_or_default();
let custom_data = mem::take(&mut new_state.custom_data);
CardAnswer { CardAnswer {
card_id: CardId(answer.card_id), card_id: CardId(answer.card_id),
rating: answer.rating().into(), rating: answer.rating().into(),
current_state: answer.current_state.unwrap_or_default().into(), current_state: answer.current_state.unwrap_or_default().into(),
new_state: answer.new_state.unwrap_or_default().into(), new_state: new_state.into(),
answered_at: TimestampMillis(answer.answered_at_millis), answered_at: TimestampMillis(answer.answered_at_millis),
milliseconds_taken: answer.milliseconds_taken, milliseconds_taken: answer.milliseconds_taken,
custom_data: answer.custom_data, custom_data,
} }
} }
} }
@ -39,7 +43,7 @@ impl From<QueuedCard> for pb::queued_cards::QueuedCard {
fn from(queued_card: QueuedCard) -> Self { fn from(queued_card: QueuedCard) -> Self {
Self { Self {
card: Some(queued_card.card.into()), card: Some(queued_card.card.into()),
next_states: Some(queued_card.next_states.into()), states: Some(queued_card.states.into()),
queue: match queued_card.kind { queue: match queued_card.kind {
crate::scheduler::queue::QueueEntryKind::New => pb::queued_cards::Queue::New, crate::scheduler::queue::QueueEntryKind::New => pb::queued_cards::Queue::New,
crate::scheduler::queue::QueueEntryKind::Review => pb::queued_cards::Queue::Review, crate::scheduler::queue::QueueEntryKind::Review => pb::queued_cards::Queue::Review,

View file

@ -11,7 +11,7 @@ use crate::{
prelude::*, prelude::*,
scheduler::{ scheduler::{
new::NewCardDueOrder, new::NewCardDueOrder,
states::{CardState, NextCardStates}, states::{CardState, SchedulingStates},
}, },
stats::studied_today, stats::studied_today,
}; };
@ -168,14 +168,14 @@ impl SchedulerService for Backend {
}) })
} }
fn get_next_card_states(&self, input: pb::CardId) -> Result<pb::NextCardStates> { fn get_scheduling_states(&self, input: pb::CardId) -> Result<pb::SchedulingStates> {
let cid: CardId = input.into(); let cid: CardId = input.into();
self.with_col(|col| col.get_next_card_states(cid)) self.with_col(|col| col.get_scheduling_states(cid))
.map(Into::into) .map(Into::into)
} }
fn describe_next_states(&self, input: pb::NextCardStates) -> Result<pb::StringList> { fn describe_next_states(&self, input: pb::SchedulingStates) -> Result<pb::StringList> {
let states: NextCardStates = input.into(); let states: SchedulingStates = input.into();
self.with_col(|col| col.describe_next_states(states)) self.with_col(|col| col.describe_next_states(states))
.map(Into::into) .map(Into::into)
} }

View file

@ -12,12 +12,12 @@ mod review;
use crate::{ use crate::{
pb, pb,
scheduler::states::{CardState, NewState, NextCardStates, NormalState}, scheduler::states::{CardState, NewState, NormalState, SchedulingStates},
}; };
impl From<NextCardStates> for pb::NextCardStates { impl From<SchedulingStates> for pb::SchedulingStates {
fn from(choices: NextCardStates) -> Self { fn from(choices: SchedulingStates) -> Self {
pb::NextCardStates { pb::SchedulingStates {
current: Some(choices.current.into()), current: Some(choices.current.into()),
again: Some(choices.again.into()), again: Some(choices.again.into()),
hard: Some(choices.hard.into()), hard: Some(choices.hard.into()),
@ -27,9 +27,9 @@ impl From<NextCardStates> for pb::NextCardStates {
} }
} }
impl From<pb::NextCardStates> for NextCardStates { impl From<pb::SchedulingStates> for SchedulingStates {
fn from(choices: pb::NextCardStates) -> Self { fn from(choices: pb::SchedulingStates) -> Self {
NextCardStates { SchedulingStates {
current: choices.current.unwrap_or_default().into(), current: choices.current.unwrap_or_default().into(),
again: choices.again.unwrap_or_default().into(), again: choices.again.unwrap_or_default().into(),
hard: choices.hard.unwrap_or_default().into(), hard: choices.hard.unwrap_or_default().into(),
@ -46,6 +46,7 @@ impl From<CardState> for pb::SchedulingState {
CardState::Normal(state) => pb::scheduling_state::Value::Normal(state.into()), CardState::Normal(state) => pb::scheduling_state::Value::Normal(state.into()),
CardState::Filtered(state) => pb::scheduling_state::Value::Filtered(state.into()), CardState::Filtered(state) => pb::scheduling_state::Value::Filtered(state.into()),
}), }),
custom_data: None,
} }
} }
} }

View file

@ -13,7 +13,7 @@ use revlog::RevlogEntryPartial;
use super::{ use super::{
states::{ states::{
steps::LearningSteps, CardState, FilteredState, NextCardStates, NormalState, StateContext, steps::LearningSteps, CardState, FilteredState, NormalState, SchedulingStates, StateContext,
}, },
timespan::answer_button_time_collapsible, timespan::answer_button_time_collapsible,
timing::SchedTimingToday, timing::SchedTimingToday,
@ -41,7 +41,7 @@ pub struct CardAnswer {
pub rating: Rating, pub rating: Rating,
pub answered_at: TimestampMillis, pub answered_at: TimestampMillis,
pub milliseconds_taken: u32, pub milliseconds_taken: u32,
pub custom_data: String, pub custom_data: Option<String>,
} }
impl CardAnswer { impl CardAnswer {
@ -189,7 +189,7 @@ impl Rating {
impl Collection { impl Collection {
/// Return the next states that will be applied for each answer button. /// Return the next states that will be applied for each answer button.
pub fn get_next_card_states(&mut self, cid: CardId) -> Result<NextCardStates> { pub fn get_scheduling_states(&mut self, cid: CardId) -> Result<SchedulingStates> {
let card = self.storage.get_card(cid)?.ok_or(AnkiError::NotFound)?; let card = self.storage.get_card(cid)?.ok_or(AnkiError::NotFound)?;
let ctx = self.card_state_updater(card)?; let ctx = self.card_state_updater(card)?;
let current = ctx.current_card_state(); let current = ctx.current_card_state();
@ -198,7 +198,7 @@ impl Collection {
} }
/// Describe the next intervals, to display on the answer buttons. /// Describe the next intervals, to display on the answer buttons.
pub fn describe_next_states(&mut self, choices: NextCardStates) -> Result<Vec<String>> { pub fn describe_next_states(&mut self, choices: SchedulingStates) -> Result<Vec<String>> {
let collapse_time = self.learn_ahead_secs(); let collapse_time = self.learn_ahead_secs();
let now = TimestampSecs::now(); let now = TimestampSecs::now();
let timing = self.timing_for_timestamp(now)?; let timing = self.timing_for_timestamp(now)?;
@ -274,8 +274,10 @@ impl Collection {
self.maybe_bury_siblings(&original, &updater.config)?; self.maybe_bury_siblings(&original, &updater.config)?;
let timing = updater.timing; let timing = updater.timing;
let mut card = updater.into_card(); let mut card = updater.into_card();
card.custom_data = answer.custom_data.clone(); if let Some(data) = answer.custom_data.take() {
card.custom_data = data;
card.validate_custom_data()?; card.validate_custom_data()?;
}
self.update_card_inner(&mut card, original, usn)?; self.update_card_inner(&mut card, original, usn)?;
if answer.new_state.leeched() { if answer.new_state.leeched() {
self.add_leech_tag(card.note_id)?; self.add_leech_tag(card.note_id)?;
@ -411,18 +413,18 @@ pub mod test_helpers {
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState> fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>
where where
F: FnOnce(&NextCardStates) -> CardState, F: FnOnce(&SchedulingStates) -> CardState,
{ {
let queued = self.get_next_card()?.unwrap(); let queued = self.get_next_card()?.unwrap();
let new_state = get_state(&queued.next_states); let new_state = get_state(&queued.states);
self.answer_card(&mut CardAnswer { self.answer_card(&mut CardAnswer {
card_id: queued.card.id, card_id: queued.card.id,
current_state: queued.next_states.current, current_state: queued.states.current,
new_state, new_state,
rating, rating,
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: String::new(), custom_data: None,
})?; })?;
Ok(PostAnswerState { Ok(PostAnswerState {
card_id: queued.card.id, card_id: queued.card.id,
@ -456,7 +458,7 @@ mod test {
}; };
fn current_state(col: &mut Collection, card_id: CardId) -> CardState { fn current_state(col: &mut Collection, card_id: CardId) -> CardState {
col.get_next_card_states(card_id).unwrap().current col.get_scheduling_states(card_id).unwrap().current
} }
// make sure the 'current' state for a card matches the // make sure the 'current' state for a card matches the

View file

@ -70,7 +70,7 @@ mod test {
col.add_or_update_deck(&mut filtered_deck)?; col.add_or_update_deck(&mut filtered_deck)?;
assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?.output, 1); assert_eq!(col.rebuild_filtered_deck(filtered_deck.id)?.output, 1);
let next = col.get_next_card_states(c.id)?; let next = col.get_scheduling_states(c.id)?;
assert!(matches!( assert!(matches!(
next.current, next.current,
CardState::Filtered(FilteredState::Preview(_)) CardState::Filtered(FilteredState::Preview(_))
@ -92,14 +92,14 @@ mod test {
rating: Rating::Again, rating: Rating::Again,
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: String::new(), custom_data: None,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::PreviewRepeat); assert_eq!(c.queue, CardQueue::PreviewRepeat);
// hard // hard
let next = col.get_next_card_states(c.id)?; let next = col.get_scheduling_states(c.id)?;
col.answer_card(&mut CardAnswer { col.answer_card(&mut CardAnswer {
card_id: c.id, card_id: c.id,
current_state: next.current, current_state: next.current,
@ -107,13 +107,13 @@ mod test {
rating: Rating::Hard, rating: Rating::Hard,
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: String::new(), custom_data: None,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::PreviewRepeat); assert_eq!(c.queue, CardQueue::PreviewRepeat);
// good // good
let next = col.get_next_card_states(c.id)?; let next = col.get_scheduling_states(c.id)?;
col.answer_card(&mut CardAnswer { col.answer_card(&mut CardAnswer {
card_id: c.id, card_id: c.id,
current_state: next.current, current_state: next.current,
@ -121,13 +121,13 @@ mod test {
rating: Rating::Good, rating: Rating::Good,
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: String::new(), custom_data: None,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::PreviewRepeat); assert_eq!(c.queue, CardQueue::PreviewRepeat);
// and then it should return to its old state once easy selected // and then it should return to its old state once easy selected
let next = col.get_next_card_states(c.id)?; let next = col.get_scheduling_states(c.id)?;
col.answer_card(&mut CardAnswer { col.answer_card(&mut CardAnswer {
card_id: c.id, card_id: c.id,
current_state: next.current, current_state: next.current,
@ -135,7 +135,7 @@ mod test {
rating: Rating::Easy, rating: Rating::Easy,
answered_at: TimestampMillis::now(), answered_at: TimestampMillis::now(),
milliseconds_taken: 0, milliseconds_taken: 0,
custom_data: String::new(), custom_data: None,
})?; })?;
c = col.storage.get_card(c.id)?.unwrap(); c = col.storage.get_card(c.id)?.unwrap();
assert_eq!(c.queue, CardQueue::DayLearn); assert_eq!(c.queue, CardQueue::DayLearn);

View file

@ -15,7 +15,7 @@ pub(crate) use learning::LearningQueueEntry;
pub(crate) use main::{MainQueueEntry, MainQueueEntryKind}; pub(crate) use main::{MainQueueEntry, MainQueueEntryKind};
use self::undo::QueueUpdate; use self::undo::QueueUpdate;
use super::{states::NextCardStates, timing::SchedTimingToday}; use super::{states::SchedulingStates, timing::SchedTimingToday};
use crate::{prelude::*, timestamp::TimestampSecs}; use crate::{prelude::*, timestamp::TimestampSecs};
#[derive(Debug)] #[derive(Debug)]
@ -49,7 +49,7 @@ impl Counts {
pub struct QueuedCard { pub struct QueuedCard {
pub card: Card, pub card: Card,
pub kind: QueueEntryKind, pub kind: QueueEntryKind,
pub next_states: NextCardStates, pub states: SchedulingStates,
} }
#[derive(Debug)] #[derive(Debug)]
@ -96,11 +96,11 @@ impl Collection {
} }
// fixme: pass in card instead of id // fixme: pass in card instead of id
let next_states = self.get_next_card_states(card.id)?; let next_states = self.get_scheduling_states(card.id)?;
Ok(QueuedCard { Ok(QueuedCard {
card, card,
next_states, states: next_states,
kind: entry.kind(), kind: entry.kind(),
}) })
}) })

View file

@ -2,7 +2,8 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{ use super::{
IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, ReviewState, StateContext, IntervalKind, PreviewState, ReschedulingFilterState, ReviewState, SchedulingStates,
StateContext,
}; };
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
@ -27,7 +28,7 @@ impl FilteredState {
} }
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
match self { match self {
FilteredState::Preview(state) => state.next_states(ctx), FilteredState::Preview(state) => state.next_states(ctx),
FilteredState::Rescheduling(state) => state.next_states(ctx), FilteredState::Rescheduling(state) => state.next_states(ctx),

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{interval_kind::IntervalKind, CardState, NextCardStates, ReviewState, StateContext}; use super::{interval_kind::IntervalKind, CardState, ReviewState, SchedulingStates, StateContext};
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@ -19,8 +19,8 @@ impl LearnState {
RevlogReviewKind::Learning RevlogReviewKind::Learning
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
again: self.answer_again(ctx).into(), again: self.answer_again(ctx).into(),
hard: self.answer_hard(ctx), hard: self.answer_hard(ctx),

View file

@ -47,7 +47,7 @@ impl CardState {
} }
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
match self { match self {
CardState::Normal(state) => state.next_states(ctx), CardState::Normal(state) => state.next_states(ctx),
CardState::Filtered(state) => state.next_states(ctx), CardState::Filtered(state) => state.next_states(ctx),
@ -139,7 +139,7 @@ impl<'a> StateContext<'a> {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NextCardStates { pub struct SchedulingStates {
pub current: CardState, pub current: CardState,
pub again: CardState, pub again: CardState,
pub hard: CardState, pub hard: CardState,

View file

@ -2,7 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{ use super::{
interval_kind::IntervalKind, LearnState, NewState, NextCardStates, RelearnState, ReviewState, interval_kind::IntervalKind, LearnState, NewState, RelearnState, ReviewState, SchedulingStates,
StateContext, StateContext,
}; };
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
@ -34,7 +34,7 @@ impl NormalState {
} }
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
match self { match self {
NormalState::New(_) => { NormalState::New(_) => {
// New state acts like answering a failed learning card // New state acts like answering a failed learning card
@ -44,7 +44,7 @@ impl NormalState {
} }
.next_states(ctx); .next_states(ctx);
// .. but with current as New, not Learning // .. but with current as New, not Learning
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
..next_states ..next_states
} }

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{IntervalKind, NextCardStates, StateContext}; use super::{IntervalKind, SchedulingStates, StateContext};
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
@ -19,8 +19,8 @@ impl PreviewState {
RevlogReviewKind::Filtered RevlogReviewKind::Filtered
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
again: PreviewState { again: PreviewState {
scheduled_secs: ctx.preview_step * 60, scheduled_secs: ctx.preview_step * 60,

View file

@ -2,7 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{ use super::{
interval_kind::IntervalKind, CardState, LearnState, NextCardStates, ReviewState, StateContext, interval_kind::IntervalKind, CardState, LearnState, ReviewState, SchedulingStates, StateContext,
}; };
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
@ -21,8 +21,8 @@ impl RelearnState {
RevlogReviewKind::Relearning RevlogReviewKind::Relearning
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
again: self.answer_again(ctx), again: self.answer_again(ctx),
hard: self.answer_hard(ctx), hard: self.answer_hard(ctx),

View file

@ -2,7 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{ use super::{
interval_kind::IntervalKind, normal::NormalState, CardState, NextCardStates, StateContext, interval_kind::IntervalKind, normal::NormalState, CardState, SchedulingStates, StateContext,
}; };
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
@ -20,10 +20,10 @@ impl ReschedulingFilterState {
self.original_state.revlog_kind() self.original_state.revlog_kind()
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
let normal = self.original_state.next_states(ctx); let normal = self.original_state.next_states(ctx);
if ctx.in_filtered_deck { if ctx.in_filtered_deck {
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
again: maybe_wrap(normal.again), again: maybe_wrap(normal.again),
hard: maybe_wrap(normal.hard), hard: maybe_wrap(normal.hard),

View file

@ -2,7 +2,8 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::{ use super::{
interval_kind::IntervalKind, CardState, LearnState, NextCardStates, RelearnState, StateContext, interval_kind::IntervalKind, CardState, LearnState, RelearnState, SchedulingStates,
StateContext,
}; };
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
@ -52,10 +53,10 @@ impl ReviewState {
} }
} }
pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates {
let (hard_interval, good_interval, easy_interval) = self.passing_review_intervals(ctx); let (hard_interval, good_interval, easy_interval) = self.passing_review_intervals(ctx);
NextCardStates { SchedulingStates {
current: self.into(), current: self.into(),
again: self.answer_again(ctx), again: self.answer_again(ctx),
hard: self.answer_hard(hard_interval).into(), hard: self.answer_hard(hard_interval).into(),

View file

@ -4,38 +4,60 @@
import { postRequest } from "../lib/postrequest"; import { postRequest } from "../lib/postrequest";
import { Scheduler } from "../lib/proto"; import { Scheduler } from "../lib/proto";
async function getCustomScheduling(): Promise<Scheduler.CustomScheduling> { interface CustomDataStates {
return Scheduler.CustomScheduling.decode( again: Record<string, unknown>;
await postRequest("/_anki/getCustomScheduling", ""), hard: Record<string, unknown>;
good: Record<string, unknown>;
easy: Record<string, unknown>;
}
async function getSchedulingStates(): Promise<Scheduler.SchedulingStates> {
return Scheduler.SchedulingStates.decode(
await postRequest("/_anki/getSchedulingStates", ""),
); );
} }
async function setCustomScheduling( async function setSchedulingStates(
key: string, key: string,
scheduling: Scheduler.CustomScheduling, states: Scheduler.SchedulingStates,
): Promise<void> { ): Promise<void> {
const bytes = Scheduler.CustomScheduling.encode(scheduling).finish(); const bytes = Scheduler.SchedulingStates.encode(states).finish();
await postRequest("/_anki/setCustomScheduling", bytes, { key }); await postRequest("/_anki/setSchedulingStates", bytes, { key });
}
function unpackCustomData(states: Scheduler.SchedulingStates): CustomDataStates {
const toObject = (s: string): Record<string, unknown> => {
try {
return JSON.parse(s);
} catch {
return {};
}
};
return {
again: toObject(states.current!.customData!),
hard: toObject(states.current!.customData!),
good: toObject(states.current!.customData!),
easy: toObject(states.current!.customData!),
};
}
function packCustomData(
states: Scheduler.SchedulingStates,
customData: CustomDataStates,
) {
states.again!.customData = JSON.stringify(customData.again);
states.hard!.customData = JSON.stringify(customData.hard);
states.good!.customData = JSON.stringify(customData.good);
states.easy!.customData = JSON.stringify(customData.easy);
} }
export async function mutateNextCardStates( export async function mutateNextCardStates(
key: string, key: string,
mutator: ( mutator: (states: Scheduler.SchedulingStates, customData: CustomDataStates) => void,
states: Scheduler.NextCardStates,
customData: Record<string, unknown>,
) => void,
): Promise<void> { ): Promise<void> {
const scheduling = await getCustomScheduling(); const states = await getSchedulingStates();
let customData = {}; const customData = unpackCustomData(states);
try { mutator(states, customData);
customData = JSON.parse(scheduling.customData); packCustomData(states, customData);
} catch { await setSchedulingStates(key, states);
// can't be parsed
}
mutator(scheduling.states!, customData);
scheduling.customData = JSON.stringify(customData);
await setCustomScheduling(key, scheduling);
} }