diff --git a/rslib/backend.proto b/rslib/backend.proto index d5cf19d59..3c590079c 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -115,6 +115,9 @@ service BackendService { rpc SetDueDate(SetDueDateIn) returns (Empty); rpc SortCards(SortCardsIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (Empty); + rpc GetNextCardStates(CardID) returns (NextCardStates); + rpc DescribeNextStates(NextCardStates) returns (StringList); + rpc AnswerCard(AnswerCardIn) returns (Empty); // stats @@ -268,7 +271,7 @@ message DeckConfigInner { float interval_multiplier = 15; uint32 maximum_review_interval = 16; - uint32 minimum_review_interval = 17; + uint32 minimum_lapse_interval = 17; uint32 graduating_interval_good = 18; uint32 graduating_interval_easy = 19; @@ -1263,3 +1266,73 @@ message RenderMarkdownIn { string markdown = 1; bool sanitize = 2; } + +message SchedulingState { + message New { + uint32 position = 1; + } + message Learning { + uint32 remaining_steps = 1; + uint32 scheduled_secs = 2; + } + message Review { + uint32 scheduled_days = 1; + uint32 elapsed_days = 2; + float ease_factor = 3; + uint32 lapses = 4; + } + message Relearning { + Review review = 1; + Learning learning = 2; + } + message Normal { + oneof value { + New new = 1; + Learning learning = 2; + Review review = 3; + Relearning relearning = 4; + } + } + message Preview { + uint32 scheduled_secs = 1; + Normal original_state = 2; + } + message ReschedulingFilter { + Normal original_state = 1; + } + message Filtered { + oneof value { + Preview preview = 1; + ReschedulingFilter rescheduling = 2; + } + } + + oneof value { + Normal normal = 1; + Filtered filtered = 2; + } +} + +message NextCardStates { + SchedulingState current = 1; + SchedulingState again = 2; + SchedulingState hard = 3; + SchedulingState good = 4; + SchedulingState easy = 5; +} + +message AnswerCardIn { + enum Rating { + AGAIN = 0; + HARD = 1; + GOOD = 2; + EASY = 3; + } + + int64 card_id = 1; + SchedulingState current_state = 2; + SchedulingState new_state = 3; + Rating rating = 4; + int64 answered_at = 5; + uint32 milliseconds_taken = 6; +} diff --git a/rslib/src/backend/generic.rs b/rslib/src/backend/generic.rs new file mode 100644 index 000000000..96e4b23d1 --- /dev/null +++ b/rslib/src/backend/generic.rs @@ -0,0 +1,76 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, prelude::*}; + +impl From> for pb::Json { + fn from(json: Vec) -> Self { + pb::Json { json } + } +} + +impl From for pb::String { + fn from(val: String) -> Self { + pb::String { val } + } +} + +impl From for pb::Int64 { + fn from(val: i64) -> Self { + pb::Int64 { val } + } +} + +impl From for pb::UInt32 { + fn from(val: u32) -> Self { + pb::UInt32 { val } + } +} + +impl From<()> for pb::Empty { + fn from(_val: ()) -> Self { + pb::Empty {} + } +} + +impl From for CardID { + fn from(cid: pb::CardId) -> Self { + CardID(cid.cid) + } +} + +impl Into> for pb::CardIDs { + fn into(self) -> Vec { + self.cids.into_iter().map(CardID).collect() + } +} + +impl From for NoteID { + fn from(nid: pb::NoteId) -> Self { + NoteID(nid.nid) + } +} + +impl From for NoteTypeID { + fn from(ntid: pb::NoteTypeId) -> Self { + NoteTypeID(ntid.ntid) + } +} + +impl From for DeckID { + fn from(did: pb::DeckId) -> Self { + DeckID(did.did) + } +} + +impl From for DeckConfID { + fn from(dcid: pb::DeckConfigId) -> Self { + DeckConfID(dcid.dcid) + } +} + +impl From> for pb::StringList { + fn from(vals: Vec) -> Self { + pb::StringList { vals } + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 7838b4627..b56302808 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -15,7 +15,7 @@ use crate::{ collection::{open_collection, Collection}, config::SortKind, dbcheck::DatabaseCheckProgress, - deckconf::{DeckConf, DeckConfID, DeckConfSchema11}, + deckconf::{DeckConf, DeckConfSchema11}, decks::{Deck, DeckID, DeckSchema11}, err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind}, i18n::{tr_args, I18n, TR}, @@ -28,13 +28,13 @@ use crate::{ media::MediaManager, notes::{Note, NoteID}, notetype::{ - all_stock_notetypes, CardTemplateSchema11, NoteType, NoteTypeID, NoteTypeSchema11, - RenderCardOutput, + all_stock_notetypes, CardTemplateSchema11, NoteType, NoteTypeSchema11, RenderCardOutput, }, sched::{ new::NewCardSortOrder, parse_due_date_str, - timespan::{answer_button_time, time_span}, + states::NextCardStates, + timespan::{answer_button_time, answer_button_time_collapsible, time_span}, }, search::{ concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node, @@ -69,7 +69,9 @@ use std::{ use tokio::runtime::{self, Runtime}; mod dbproxy; +mod generic; mod http_sync_server; +mod sched; struct ThrottlingProgressHandler { state: Arc>, @@ -218,82 +220,6 @@ pub fn init_backend(init_msg: &[u8]) -> std::result::Result { Ok(Backend::new(i18n, input.server)) } -impl From> for pb::Json { - fn from(json: Vec) -> Self { - pb::Json { json } - } -} - -impl From for pb::String { - fn from(val: String) -> Self { - pb::String { val } - } -} - -impl From for pb::Int64 { - fn from(val: i64) -> Self { - pb::Int64 { val } - } -} - -impl From for pb::UInt32 { - fn from(val: u32) -> Self { - pb::UInt32 { val } - } -} - -impl From<()> for pb::Empty { - fn from(_val: ()) -> Self { - pb::Empty {} - } -} - -impl From for CardID { - fn from(cid: pb::CardId) -> Self { - CardID(cid.cid) - } -} - -impl pb::CardIDs { - fn into_native(self) -> Vec { - self.cids.into_iter().map(CardID).collect() - } -} - -impl From for NoteID { - fn from(nid: pb::NoteId) -> Self { - NoteID(nid.nid) - } -} - -impl pb::search_node::IdList { - fn into_id_string(self) -> String { - self.ids - .iter() - .map(|i| i.to_string()) - .collect::>() - .join(",") - } -} - -impl From for NoteTypeID { - fn from(ntid: pb::NoteTypeId) -> Self { - NoteTypeID(ntid.ntid) - } -} - -impl From for DeckID { - fn from(did: pb::DeckId) -> Self { - DeckID(did.did) - } -} - -impl From for DeckConfID { - fn from(dcid: pb::DeckConfigId) -> Self { - DeckConfID(dcid.dcid) - } -} - impl TryFrom for Node { type Error = AnkiError; @@ -436,62 +362,6 @@ impl BackendService for Backend { // card rendering - fn render_existing_card( - &self, - input: pb::RenderExistingCardIn, - ) -> BackendResult { - self.with_col(|col| { - col.render_existing_card(CardID(input.card_id), input.browser) - .map(Into::into) - }) - } - - fn render_uncommitted_card( - &self, - input: pb::RenderUncommittedCardIn, - ) -> BackendResult { - let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?; - let template = schema11.into(); - let mut note = input - .note - .ok_or_else(|| AnkiError::invalid_input("missing note"))? - .into(); - let ord = input.card_ord as u16; - let fill_empty = input.fill_empty; - self.with_col(|col| { - col.render_uncommitted_card(&mut note, &template, ord, fill_empty) - .map(Into::into) - }) - } - - fn get_empty_cards(&self, _input: pb::Empty) -> Result { - self.with_col(|col| { - let mut empty = col.empty_cards()?; - let report = col.empty_cards_report(&mut empty)?; - - let mut outnotes = vec![]; - for (_ntid, notes) in empty { - outnotes.extend(notes.into_iter().map(|e| { - pb::empty_cards_report::NoteWithEmptyCards { - note_id: e.nid.0, - will_delete_note: e.empty.len() == e.current_count, - card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(), - } - })) - } - Ok(pb::EmptyCardsReport { - report, - notes: outnotes, - }) - }) - } - - fn strip_av_tags(&self, input: pb::String) -> BackendResult { - Ok(pb::String { - val: strip_av_tags(&input.val).into(), - }) - } - fn extract_av_tags(&self, input: pb::ExtractAvTagsIn) -> BackendResult { let (text, tags) = extract_av_tags(&input.text, input.question_side); let pt_tags = tags @@ -544,6 +414,62 @@ impl BackendService for Backend { }) } + fn get_empty_cards(&self, _input: pb::Empty) -> Result { + self.with_col(|col| { + let mut empty = col.empty_cards()?; + let report = col.empty_cards_report(&mut empty)?; + + let mut outnotes = vec![]; + for (_ntid, notes) in empty { + outnotes.extend(notes.into_iter().map(|e| { + pb::empty_cards_report::NoteWithEmptyCards { + note_id: e.nid.0, + will_delete_note: e.empty.len() == e.current_count, + card_ids: e.empty.into_iter().map(|(_ord, id)| id.0).collect(), + } + })) + } + Ok(pb::EmptyCardsReport { + report, + notes: outnotes, + }) + }) + } + + fn render_existing_card( + &self, + input: pb::RenderExistingCardIn, + ) -> BackendResult { + self.with_col(|col| { + col.render_existing_card(CardID(input.card_id), input.browser) + .map(Into::into) + }) + } + + fn render_uncommitted_card( + &self, + input: pb::RenderUncommittedCardIn, + ) -> BackendResult { + let schema11: CardTemplateSchema11 = serde_json::from_slice(&input.template)?; + let template = schema11.into(); + let mut note = input + .note + .ok_or_else(|| AnkiError::invalid_input("missing note"))? + .into(); + let ord = input.card_ord as u16; + let fill_empty = input.fill_empty; + self.with_col(|col| { + col.render_uncommitted_card(&mut note, &template, ord, fill_empty) + .map(Into::into) + }) + } + + fn strip_av_tags(&self, input: pb::String) -> BackendResult { + Ok(pb::String { + val: strip_av_tags(&input.val).into(), + }) + } + // searching //----------------------------------------------- @@ -675,10 +601,8 @@ impl BackendService for Backend { } fn restore_buried_and_suspended_cards(&self, input: pb::CardIDs) -> BackendResult { - self.with_col(|col| { - col.unbury_or_unsuspend_cards(&input.into_native()) - .map(Into::into) - }) + let cids: Vec<_> = input.into(); + self.with_col(|col| col.unbury_or_unsuspend_cards(&cids).map(Into::into)) } fn unbury_cards_in_current_deck( @@ -747,6 +671,46 @@ impl BackendService for Backend { }) } + fn get_next_card_states(&self, input: pb::CardId) -> BackendResult { + let cid: CardID = input.into(); + self.with_col(|col| col.get_next_card_states(cid)) + .map(Into::into) + } + + fn describe_next_states(&self, input: pb::NextCardStates) -> BackendResult { + let collapse_time = self.with_col(|col| Ok(col.learn_ahead_secs()))?; + let choices: NextCardStates = input.into(); + + Ok(vec![ + answer_button_time_collapsible( + choices.again.interval_kind().as_seconds(), + collapse_time, + &self.i18n, + ), + answer_button_time_collapsible( + choices.hard.interval_kind().as_seconds(), + collapse_time, + &self.i18n, + ), + answer_button_time_collapsible( + choices.good.interval_kind().as_seconds(), + collapse_time, + &self.i18n, + ), + answer_button_time_collapsible( + choices.easy.interval_kind().as_seconds(), + collapse_time, + &self.i18n, + ), + ] + .into()) + } + + fn answer_card(&self, input: pb::AnswerCardIn) -> BackendResult { + self.with_col(|col| col.answer_card(&input.into())) + .map(Into::into) + } + // statistics //----------------------------------------------- @@ -768,9 +732,102 @@ impl BackendService for Backend { .map(Into::into) } - // decks + // media //----------------------------------------------- + fn check_media(&self, _input: pb::Empty) -> Result { + let mut handler = self.new_progress_handler(); + let progress_fn = + move |progress| handler.update(Progress::MediaCheck(progress as u32), true); + self.with_col(|col| { + let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; + col.transact(None, |ctx| { + let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); + let mut output = checker.check()?; + + let report = checker.summarize_output(&mut output); + + Ok(pb::CheckMediaOut { + unused: output.unused, + missing: output.missing, + report, + have_trash: output.trash_count > 0, + }) + }) + }) + } + + fn trash_media_files(&self, input: pb::TrashMediaFilesIn) -> BackendResult { + self.with_col(|col| { + let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; + let mut ctx = mgr.dbctx(); + mgr.remove_files(&mut ctx, &input.fnames) + }) + .map(Into::into) + } + + fn add_media_file(&self, input: pb::AddMediaFileIn) -> BackendResult { + self.with_col(|col| { + let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; + let mut ctx = mgr.dbctx(); + Ok(mgr + .add_file(&mut ctx, &input.desired_name, &input.data)? + .to_string() + .into()) + }) + } + + fn empty_trash(&self, _input: Empty) -> BackendResult { + let mut handler = self.new_progress_handler(); + let progress_fn = + move |progress| handler.update(Progress::MediaCheck(progress as u32), true); + + self.with_col(|col| { + let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; + col.transact(None, |ctx| { + let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); + + checker.empty_trash() + }) + }) + .map(Into::into) + } + + fn restore_trash(&self, _input: Empty) -> BackendResult { + let mut handler = self.new_progress_handler(); + let progress_fn = + move |progress| handler.update(Progress::MediaCheck(progress as u32), true); + self.with_col(|col| { + let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; + + col.transact(None, |ctx| { + let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); + + checker.restore_trash() + }) + }) + .map(Into::into) + } + + // decks + //---------------------------------------------------- + + fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result { + self.with_col(|col| { + let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; + let mut deck: Deck = schema11.into(); + if input.preserve_usn_and_mtime { + col.transact(None, |col| { + let usn = col.usn()?; + col.add_or_update_single_deck(&mut deck, usn) + })?; + } else { + col.add_or_update_deck(&mut deck)?; + } + Ok(pb::DeckId { did: deck.id.0 }) + }) + } + fn deck_tree(&self, input: pb::DeckTreeIn) -> Result { let lim = if input.top_deck_id > 0 { Some(DeckID(input.top_deck_id)) @@ -796,17 +853,12 @@ impl BackendService for Backend { }) } - fn get_deck_legacy(&self, input: pb::DeckId) -> Result { + fn get_all_decks_legacy(&self, _input: Empty) -> BackendResult { self.with_col(|col| { - let deck: DeckSchema11 = col - .storage - .get_deck(input.into())? - .ok_or(AnkiError::NotFound)? - .into(); - serde_json::to_vec(&deck) - .map_err(Into::into) - .map(Into::into) + let decks = col.storage.get_all_decks_as_schema11()?; + serde_json::to_vec(&decks).map_err(Into::into) }) + .map(Into::into) } fn get_deck_id_by_name(&self, input: pb::String) -> Result { @@ -818,12 +870,17 @@ impl BackendService for Backend { }) } - fn get_all_decks_legacy(&self, _input: Empty) -> BackendResult { + fn get_deck_legacy(&self, input: pb::DeckId) -> Result { self.with_col(|col| { - let decks = col.storage.get_all_decks_as_schema11()?; - serde_json::to_vec(&decks).map_err(Into::into) + let deck: DeckSchema11 = col + .storage + .get_deck(input.into())? + .ok_or(AnkiError::NotFound)? + .into(); + serde_json::to_vec(&deck) + .map_err(Into::into) + .map(Into::into) }) - .map(Into::into) } fn get_deck_names(&self, input: pb::GetDeckNamesIn) -> Result { @@ -842,22 +899,6 @@ impl BackendService for Backend { }) } - fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result { - self.with_col(|col| { - let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; - let mut deck: Deck = schema11.into(); - if input.preserve_usn_and_mtime { - col.transact(None, |col| { - let usn = col.usn()?; - col.add_or_update_single_deck(&mut deck, usn) - })?; - } else { - col.add_or_update_deck(&mut deck)?; - } - Ok(pb::DeckId { did: deck.id.0 }) - }) - } - fn new_deck_legacy(&self, input: pb::Bool) -> BackendResult { let deck = if input.val { Deck::new_filtered() @@ -917,6 +958,15 @@ impl BackendService for Backend { .map(Into::into) } + fn get_deck_config_legacy(&self, input: pb::DeckConfigId) -> BackendResult { + self.with_col(|col| { + let conf = col.get_deck_config(input.into(), true)?.unwrap(); + let conf: DeckConfSchema11 = conf.into(); + Ok(serde_json::to_vec(&conf)?) + }) + .map(Into::into) + } + fn new_deck_config_legacy(&self, _input: Empty) -> BackendResult { serde_json::to_vec(&DeckConfSchema11::default()) .map_err(Into::into) @@ -928,15 +978,6 @@ impl BackendService for Backend { .map(Into::into) } - fn get_deck_config_legacy(&self, input: pb::DeckConfigId) -> BackendResult { - self.with_col(|col| { - let conf = col.get_deck_config(input.into(), true)?.unwrap(); - let conf: DeckConfSchema11 = conf.into(); - Ok(serde_json::to_vec(&conf)?) - }) - .map(Into::into) - } - // cards //------------------------------------------------------------------- @@ -1080,18 +1121,6 @@ impl BackendService for Backend { }) } - fn field_names_for_notes( - &self, - input: pb::FieldNamesForNotesIn, - ) -> BackendResult { - self.with_col(|col| { - let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect(); - col.storage - .field_names_for_notes(&nids) - .map(|fields| pb::FieldNamesForNotesOut { fields }) - }) - } - fn after_note_updates(&self, input: pb::AfterNoteUpdatesIn) -> BackendResult { self.with_col(|col| { col.transact(None, |col| { @@ -1105,6 +1134,18 @@ impl BackendService for Backend { }) } + fn field_names_for_notes( + &self, + input: pb::FieldNamesForNotesIn, + ) -> BackendResult { + self.with_col(|col| { + let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect(); + col.storage + .field_names_for_notes(&nids) + .map(|fields| pb::FieldNamesForNotesOut { fields }) + }) + } + fn note_is_duplicate_or_empty( &self, input: pb::Note, @@ -1129,6 +1170,22 @@ impl BackendService for Backend { // notetypes //------------------------------------------------------------------- + fn add_or_update_notetype( + &self, + input: pb::AddOrUpdateNotetypeIn, + ) -> BackendResult { + self.with_col(|col| { + let legacy: NoteTypeSchema11 = serde_json::from_slice(&input.json)?; + let mut nt: NoteType = legacy.into(); + if nt.id.0 == 0 { + col.add_notetype(&mut nt)?; + } else { + col.update_notetype(&mut nt, input.preserve_usn_and_mtime)?; + } + Ok(pb::NoteTypeId { ntid: nt.id.0 }) + }) + } + fn get_stock_notetype_legacy(&self, input: pb::StockNoteType) -> BackendResult { // fixme: use individual functions instead of full vec let mut all = all_stock_notetypes(&self.i18n); @@ -1140,6 +1197,17 @@ impl BackendService for Backend { .map(Into::into) } + fn get_notetype_legacy(&self, input: pb::NoteTypeId) -> BackendResult { + self.with_col(|col| { + let schema11: NoteTypeSchema11 = col + .storage + .get_notetype(input.into())? + .ok_or(AnkiError::NotFound)? + .into(); + Ok(serde_json::to_vec(&schema11)?).map(Into::into) + }) + } + fn get_notetype_names(&self, _input: Empty) -> BackendResult { self.with_col(|col| { let entries: Vec<_> = col @@ -1168,17 +1236,6 @@ impl BackendService for Backend { }) } - fn get_notetype_legacy(&self, input: pb::NoteTypeId) -> BackendResult { - self.with_col(|col| { - let schema11: NoteTypeSchema11 = col - .storage - .get_notetype(input.into())? - .ok_or(AnkiError::NotFound)? - .into(); - Ok(serde_json::to_vec(&schema11)?).map(Into::into) - }) - } - fn get_notetype_id_by_name(&self, input: pb::String) -> BackendResult { self.with_col(|col| { col.storage @@ -1188,120 +1245,14 @@ impl BackendService for Backend { }) } - fn add_or_update_notetype( - &self, - input: pb::AddOrUpdateNotetypeIn, - ) -> BackendResult { - self.with_col(|col| { - let legacy: NoteTypeSchema11 = serde_json::from_slice(&input.json)?; - let mut nt: NoteType = legacy.into(); - if nt.id.0 == 0 { - col.add_notetype(&mut nt)?; - } else { - col.update_notetype(&mut nt, input.preserve_usn_and_mtime)?; - } - Ok(pb::NoteTypeId { ntid: nt.id.0 }) - }) - } - fn remove_notetype(&self, input: pb::NoteTypeId) -> BackendResult { self.with_col(|col| col.remove_notetype(input.into())) .map(Into::into) } - // media - //------------------------------------------------------------------- - - fn add_media_file(&self, input: pb::AddMediaFileIn) -> BackendResult { - self.with_col(|col| { - let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - let mut ctx = mgr.dbctx(); - Ok(mgr - .add_file(&mut ctx, &input.desired_name, &input.data)? - .to_string() - .into()) - }) - } - - fn empty_trash(&self, _input: Empty) -> BackendResult { - let mut handler = self.new_progress_handler(); - let progress_fn = - move |progress| handler.update(Progress::MediaCheck(progress as u32), true); - - self.with_col(|col| { - let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - col.transact(None, |ctx| { - let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); - - checker.empty_trash() - }) - }) - .map(Into::into) - } - - fn restore_trash(&self, _input: Empty) -> BackendResult { - let mut handler = self.new_progress_handler(); - let progress_fn = - move |progress| handler.update(Progress::MediaCheck(progress as u32), true); - self.with_col(|col| { - let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - - col.transact(None, |ctx| { - let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); - - checker.restore_trash() - }) - }) - .map(Into::into) - } - - fn trash_media_files(&self, input: pb::TrashMediaFilesIn) -> BackendResult { - self.with_col(|col| { - let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - let mut ctx = mgr.dbctx(); - mgr.remove_files(&mut ctx, &input.fnames) - }) - .map(Into::into) - } - - fn check_media(&self, _input: pb::Empty) -> Result { - let mut handler = self.new_progress_handler(); - let progress_fn = - move |progress| handler.update(Progress::MediaCheck(progress as u32), true); - self.with_col(|col| { - let mgr = MediaManager::new(&col.media_folder, &col.media_db)?; - col.transact(None, |ctx| { - let mut checker = MediaChecker::new(ctx, &mgr, progress_fn); - let mut output = checker.check()?; - - let report = checker.summarize_output(&mut output); - - Ok(pb::CheckMediaOut { - unused: output.unused, - missing: output.missing, - report, - have_trash: output.trash_count > 0, - }) - }) - }) - } - // collection //------------------------------------------------------------------- - fn check_database(&self, _input: pb::Empty) -> BackendResult { - let mut handler = self.new_progress_handler(); - let progress_fn = move |progress, throttle| { - handler.update(Progress::DatabaseCheck(progress), throttle); - }; - self.with_col(|col| { - col.check_database(progress_fn) - .map(|problems| pb::CheckDatabaseOut { - problems: problems.to_i18n_strings(&col.i18n), - }) - }) - } - fn open_collection(&self, input: pb::OpenCollectionIn) -> BackendResult { let mut col = self.col.lock().unwrap(); if col.is_some() { @@ -1350,31 +1301,22 @@ impl BackendService for Backend { Ok(().into()) } + fn check_database(&self, _input: pb::Empty) -> BackendResult { + let mut handler = self.new_progress_handler(); + let progress_fn = move |progress, throttle| { + handler.update(Progress::DatabaseCheck(progress), throttle); + }; + self.with_col(|col| { + col.check_database(progress_fn) + .map(|problems| pb::CheckDatabaseOut { + problems: problems.to_i18n_strings(&col.i18n), + }) + }) + } + // sync //------------------------------------------------------------------- - fn sync_login(&self, input: pb::SyncLoginIn) -> BackendResult { - self.sync_login_inner(input) - } - - fn sync_status(&self, input: pb::SyncAuth) -> BackendResult { - self.sync_status_inner(input) - } - - fn sync_collection(&self, input: pb::SyncAuth) -> BackendResult { - self.sync_collection_inner(input) - } - - fn full_upload(&self, input: pb::SyncAuth) -> BackendResult { - self.full_sync_inner(input, true)?; - Ok(().into()) - } - - fn full_download(&self, input: pb::SyncAuth) -> BackendResult { - self.full_sync_inner(input, false)?; - Ok(().into()) - } - fn sync_media(&self, input: pb::SyncAuth) -> BackendResult { self.sync_media_inner(input).map(Into::into) } @@ -1399,6 +1341,28 @@ impl BackendService for Backend { self.with_col(|col| col.before_upload().map(Into::into)) } + fn sync_login(&self, input: pb::SyncLoginIn) -> BackendResult { + self.sync_login_inner(input) + } + + fn sync_status(&self, input: pb::SyncAuth) -> BackendResult { + self.sync_status_inner(input) + } + + fn sync_collection(&self, input: pb::SyncAuth) -> BackendResult { + self.sync_collection_inner(input) + } + + fn full_upload(&self, input: pb::SyncAuth) -> BackendResult { + self.full_sync_inner(input, true)?; + Ok(().into()) + } + + fn full_download(&self, input: pb::SyncAuth) -> BackendResult { + self.full_sync_inner(input, false)?; + Ok(().into()) + } + fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> BackendResult { let req = SyncRequest::from_method_and_data(input.method(), input.data)?; self.sync_server_method_inner(req).map(Into::into) @@ -1450,6 +1414,10 @@ impl BackendService for Backend { // tags //------------------------------------------------------------------- + fn clear_unused_tags(&self, _input: pb::Empty) -> BackendResult { + self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) + } + fn all_tags(&self, _input: Empty) -> BackendResult { Ok(pb::StringList { vals: self.with_col(|col| { @@ -1472,10 +1440,6 @@ impl BackendService for Backend { }) } - fn clear_unused_tags(&self, _input: pb::Empty) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into))) - } - fn clear_tag(&self, tag: pb::String) -> BackendResult { self.with_col(|col| { col.transact(None, |col| { @@ -1536,15 +1500,6 @@ impl BackendService for Backend { .map(Into::into) } - fn get_preferences(&self, _input: Empty) -> BackendResult { - self.with_col(|col| col.get_preferences()) - } - - fn set_preferences(&self, input: pb::Preferences) -> BackendResult { - self.with_col(|col| col.transact(None, |col| col.set_preferences(input))) - .map(Into::into) - } - fn get_config_bool(&self, input: pb::config::Bool) -> BackendResult { self.with_col(|col| { Ok(pb::Bool { @@ -1570,6 +1525,15 @@ impl BackendService for Backend { self.with_col(|col| col.transact(None, |col| col.set_string(input))) .map(Into::into) } + + fn get_preferences(&self, _input: Empty) -> BackendResult { + self.with_col(|col| col.get_preferences()) + } + + fn set_preferences(&self, input: pb::Preferences) -> BackendResult { + self.with_col(|col| col.transact(None, |col| col.set_preferences(input))) + .map(Into::into) + } } impl Backend { @@ -2172,3 +2136,13 @@ impl From for Progress { Progress::NormalSync(p) } } + +impl pb::search_node::IdList { + fn into_id_string(self) -> String { + self.ids + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(",") + } +} diff --git a/rslib/src/backend/sched/answering.rs b/rslib/src/backend/sched/answering.rs new file mode 100644 index 000000000..54b94307a --- /dev/null +++ b/rslib/src/backend/sched/answering.rs @@ -0,0 +1,32 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + backend_proto as pb, + prelude::*, + sched::answering::{CardAnswer, Rating}, +}; + +impl From for CardAnswer { + fn from(answer: pb::AnswerCardIn) -> Self { + CardAnswer { + card_id: CardID(answer.card_id), + rating: answer.rating().into(), + current_state: answer.current_state.unwrap_or_default().into(), + new_state: answer.new_state.unwrap_or_default().into(), + answered_at: TimestampSecs(answer.answered_at), + milliseconds_taken: answer.milliseconds_taken, + } + } +} + +impl From for Rating { + fn from(rating: pb::answer_card_in::Rating) -> Self { + match rating { + pb::answer_card_in::Rating::Again => Rating::Again, + pb::answer_card_in::Rating::Hard => Rating::Hard, + pb::answer_card_in::Rating::Good => Rating::Good, + pb::answer_card_in::Rating::Easy => Rating::Easy, + } + } +} diff --git a/rslib/src/backend/sched/mod.rs b/rslib/src/backend/sched/mod.rs new file mode 100644 index 000000000..8663da96c --- /dev/null +++ b/rslib/src/backend/sched/mod.rs @@ -0,0 +1,5 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod answering; +mod states; diff --git a/rslib/src/backend/sched/states/filtered.rs b/rslib/src/backend/sched/states/filtered.rs new file mode 100644 index 000000000..98283059d --- /dev/null +++ b/rslib/src/backend/sched/states/filtered.rs @@ -0,0 +1,35 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::FilteredState}; + +impl From for pb::scheduling_state::Filtered { + fn from(state: FilteredState) -> Self { + pb::scheduling_state::Filtered { + value: Some(match state { + FilteredState::Preview(state) => { + pb::scheduling_state::filtered::Value::Preview(state.into()) + } + FilteredState::Rescheduling(state) => { + pb::scheduling_state::filtered::Value::Rescheduling(state.into()) + } + }), + } + } +} + +impl From for FilteredState { + fn from(state: pb::scheduling_state::Filtered) -> Self { + match state + .value + .unwrap_or_else(|| pb::scheduling_state::filtered::Value::Preview(Default::default())) + { + pb::scheduling_state::filtered::Value::Preview(state) => { + FilteredState::Preview(state.into()) + } + pb::scheduling_state::filtered::Value::Rescheduling(state) => { + FilteredState::Rescheduling(state.into()) + } + } + } +} diff --git a/rslib/src/backend/sched/states/learning.rs b/rslib/src/backend/sched/states/learning.rs new file mode 100644 index 000000000..95e393211 --- /dev/null +++ b/rslib/src/backend/sched/states/learning.rs @@ -0,0 +1,22 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::LearnState}; + +impl From for LearnState { + fn from(state: pb::scheduling_state::Learning) -> Self { + LearnState { + remaining_steps: state.remaining_steps, + scheduled_secs: state.scheduled_secs, + } + } +} + +impl From for pb::scheduling_state::Learning { + fn from(state: LearnState) -> Self { + pb::scheduling_state::Learning { + remaining_steps: state.remaining_steps, + scheduled_secs: state.scheduled_secs, + } + } +} diff --git a/rslib/src/backend/sched/states/mod.rs b/rslib/src/backend/sched/states/mod.rs new file mode 100644 index 000000000..bd69570b6 --- /dev/null +++ b/rslib/src/backend/sched/states/mod.rs @@ -0,0 +1,66 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod filtered; +mod learning; +mod new; +mod normal; +mod preview; +mod relearning; +mod rescheduling; +mod review; + +use crate::{ + backend_proto as pb, + sched::states::{CardState, NewState, NextCardStates, NormalState}, +}; + +impl From for pb::NextCardStates { + fn from(choices: NextCardStates) -> Self { + pb::NextCardStates { + current: Some(choices.current.into()), + again: Some(choices.again.into()), + hard: Some(choices.hard.into()), + good: Some(choices.good.into()), + easy: Some(choices.easy.into()), + } + } +} + +impl From for NextCardStates { + fn from(choices: pb::NextCardStates) -> Self { + NextCardStates { + current: choices.current.unwrap_or_default().into(), + again: choices.again.unwrap_or_default().into(), + hard: choices.hard.unwrap_or_default().into(), + good: choices.good.unwrap_or_default().into(), + easy: choices.easy.unwrap_or_default().into(), + } + } +} + +impl From for pb::SchedulingState { + fn from(state: CardState) -> Self { + pb::SchedulingState { + value: Some(match state { + CardState::Normal(state) => pb::scheduling_state::Value::Normal(state.into()), + CardState::Filtered(state) => pb::scheduling_state::Value::Filtered(state.into()), + }), + } + } +} + +impl From for CardState { + fn from(state: pb::SchedulingState) -> Self { + if let Some(value) = state.value { + match value { + pb::scheduling_state::Value::Normal(normal) => CardState::Normal(normal.into()), + pb::scheduling_state::Value::Filtered(filtered) => { + CardState::Filtered(filtered.into()) + } + } + } else { + CardState::Normal(NormalState::New(NewState::default())) + } + } +} diff --git a/rslib/src/backend/sched/states/new.rs b/rslib/src/backend/sched/states/new.rs new file mode 100644 index 000000000..66b7fe7bf --- /dev/null +++ b/rslib/src/backend/sched/states/new.rs @@ -0,0 +1,20 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::NewState}; + +impl From for NewState { + fn from(state: pb::scheduling_state::New) -> Self { + NewState { + position: state.position, + } + } +} + +impl From for pb::scheduling_state::New { + fn from(state: NewState) -> Self { + pb::scheduling_state::New { + position: state.position, + } + } +} diff --git a/rslib/src/backend/sched/states/normal.rs b/rslib/src/backend/sched/states/normal.rs new file mode 100644 index 000000000..86e26e0db --- /dev/null +++ b/rslib/src/backend/sched/states/normal.rs @@ -0,0 +1,41 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::NormalState}; + +impl From for pb::scheduling_state::Normal { + fn from(state: NormalState) -> Self { + pb::scheduling_state::Normal { + value: Some(match state { + NormalState::New(state) => pb::scheduling_state::normal::Value::New(state.into()), + NormalState::Learning(state) => { + pb::scheduling_state::normal::Value::Learning(state.into()) + } + NormalState::Review(state) => { + pb::scheduling_state::normal::Value::Review(state.into()) + } + NormalState::Relearning(state) => { + pb::scheduling_state::normal::Value::Relearning(state.into()) + } + }), + } + } +} + +impl From for NormalState { + fn from(state: pb::scheduling_state::Normal) -> Self { + match state + .value + .unwrap_or_else(|| pb::scheduling_state::normal::Value::New(Default::default())) + { + pb::scheduling_state::normal::Value::New(state) => NormalState::New(state.into()), + pb::scheduling_state::normal::Value::Learning(state) => { + NormalState::Learning(state.into()) + } + pb::scheduling_state::normal::Value::Review(state) => NormalState::Review(state.into()), + pb::scheduling_state::normal::Value::Relearning(state) => { + NormalState::Relearning(state.into()) + } + } + } +} diff --git a/rslib/src/backend/sched/states/preview.rs b/rslib/src/backend/sched/states/preview.rs new file mode 100644 index 000000000..9b2202a0a --- /dev/null +++ b/rslib/src/backend/sched/states/preview.rs @@ -0,0 +1,22 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::PreviewState}; + +impl From for PreviewState { + fn from(state: pb::scheduling_state::Preview) -> Self { + PreviewState { + scheduled_secs: state.scheduled_secs, + original_state: state.original_state.unwrap_or_default().into(), + } + } +} + +impl From for pb::scheduling_state::Preview { + fn from(state: PreviewState) -> Self { + pb::scheduling_state::Preview { + scheduled_secs: state.scheduled_secs, + original_state: Some(state.original_state.into()), + } + } +} diff --git a/rslib/src/backend/sched/states/relearning.rs b/rslib/src/backend/sched/states/relearning.rs new file mode 100644 index 000000000..2a737d17a --- /dev/null +++ b/rslib/src/backend/sched/states/relearning.rs @@ -0,0 +1,22 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::RelearnState}; + +impl From for RelearnState { + fn from(state: pb::scheduling_state::Relearning) -> Self { + RelearnState { + review: state.review.unwrap_or_default().into(), + learning: state.learning.unwrap_or_default().into(), + } + } +} + +impl From for pb::scheduling_state::Relearning { + fn from(state: RelearnState) -> Self { + pb::scheduling_state::Relearning { + review: Some(state.review.into()), + learning: Some(state.learning.into()), + } + } +} diff --git a/rslib/src/backend/sched/states/rescheduling.rs b/rslib/src/backend/sched/states/rescheduling.rs new file mode 100644 index 000000000..225c27ab7 --- /dev/null +++ b/rslib/src/backend/sched/states/rescheduling.rs @@ -0,0 +1,20 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::ReschedulingFilterState}; + +impl From for ReschedulingFilterState { + fn from(state: pb::scheduling_state::ReschedulingFilter) -> Self { + ReschedulingFilterState { + original_state: state.original_state.unwrap_or_default().into(), + } + } +} + +impl From for pb::scheduling_state::ReschedulingFilter { + fn from(state: ReschedulingFilterState) -> Self { + pb::scheduling_state::ReschedulingFilter { + original_state: Some(state.original_state.into()), + } + } +} diff --git a/rslib/src/backend/sched/states/review.rs b/rslib/src/backend/sched/states/review.rs new file mode 100644 index 000000000..eff0f0ce9 --- /dev/null +++ b/rslib/src/backend/sched/states/review.rs @@ -0,0 +1,26 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{backend_proto as pb, sched::states::ReviewState}; + +impl From for ReviewState { + fn from(state: pb::scheduling_state::Review) -> Self { + ReviewState { + scheduled_days: state.scheduled_days, + elapsed_days: state.elapsed_days, + ease_factor: state.ease_factor, + lapses: state.lapses, + } + } +} + +impl From for pb::scheduling_state::Review { + fn from(state: ReviewState) -> Self { + pb::scheduling_state::Review { + scheduled_days: state.scheduled_days, + elapsed_days: state.elapsed_days, + ease_factor: state.ease_factor, + lapses: state.lapses, + } + } +} diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 5a3fc44b4..1930eb793 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::decks::DeckID; use crate::define_newtype; use crate::err::{AnkiError, Result}; use crate::notes::NoteID; @@ -9,6 +8,7 @@ use crate::{ collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn, undo::Undoable, }; +use crate::{deckconf::DeckConf, decks::DeckID}; use num_enum::TryFromPrimitive; use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::HashSet; @@ -107,6 +107,17 @@ impl Card { self.remove_from_filtered_deck_restoring_queue(sched); self.deck_id = deck; } + + /// Return the total number of steps left to do, ignoring the + /// "steps today" number packed into the DB representation. + pub fn remaining_steps(&self) -> u32 { + self.remaining_steps % 1000 + } + + /// Return ease factor as a multiplier (eg 2.5) + pub fn ease_factor(&self) -> f32 { + (self.ease_factor as f32) / 1000.0 + } } #[derive(Debug)] pub(crate) struct UpdateCardUndo(Card); @@ -122,15 +133,17 @@ impl Undoable for UpdateCardUndo { } impl Card { - pub fn new(nid: NoteID, ord: u16, deck_id: DeckID, due: i32) -> Self { - let mut card = Card::default(); - card.note_id = nid; - card.template_idx = ord; - card.deck_id = deck_id; - card.due = due; - card + pub fn new(note_id: NoteID, template_idx: u16, deck_id: DeckID, due: i32) -> Self { + Card { + note_id, + template_idx, + deck_id, + due, + ..Default::default() + } } } + impl Collection { #[cfg(test)] pub(crate) fn get_and_update_card(&mut self, cid: CardID, func: F) -> Result @@ -217,6 +230,17 @@ impl Collection { Ok(()) }) } + + /// Get deck config for the given card. If missing, return default values. + pub(crate) fn deck_config_for_card(&mut self, card: &Card) -> Result { + if let Some(deck) = self.get_deck(card.original_or_current_deck_id())? { + if let Some(conf_id) = deck.config_id() { + return Ok(self.get_deck_config(conf_id, true)?.unwrap()); + } + } + + Ok(DeckConf::default()) + } } #[cfg(test)] diff --git a/rslib/src/deckconf/mod.rs b/rslib/src/deckconf/mod.rs index 4b909c03a..f0258e9e0 100644 --- a/rslib/src/deckconf/mod.rs +++ b/rslib/src/deckconf/mod.rs @@ -5,6 +5,7 @@ use crate::{ collection::Collection, define_newtype, err::{AnkiError, Result}, + sched::states::review::INITIAL_EASE_FACTOR, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, }; @@ -15,7 +16,7 @@ pub use crate::backend_proto::{ }; pub use schema11::{DeckConfSchema11, NewCardOrderSchema11}; /// Old deck config and cards table store 250% as 2500. -pub const INITIAL_EASE_FACTOR_THOUSANDS: u16 = 2500; +pub(crate) const INITIAL_EASE_FACTOR_THOUSANDS: u16 = (INITIAL_EASE_FACTOR * 1000.0) as u16; mod schema11; @@ -54,7 +55,7 @@ impl Default for DeckConf { lapse_multiplier: 0.0, interval_multiplier: 1.0, maximum_review_interval: 36_500, - minimum_review_interval: 1, + minimum_lapse_interval: 1, graduating_interval_good: 1, graduating_interval_easy: 4, new_card_order: NewCardOrder::Due as i32, diff --git a/rslib/src/deckconf/schema11.rs b/rslib/src/deckconf/schema11.rs index 411ec86e4..a6326ee9d 100644 --- a/rslib/src/deckconf/schema11.rs +++ b/rslib/src/deckconf/schema11.rs @@ -243,7 +243,7 @@ impl From for DeckConf { lapse_multiplier: c.lapse.mult, interval_multiplier: c.rev.ivl_fct, maximum_review_interval: c.rev.max_ivl, - minimum_review_interval: c.lapse.min_int, + minimum_lapse_interval: c.lapse.min_int, graduating_interval_good: c.new.ints.good as u32, graduating_interval_easy: c.new.ints.easy as u32, new_card_order: match c.new.order { @@ -327,7 +327,7 @@ impl From for DeckConfSchema11 { _ => LeechAction::Suspend, }, leech_fails: i.leech_threshold, - min_int: i.minimum_review_interval, + min_int: i.minimum_lapse_interval, mult: i.lapse_multiplier, other: lapse_other, }, diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 06bf15378..1c26e46b0 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -37,11 +37,6 @@ pub struct Deck { impl Deck { pub fn new_normal() -> Deck { - let mut norm = NormalDeck::default(); - norm.config_id = 1; - // enable in the future - // norm.markdown_description = true; - Deck { id: DeckID(0), name: "".into(), @@ -52,7 +47,12 @@ impl Deck { browser_collapsed: true, ..Default::default() }, - kind: DeckKind::Normal(norm), + kind: DeckKind::Normal(NormalDeck { + config_id: 1, + // enable in the future + // markdown_description = true, + ..Default::default() + }), } } diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 86d7266bb..6a93df75b 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -8,7 +8,7 @@ use reqwest::StatusCode; use std::{io, num::ParseIntError, str::Utf8Error}; use tempfile::PathPersistError; -pub type Result = std::result::Result; +pub type Result = std::result::Result; #[derive(Debug, Fail, PartialEq)] pub enum AnkiError { diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs index c07b4e9f3..b05c69675 100644 --- a/rslib/src/filtered.rs +++ b/rslib/src/filtered.rs @@ -75,6 +75,14 @@ impl Card { } } + pub(crate) fn original_or_current_deck_id(&self) -> DeckID { + if self.original_deck_id.0 > 0 { + self.original_deck_id + } else { + self.deck_id + } + } + pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self, sched: SchedulerVersion) { if self.original_deck_id.0 == 0 { // not in a filtered deck diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 0a3c9ccf5..6c19e94d8 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -58,10 +58,6 @@ pub struct NoteType { impl Default for NoteType { fn default() -> Self { - let mut conf = NoteTypeConfig::default(); - conf.css = DEFAULT_CSS.into(); - conf.latex_pre = DEFAULT_LATEX_HEADER.into(); - conf.latex_post = DEFAULT_LATEX_FOOTER.into(); NoteType { id: NoteTypeID(0), name: "".into(), @@ -69,7 +65,12 @@ impl Default for NoteType { usn: Usn(0), fields: vec![], templates: vec![], - config: conf, + config: NoteTypeConfig { + css: DEFAULT_CSS.into(), + latex_pre: DEFAULT_LATEX_HEADER.into(), + latex_post: DEFAULT_LATEX_FOOTER.into(), + ..Default::default() + }, } } } diff --git a/rslib/src/notetype/render.rs b/rslib/src/notetype/render.rs index 3e18f69ec..689405a09 100644 --- a/rslib/src/notetype/render.rs +++ b/rslib/src/notetype/render.rs @@ -76,9 +76,10 @@ impl Collection { } // no existing card; synthesize one - let mut card = Card::default(); - card.template_idx = card_ord; - Ok(card) + Ok(Card { + template_idx: card_ord, + ..Default::default() + }) } fn render_card_inner( diff --git a/rslib/src/notetype/stock.rs b/rslib/src/notetype/stock.rs index 079c6f2a2..b110f59ef 100644 --- a/rslib/src/notetype/stock.rs +++ b/rslib/src/notetype/stock.rs @@ -43,8 +43,10 @@ fn fieldref>(name: S) -> String { } pub(crate) fn basic(i18n: &I18n) -> NoteType { - let mut nt = NoteType::default(); - nt.name = i18n.tr(TR::NotetypesBasicName).into(); + let mut nt = NoteType { + name: i18n.tr(TR::NotetypesBasicName).into(), + ..Default::default() + }; let front = i18n.tr(TR::NotetypesFrontField); let back = i18n.tr(TR::NotetypesBackField); nt.add_field(front.as_ref()); diff --git a/rslib/src/revlog.rs b/rslib/src/revlog.rs index 2802a1fed..33fad146d 100644 --- a/rslib/src/revlog.rs +++ b/rslib/src/revlog.rs @@ -43,6 +43,7 @@ pub enum RevlogReviewKind { Relearning = 2, EarlyReview = 3, Manual = 4, + // Preview = 5, } impl Default for RevlogReviewKind { diff --git a/rslib/src/sched/answering.rs b/rslib/src/sched/answering.rs new file mode 100644 index 000000000..1680af574 --- /dev/null +++ b/rslib/src/sched/answering.rs @@ -0,0 +1,506 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + backend_proto, + card::{CardQueue, CardType}, + deckconf::DeckConf, + decks::{Deck, DeckKind}, + prelude::*, + revlog::{RevlogEntry, RevlogReviewKind}, +}; + +use super::{ + cutoff::SchedTimingToday, + states::{ + steps::LearningSteps, CardState, FilteredState, IntervalKind, LearnState, NewState, + NextCardStates, NormalState, PreviewState, RelearnState, ReschedulingFilterState, + ReviewState, StateContext, + }, +}; + +#[derive(Copy, Clone)] +pub enum Rating { + Again, + Hard, + Good, + Easy, +} + +pub struct CardAnswer { + pub card_id: CardID, + pub current_state: CardState, + pub new_state: CardState, + pub rating: Rating, + pub answered_at: TimestampSecs, + pub milliseconds_taken: u32, +} + +// FIXME: suspension +// fixme: fuzz learning intervals, graduating intervals +// fixme: 4 buttons for previewing +// fixme: log previewing +// fixme: - undo + +/// Information needed when answering a card. +struct AnswerContext { + deck: Deck, + config: DeckConf, + timing: SchedTimingToday, + now: TimestampSecs, + fuzz_seed: Option, +} + +impl AnswerContext { + fn state_context(&self) -> StateContext<'_> { + StateContext { + fuzz_seed: self.fuzz_seed, + steps: self.learn_steps(), + graduating_interval_good: self.config.inner.graduating_interval_good, + graduating_interval_easy: self.config.inner.graduating_interval_easy, + hard_multiplier: self.config.inner.hard_multiplier, + easy_multiplier: self.config.inner.easy_multiplier, + interval_multiplier: self.config.inner.interval_multiplier, + maximum_review_interval: self.config.inner.maximum_review_interval, + relearn_steps: self.relearn_steps(), + lapse_multiplier: self.config.inner.lapse_multiplier, + minimum_lapse_interval: self.config.inner.minimum_lapse_interval, + in_filtered_deck: self.deck.is_filtered(), + } + } + + fn secs_until_rollover(&self) -> u32 { + (self.timing.next_day_at - self.now.0).max(0) as u32 + } + + fn normal_study_state( + &self, + ctype: CardType, + due: i32, + interval: u32, + lapses: u32, + ease_factor: f32, + remaining_steps: u32, + ) -> NormalState { + match ctype { + CardType::New => NormalState::New(NewState { + position: due.max(0) as u32, + }), + CardType::Learn => { + LearnState { + scheduled_secs: self.learn_steps().current_delay_secs(remaining_steps), + remaining_steps, + } + } + .into(), + CardType::Review => ReviewState { + scheduled_days: interval, + elapsed_days: ((interval as i32) - (due - self.timing.days_elapsed as i32)).max(0) + as u32, + ease_factor, + lapses, + } + .into(), + CardType::Relearn => RelearnState { + learning: LearnState { + scheduled_secs: self.relearn_steps().current_delay_secs(remaining_steps), + remaining_steps, + }, + review: ReviewState { + scheduled_days: interval, + elapsed_days: interval, + ease_factor, + lapses, + }, + } + .into(), + } + } + + // FIXME: context depends on deck conf, but card passed in later - needs rethink + fn current_card_state(&self, card: &Card) -> CardState { + let interval = card.interval; + let lapses = card.lapses; + let ease_factor = card.ease_factor(); + let remaining_steps = card.remaining_steps(); + let ctype = card.ctype; + + let due = match &self.deck.kind { + DeckKind::Normal(_) => { + // if not in a filtered deck, ensure due time is not before today, + // which avoids tripping up test_nextIvl() in the Python tests + if matches!(ctype, CardType::Review) { + card.due.min(self.timing.days_elapsed as i32) + } else { + card.due + } + } + DeckKind::Filtered(_) => { + if card.original_due != 0 { + card.original_due + } else { + // v2 scheduler resets original_due on first answer + card.due + } + } + }; + + let normal_state = + self.normal_study_state(ctype, due, interval, lapses, ease_factor, remaining_steps); + + match &self.deck.kind { + // normal decks have normal state + DeckKind::Normal(_) => normal_state.into(), + // filtered decks wrap the normal state + DeckKind::Filtered(filtered) => { + if filtered.reschedule { + ReschedulingFilterState { + original_state: normal_state, + } + .into() + } else { + PreviewState { + scheduled_secs: filtered.preview_delay * 60, + original_state: normal_state, + } + .into() + } + } + } + } + + fn learn_steps(&self) -> LearningSteps<'_> { + LearningSteps::new(&self.config.inner.learn_steps) + } + + fn relearn_steps(&self) -> LearningSteps<'_> { + LearningSteps::new(&self.config.inner.relearn_steps) + } +} + +impl Card { + fn apply_study_state( + &mut self, + current: CardState, + next: CardState, + ctx: &AnswerContext, + ) -> Result> { + // any non-preview answer resets card.odue and increases reps + if !matches!(current, CardState::Filtered(FilteredState::Preview(_))) { + self.reps += 1; + self.original_due = 0; + } + + match next { + CardState::Normal(normal) => match normal { + NormalState::New(next) => self.apply_new_state(current, next, ctx), + NormalState::Learning(next) => self.apply_learning_state(current, next, ctx), + NormalState::Review(next) => self.apply_review_state(current, next, ctx), + NormalState::Relearning(next) => self.apply_relearning_state(current, next, ctx), + }, + CardState::Filtered(filtered) => match filtered { + FilteredState::Preview(next) => self.apply_preview_state(current, next, ctx), + FilteredState::Rescheduling(next) => { + self.apply_rescheduling_state(current, next, ctx) + } + }, + } + } + + fn apply_new_state( + &mut self, + current: CardState, + next: NewState, + ctx: &AnswerContext, + ) -> Result> { + self.ctype = CardType::New; + self.queue = CardQueue::New; + self.due = next.position as i32; + + Ok(RevlogEntryPartial::maybe_new( + current, + next.into(), + 0.0, + ctx.secs_until_rollover(), + )) + } + + fn apply_learning_state( + &mut self, + current: CardState, + next: LearnState, + ctx: &AnswerContext, + ) -> Result> { + self.remaining_steps = next.remaining_steps; + self.ctype = CardType::Learn; + + let interval = next + .interval_kind() + .maybe_as_days(ctx.secs_until_rollover()); + match interval { + IntervalKind::InSecs(secs) => { + self.queue = CardQueue::Learn; + self.due = TimestampSecs::now().0 as i32 + secs as i32; + } + IntervalKind::InDays(days) => { + self.queue = CardQueue::DayLearn; + self.due = (ctx.timing.days_elapsed + days) as i32; + } + } + + Ok(RevlogEntryPartial::maybe_new( + current, + next.into(), + 0.0, + ctx.secs_until_rollover(), + )) + } + + fn apply_review_state( + &mut self, + current: CardState, + next: ReviewState, + ctx: &AnswerContext, + ) -> Result> { + self.remove_from_filtered_deck_before_reschedule(); + + self.queue = CardQueue::Review; + self.ctype = CardType::Review; + self.interval = next.scheduled_days; + self.due = (ctx.timing.days_elapsed + next.scheduled_days) as i32; + self.ease_factor = (next.ease_factor * 1000.0).round() as u16; + self.lapses = next.lapses; + + Ok(RevlogEntryPartial::maybe_new( + current, + next.into(), + next.ease_factor, + ctx.secs_until_rollover(), + )) + } + + fn apply_relearning_state( + &mut self, + current: CardState, + next: RelearnState, + ctx: &AnswerContext, + ) -> Result> { + self.interval = next.review.scheduled_days; + self.remaining_steps = next.learning.remaining_steps; + self.ctype = CardType::Relearn; + + let interval = next + .interval_kind() + .maybe_as_days(ctx.secs_until_rollover()); + match interval { + IntervalKind::InSecs(secs) => { + self.queue = CardQueue::Learn; + self.due = TimestampSecs::now().0 as i32 + secs as i32; + } + IntervalKind::InDays(days) => { + self.queue = CardQueue::DayLearn; + self.due = (ctx.timing.days_elapsed + days) as i32; + } + } + + Ok(RevlogEntryPartial::maybe_new( + current, + next.into(), + next.review.ease_factor, + ctx.secs_until_rollover(), + )) + } + + // fixme: check learning card moved into preview + // restores correctly in both learn and day-learn case + fn apply_preview_state( + &mut self, + current: CardState, + next: PreviewState, + ctx: &AnswerContext, + ) -> Result> { + self.ensure_filtered()?; + self.queue = CardQueue::PreviewRepeat; + + let interval = next.interval_kind(); + match interval { + IntervalKind::InSecs(secs) => { + self.due = TimestampSecs::now().0 as i32 + secs as i32; + } + IntervalKind::InDays(_days) => { + unreachable!() + } + } + + Ok(RevlogEntryPartial::maybe_new( + current, + next.into(), + 0.0, + ctx.secs_until_rollover(), + )) + } + + // fixme: better name + fn apply_rescheduling_state( + &mut self, + current: CardState, + next: ReschedulingFilterState, + ctx: &AnswerContext, + ) -> Result> { + self.ensure_filtered()?; + self.apply_study_state(current, next.original_state.into(), ctx) + } + + fn ensure_filtered(&self) -> Result<()> { + if self.original_deck_id.0 == 0 { + Err(AnkiError::invalid_input( + "card answering can't transition into filtered state", + )) + } else { + Ok(()) + } + } +} + +impl Rating { + fn as_number(self) -> u8 { + match self { + Rating::Again => 1, + Rating::Hard => 2, + Rating::Good => 3, + Rating::Easy => 4, + } + } +} +pub struct RevlogEntryPartial { + interval: IntervalKind, + last_interval: IntervalKind, + ease_factor: f32, + review_kind: RevlogReviewKind, +} + +impl RevlogEntryPartial { + fn maybe_new( + current: CardState, + next: CardState, + ease_factor: f32, + secs_until_rollover: u32, + ) -> Option { + current.revlog_kind().map(|review_kind| { + let next_interval = next.interval_kind().maybe_as_days(secs_until_rollover); + let current_interval = current.interval_kind().maybe_as_days(secs_until_rollover); + + RevlogEntryPartial { + interval: next_interval, + last_interval: current_interval, + ease_factor, + review_kind, + } + }) + } + + fn into_revlog_entry( + self, + usn: Usn, + cid: CardID, + button_chosen: u8, + taken_millis: u32, + ) -> RevlogEntry { + RevlogEntry { + id: TimestampMillis::now(), + cid, + usn, + button_chosen, + interval: self.interval.as_revlog_interval(), + last_interval: self.last_interval.as_revlog_interval(), + ease_factor: (self.ease_factor * 1000.0).round() as u32, + taken_millis, + review_kind: self.review_kind, + } + } +} + +impl Collection { + pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<()> { + self.transact(None, |col| col.answer_card_inner(answer)) + } + + fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> { + let mut card = self + .storage + .get_card(answer.card_id)? + .ok_or(AnkiError::NotFound)?; + let answer_context = self.answer_context(&card)?; + let current_state = answer_context.current_card_state(&card); + if current_state != answer.current_state { + // fixme: unique error + return Err(AnkiError::invalid_input(format!( + "card was modified: {:#?} {:#?}", + current_state, answer.current_state, + ))); + } + let original = card.clone(); + let usn = self.usn()?; + + if let Some(revlog_partial) = + card.apply_study_state(current_state, answer.new_state, &answer_context)? + { + let button_chosen = answer.rating.as_number(); + let revlog = revlog_partial.into_revlog_entry( + usn, + answer.card_id, + button_chosen, + answer.milliseconds_taken, + ); + self.storage.add_revlog_entry(&revlog)?; + } + self.update_card(&mut card, &original, usn)?; + + // fixme: we're reusing code used by python, which means re-feteching the target deck + // - might want to avoid that in the future + self.update_deck_stats( + answer_context.timing.days_elapsed, + usn, + backend_proto::UpdateStatsIn { + deck_id: answer_context.deck.id.0, + new_delta: if matches!(current_state, CardState::Normal(NormalState::New(_))) { + 1 + } else { + 0 + }, + review_delta: if matches!(current_state, CardState::Normal(NormalState::Review(_))) + { + 1 + } else { + 0 + }, + millisecond_delta: answer.milliseconds_taken as i32, + }, + )?; + + Ok(()) + } + + fn answer_context(&mut self, card: &Card) -> Result { + let timing = self.timing_today()?; + Ok(AnswerContext { + // fixme: fetching deck twice + deck: self + .storage + .get_deck(card.deck_id)? + .ok_or(AnkiError::NotFound)?, + config: self.deck_config_for_card(card)?, + timing, + now: TimestampSecs::now(), + fuzz_seed: None, + }) + } + + pub fn get_next_card_states(&mut self, cid: CardID) -> Result { + let card = self.storage.get_card(cid)?.ok_or(AnkiError::NotFound)?; + let ctx = self.answer_context(&card)?; + let current = ctx.current_card_state(&card); + let state_ctx = ctx.state_context(); + Ok(current.next_states(&state_ctx)) + } +} diff --git a/rslib/src/sched/learning.rs b/rslib/src/sched/learning.rs index 10ee26f61..1878bb96b 100644 --- a/rslib/src/sched/learning.rs +++ b/rslib/src/sched/learning.rs @@ -32,27 +32,4 @@ impl Card { self.ease_factor = INITIAL_EASE_FACTOR_THOUSANDS; } } - - fn all_remaining_steps(&self) -> u32 { - self.remaining_steps % 1000 - } - - #[allow(dead_code)] - fn remaining_steps_today(&self) -> u32 { - self.remaining_steps / 1000 - } - - #[allow(dead_code)] - pub(crate) fn current_learning_delay_seconds(&self, delays: &[u32]) -> Option { - if self.queue == CardQueue::Learn { - let remaining = self.all_remaining_steps(); - delays - .iter() - .nth_back(remaining.saturating_sub(0) as usize) - .or(Some(&0)) - .map(|n| n * 60) - } else { - None - } - } } diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs index 464ba39db..d4ac18280 100644 --- a/rslib/src/sched/mod.rs +++ b/rslib/src/sched/mod.rs @@ -3,12 +3,14 @@ use crate::{collection::Collection, config::SchedulerVersion, err::Result, prelude::*}; +pub mod answering; pub mod bury_and_suspend; pub(crate) mod congrats; pub mod cutoff; mod learning; pub mod new; mod reviews; +pub mod states; pub mod timespan; use chrono::FixedOffset; diff --git a/rslib/src/sched/states/filtered.rs b/rslib/src/sched/states/filtered.rs new file mode 100644 index 000000000..717552d58 --- /dev/null +++ b/rslib/src/sched/states/filtered.rs @@ -0,0 +1,35 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::{IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, StateContext}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FilteredState { + Preview(PreviewState), + Rescheduling(ReschedulingFilterState), +} + +impl FilteredState { + pub(crate) fn interval_kind(self) -> IntervalKind { + match self { + FilteredState::Preview(state) => state.interval_kind(), + FilteredState::Rescheduling(state) => state.interval_kind(), + } + } + + pub(crate) fn revlog_kind(self) -> Option { + match self { + FilteredState::Preview(_state) => None, + FilteredState::Rescheduling(state) => Some(state.revlog_kind()), + } + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + match self { + FilteredState::Preview(state) => state.next_states(), + FilteredState::Rescheduling(state) => state.next_states(ctx), + } + } +} diff --git a/rslib/src/sched/states/interval_kind.rs b/rslib/src/sched/states/interval_kind.rs new file mode 100644 index 000000000..a7720695e --- /dev/null +++ b/rslib/src/sched/states/interval_kind.rs @@ -0,0 +1,38 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum IntervalKind { + InSecs(u32), + InDays(u32), +} + +impl IntervalKind { + /// Convert seconds-based intervals that pass the day barrier into days. + pub(crate) fn maybe_as_days(self, secs_until_rollover: u32) -> Self { + match self { + IntervalKind::InSecs(secs) => { + if secs >= secs_until_rollover { + IntervalKind::InDays(((secs - secs_until_rollover) / 86_400) + 1) + } else { + IntervalKind::InSecs(secs) + } + } + other => other, + } + } + + pub(crate) fn as_seconds(self) -> u32 { + match self { + IntervalKind::InSecs(secs) => secs, + IntervalKind::InDays(days) => days * 86_400, + } + } + + pub(crate) fn as_revlog_interval(self) -> i32 { + match self { + IntervalKind::InDays(days) => days as i32, + IntervalKind::InSecs(secs) => -(secs as i32), + } + } +} diff --git a/rslib/src/sched/states/learning.rs b/rslib/src/sched/states/learning.rs new file mode 100644 index 000000000..139b4d2f5 --- /dev/null +++ b/rslib/src/sched/states/learning.rs @@ -0,0 +1,78 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::{interval_kind::IntervalKind, CardState, NextCardStates, ReviewState, StateContext}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LearnState { + pub remaining_steps: u32, + pub scheduled_secs: u32, +} + +impl LearnState { + pub(crate) fn interval_kind(self) -> IntervalKind { + IntervalKind::InSecs(self.scheduled_secs) + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + RevlogReviewKind::Learning + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + NextCardStates { + current: self.into(), + again: self.answer_again(ctx).into(), + hard: self.answer_hard(ctx), + good: self.answer_good(ctx), + easy: self.answer_easy(ctx).into(), + } + } + + fn answer_again(self, ctx: &StateContext) -> LearnState { + LearnState { + remaining_steps: ctx.steps.remaining_for_failed(), + scheduled_secs: ctx.steps.again_delay_secs_learn(), + } + } + + fn answer_hard(self, ctx: &StateContext) -> CardState { + if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) { + LearnState { + scheduled_secs: hard_delay, + ..self + } + .into() + } else { + ReviewState { + scheduled_days: ctx.graduating_interval_good, + ..Default::default() + } + .into() + } + } + + fn answer_good(self, ctx: &StateContext) -> CardState { + if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) { + LearnState { + remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps), + scheduled_secs: good_delay, + } + .into() + } else { + ReviewState { + scheduled_days: ctx.graduating_interval_good, + ..Default::default() + } + .into() + } + } + + fn answer_easy(self, ctx: &StateContext) -> ReviewState { + ReviewState { + scheduled_days: ctx.graduating_interval_easy, + ..Default::default() + } + } +} diff --git a/rslib/src/sched/states/mod.rs b/rslib/src/sched/states/mod.rs new file mode 100644 index 000000000..fd28d5fd8 --- /dev/null +++ b/rslib/src/sched/states/mod.rs @@ -0,0 +1,129 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +pub(crate) mod filtered; +pub(crate) mod interval_kind; +pub(crate) mod learning; +pub(crate) mod new; +pub(crate) mod normal; +pub(crate) mod preview_filter; +pub(crate) mod relearning; +pub(crate) mod rescheduling_filter; +pub(crate) mod review; +pub(crate) mod steps; + +pub use { + filtered::FilteredState, learning::LearnState, new::NewState, normal::NormalState, + preview_filter::PreviewState, relearning::RelearnState, + rescheduling_filter::ReschedulingFilterState, review::ReviewState, +}; + +pub(crate) use interval_kind::IntervalKind; + +use crate::revlog::RevlogReviewKind; + +use self::steps::LearningSteps; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CardState { + Normal(NormalState), + Filtered(FilteredState), +} + +impl CardState { + pub(crate) fn interval_kind(self) -> IntervalKind { + match self { + CardState::Normal(normal) => normal.interval_kind(), + CardState::Filtered(filtered) => filtered.interval_kind(), + } + } + + pub(crate) fn revlog_kind(self) -> Option { + match self { + CardState::Normal(normal) => Some(normal.revlog_kind()), + CardState::Filtered(filtered) => filtered.revlog_kind(), + } + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + match self { + CardState::Normal(state) => state.next_states(&ctx), + CardState::Filtered(state) => state.next_states(&ctx), + } + } +} + +/// Info required during state transitions. +pub(crate) struct StateContext<'a> { + pub fuzz_seed: Option, + + // learning + pub steps: LearningSteps<'a>, + pub graduating_interval_good: u32, + pub graduating_interval_easy: u32, + + // reviewing + pub hard_multiplier: f32, + pub easy_multiplier: f32, + pub interval_multiplier: f32, + pub maximum_review_interval: u32, + + // relearning + pub relearn_steps: LearningSteps<'a>, + pub lapse_multiplier: f32, + pub minimum_lapse_interval: u32, + + // filtered + pub in_filtered_deck: bool, +} + +#[derive(Debug)] +pub struct NextCardStates { + pub current: CardState, + pub again: CardState, + pub hard: CardState, + pub good: CardState, + pub easy: CardState, +} + +impl From for CardState { + fn from(state: NewState) -> Self { + CardState::Normal(state.into()) + } +} + +impl From for CardState { + fn from(state: ReviewState) -> Self { + CardState::Normal(state.into()) + } +} + +impl From for CardState { + fn from(state: LearnState) -> Self { + CardState::Normal(state.into()) + } +} + +impl From for CardState { + fn from(state: RelearnState) -> Self { + CardState::Normal(state.into()) + } +} + +impl From for CardState { + fn from(state: NormalState) -> Self { + CardState::Normal(state) + } +} + +impl From for CardState { + fn from(state: PreviewState) -> Self { + CardState::Filtered(FilteredState::Preview(state)) + } +} + +impl From for CardState { + fn from(state: ReschedulingFilterState) -> Self { + CardState::Filtered(FilteredState::Rescheduling(state)) + } +} diff --git a/rslib/src/sched/states/new.rs b/rslib/src/sched/states/new.rs new file mode 100644 index 000000000..f67b55aa4 --- /dev/null +++ b/rslib/src/sched/states/new.rs @@ -0,0 +1,23 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::interval_kind::IntervalKind; + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct NewState { + pub position: u32, +} + +impl NewState { + pub(crate) fn interval_kind(self) -> IntervalKind { + // todo: consider packing the due number in here; it would allow us to restore the + // original position of cards - though not as cheaply as if it were a card property. + IntervalKind::InSecs(0) + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + RevlogReviewKind::Learning + } +} diff --git a/rslib/src/sched/states/normal.rs b/rslib/src/sched/states/normal.rs new file mode 100644 index 000000000..0b7704547 --- /dev/null +++ b/rslib/src/sched/states/normal.rs @@ -0,0 +1,82 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::{ + interval_kind::IntervalKind, LearnState, NewState, NextCardStates, RelearnState, ReviewState, + StateContext, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NormalState { + New(NewState), + Learning(LearnState), + Review(ReviewState), + Relearning(RelearnState), +} + +impl NormalState { + pub(crate) fn interval_kind(self) -> IntervalKind { + match self { + NormalState::New(state) => state.interval_kind(), + NormalState::Learning(state) => state.interval_kind(), + NormalState::Review(state) => state.interval_kind(), + NormalState::Relearning(state) => state.interval_kind(), + } + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + match self { + NormalState::New(state) => state.revlog_kind(), + NormalState::Learning(state) => state.revlog_kind(), + NormalState::Review(state) => state.revlog_kind(), + NormalState::Relearning(state) => state.revlog_kind(), + } + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + match self { + NormalState::New(_) => { + // New state acts like answering a failed learning card + let next_states = LearnState { + remaining_steps: ctx.steps.remaining_for_failed(), + scheduled_secs: 0, + } + .next_states(ctx); + // .. but with current as New, not Learning + NextCardStates { + current: self.into(), + ..next_states + } + } + NormalState::Learning(state) => state.next_states(ctx), + NormalState::Review(state) => state.next_states(ctx), + NormalState::Relearning(state) => state.next_states(ctx), + } + } +} + +impl From for NormalState { + fn from(state: NewState) -> Self { + NormalState::New(state) + } +} + +impl From for NormalState { + fn from(state: ReviewState) -> Self { + NormalState::Review(state) + } +} + +impl From for NormalState { + fn from(state: LearnState) -> Self { + NormalState::Learning(state) + } +} + +impl From for NormalState { + fn from(state: RelearnState) -> Self { + NormalState::Relearning(state) + } +} diff --git a/rslib/src/sched/states/preview_filter.rs b/rslib/src/sched/states/preview_filter.rs new file mode 100644 index 000000000..d49979f02 --- /dev/null +++ b/rslib/src/sched/states/preview_filter.rs @@ -0,0 +1,26 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{IntervalKind, NextCardStates, NormalState}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PreviewState { + pub scheduled_secs: u32, + pub original_state: NormalState, +} + +impl PreviewState { + pub(crate) fn interval_kind(self) -> IntervalKind { + IntervalKind::InSecs(self.scheduled_secs) + } + + pub(crate) fn next_states(self) -> NextCardStates { + NextCardStates { + current: self.into(), + again: self.into(), + hard: self.original_state.into(), + good: self.original_state.into(), + easy: self.original_state.into(), + } + } +} diff --git a/rslib/src/sched/states/relearning.rs b/rslib/src/sched/states/relearning.rs new file mode 100644 index 000000000..da5dec23e --- /dev/null +++ b/rslib/src/sched/states/relearning.rs @@ -0,0 +1,105 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::{ + interval_kind::IntervalKind, CardState, LearnState, NextCardStates, ReviewState, StateContext, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RelearnState { + pub learning: LearnState, + pub review: ReviewState, +} + +impl RelearnState { + pub(crate) fn interval_kind(self) -> IntervalKind { + self.learning.interval_kind() + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + RevlogReviewKind::Relearning + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + NextCardStates { + current: self.into(), + again: self.answer_again(ctx), + hard: self.answer_hard(ctx), + good: self.answer_good(ctx), + easy: self.answer_easy().into(), + } + } + + fn answer_again(self, ctx: &StateContext) -> CardState { + if let Some(learn_interval) = ctx.relearn_steps.again_delay_secs_relearn() { + RelearnState { + learning: LearnState { + remaining_steps: ctx.relearn_steps.remaining_for_failed(), + scheduled_secs: learn_interval, + }, + review: ReviewState { + scheduled_days: self.review.failing_review_interval(ctx), + elapsed_days: 0, + ..self.review + }, + } + .into() + } else { + self.review.into() + } + } + + fn answer_hard(self, ctx: &StateContext) -> CardState { + if let Some(learn_interval) = ctx + .relearn_steps + .hard_delay_secs(self.learning.remaining_steps) + { + RelearnState { + learning: LearnState { + scheduled_secs: learn_interval, + ..self.learning + }, + review: ReviewState { + elapsed_days: 0, + ..self.review + }, + } + .into() + } else { + self.review.into() + } + } + + fn answer_good(self, ctx: &StateContext) -> CardState { + if let Some(learn_interval) = ctx + .relearn_steps + .good_delay_secs(self.learning.remaining_steps) + { + RelearnState { + learning: LearnState { + scheduled_secs: learn_interval, + remaining_steps: ctx + .relearn_steps + .remaining_for_good(self.learning.remaining_steps), + }, + review: ReviewState { + elapsed_days: 0, + ..self.review + }, + } + .into() + } else { + self.review.into() + } + } + + fn answer_easy(self) -> ReviewState { + ReviewState { + scheduled_days: self.review.scheduled_days + 1, + elapsed_days: 0, + ..self.review + } + } +} diff --git a/rslib/src/sched/states/rescheduling_filter.rs b/rslib/src/sched/states/rescheduling_filter.rs new file mode 100644 index 000000000..2aa110a71 --- /dev/null +++ b/rslib/src/sched/states/rescheduling_filter.rs @@ -0,0 +1,60 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::revlog::RevlogReviewKind; + +use super::{ + interval_kind::IntervalKind, normal::NormalState, CardState, NextCardStates, StateContext, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ReschedulingFilterState { + pub original_state: NormalState, +} + +impl ReschedulingFilterState { + pub(crate) fn interval_kind(self) -> IntervalKind { + self.original_state.interval_kind() + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + self.original_state.revlog_kind() + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + let normal = self.original_state.next_states(&ctx); + if ctx.in_filtered_deck { + NextCardStates { + current: self.into(), + again: maybe_wrap(normal.again), + hard: maybe_wrap(normal.hard), + good: maybe_wrap(normal.good), + easy: maybe_wrap(normal.easy), + } + } else { + // card is marked as filtered, but not in a filtered deck; convert to normal + normal + } + } +} + +/// The review state is returned unchanged because cards are returned to +/// their original deck in that state; other normal states are wrapped +/// in the filtered state. Providing a filtered state is an error. +fn maybe_wrap(state: CardState) -> CardState { + match state { + CardState::Normal(normal) => { + if matches!(normal, NormalState::Review(_)) { + normal.into() + } else { + ReschedulingFilterState { + original_state: normal, + } + .into() + } + } + CardState::Filtered(_) => { + unreachable!() + } + } +} diff --git a/rslib/src/sched/states/review.rs b/rslib/src/sched/states/review.rs new file mode 100644 index 000000000..2c5cd137a --- /dev/null +++ b/rslib/src/sched/states/review.rs @@ -0,0 +1,234 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use rand::prelude::*; +use rand::rngs::StdRng; + +use crate::revlog::RevlogReviewKind; + +use super::{ + interval_kind::IntervalKind, CardState, LearnState, NextCardStates, RelearnState, StateContext, +}; + +pub const INITIAL_EASE_FACTOR: f32 = 2.5; +pub const MINIMUM_EASE_FACTOR: f32 = 1.3; +pub const EASE_FACTOR_AGAIN_DELTA: f32 = -0.2; +pub const EASE_FACTOR_HARD_DELTA: f32 = -0.15; +pub const EASE_FACTOR_EASY_DELTA: f32 = 0.15; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ReviewState { + pub scheduled_days: u32, + pub elapsed_days: u32, + pub ease_factor: f32, + pub lapses: u32, +} + +impl Default for ReviewState { + fn default() -> Self { + ReviewState { + scheduled_days: 0, + elapsed_days: 0, + ease_factor: INITIAL_EASE_FACTOR, + lapses: 0, + } + } +} + +impl ReviewState { + pub(crate) fn days_late(&self) -> i32 { + self.elapsed_days as i32 - self.scheduled_days as i32 + } + + pub(crate) fn interval_kind(self) -> IntervalKind { + // fixme: maybe use elapsed days in the future? would only + // make sense for revlog's lastIvl, not for future interval + IntervalKind::InDays(self.scheduled_days) + } + + pub(crate) fn revlog_kind(self) -> RevlogReviewKind { + if self.days_late() < 0 { + RevlogReviewKind::EarlyReview + } else { + RevlogReviewKind::Review + } + } + + pub(crate) fn next_states(self, ctx: &StateContext) -> NextCardStates { + let (hard_interval, good_interval, easy_interval) = self.passing_review_intervals(ctx); + + NextCardStates { + current: self.into(), + again: self.answer_again(ctx), + hard: self.answer_hard(hard_interval).into(), + good: self.answer_good(good_interval).into(), + easy: self.answer_easy(easy_interval).into(), + } + } + + pub(crate) fn failing_review_interval(self, ctx: &StateContext) -> u32 { + // fixme: floor() is for python + (((self.scheduled_days as f32) * ctx.lapse_multiplier).floor() as u32) + .max(ctx.minimum_lapse_interval) + .max(1) + } + + fn answer_again(self, ctx: &StateContext) -> CardState { + let again_review = ReviewState { + scheduled_days: self.failing_review_interval(ctx), + elapsed_days: 0, + ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR), + lapses: self.lapses + 1, + }; + + if let Some(learn_interval) = ctx.relearn_steps.again_delay_secs_relearn() { + RelearnState { + learning: LearnState { + remaining_steps: ctx.relearn_steps.remaining_for_failed(), + scheduled_secs: learn_interval, + }, + review: again_review, + } + .into() + } else { + again_review.into() + } + } + + fn answer_hard(self, scheduled_days: u32) -> ReviewState { + ReviewState { + scheduled_days, + elapsed_days: 0, + ease_factor: (self.ease_factor + EASE_FACTOR_HARD_DELTA).max(MINIMUM_EASE_FACTOR), + ..self + } + } + + fn answer_good(self, scheduled_days: u32) -> ReviewState { + ReviewState { + scheduled_days, + elapsed_days: 0, + ..self + } + } + + fn answer_easy(self, scheduled_days: u32) -> ReviewState { + ReviewState { + scheduled_days, + elapsed_days: 0, + ease_factor: self.ease_factor + EASE_FACTOR_EASY_DELTA, + ..self + } + } + + /// Return the intervals for hard, good and easy, each of which depends on the previous. + fn passing_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { + if self.days_late() < 0 { + self.passing_early_review_intervals(ctx) + } else { + self.passing_nonearly_review_intervals(ctx) + } + } + + fn passing_nonearly_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { + let current_interval = self.scheduled_days as f32; + let days_late = self.days_late().max(0) as f32; + let hard_factor = ctx.hard_multiplier; + let hard_minimum = if hard_factor <= 1.0 { + 0 + } else { + self.scheduled_days + 1 + }; + + // fixme: floor() is to match python + + let hard_interval = + constrain_passing_interval(ctx, current_interval * hard_factor, hard_minimum); + let good_interval = constrain_passing_interval( + ctx, + (current_interval + (days_late / 2.0).floor()) * self.ease_factor, + hard_interval + 1, + ); + let easy_interval = constrain_passing_interval( + ctx, + (current_interval + days_late) * self.ease_factor * ctx.easy_multiplier, + good_interval + 1, + ); + + (hard_interval, good_interval, easy_interval) + } + + /// Mostly direct port from the Python version for now, so we can confirm implementation + /// is correct. + /// FIXME: this needs reworking in the future; it overly penalizes reviews done + /// shortly before the due date. + fn passing_early_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) { + let scheduled = self.scheduled_days as f32; + let elapsed = (self.scheduled_days as f32) + (self.days_late() as f32); + + let hard_interval = { + let factor = ctx.hard_multiplier; + let half_usual = factor / 2.0; + constrain_passing_interval(ctx, (elapsed * factor).max(scheduled * half_usual), 0) + }; + + let good_interval = + constrain_passing_interval(ctx, (elapsed * self.ease_factor).max(scheduled), 0); + + let easy_interval = { + // currently flooring() f64s to match python output + let easy_mult = ctx.easy_multiplier as f64; + let reduced_bonus = easy_mult - (easy_mult - 1.0) / 2.0; + constrain_passing_interval( + ctx, + ((elapsed as f64 * self.ease_factor as f64).max(scheduled as f64) * reduced_bonus) + .floor() as f32, + 0, + ) + }; + + (hard_interval, good_interval, easy_interval) + } +} + +fn fuzz_range(interval: f32, factor: f32, minimum: f32) -> (f32, f32) { + let delta = (interval * factor).max(minimum).max(1.0); + (interval - delta, interval + delta) +} + +/// Transform the provided hard/good/easy interval. +/// - Apply configured interval multiplier. +/// - Apply fuzz. +/// - Ensure it is at least `minimum`, and at least 1. +/// - Ensure it is at or below the configured maximum interval. +fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32) -> u32 { + // fixme: floor is to match python + let interval = interval.floor(); + with_review_fuzz(ctx.fuzz_seed, interval * ctx.interval_multiplier) + .max(minimum) + .min(ctx.maximum_review_interval) + .max(1) +} + +fn with_review_fuzz(seed: Option, interval: f32) -> u32 { + // fixme: floor() is to match python + let interval = interval.floor(); + if let Some(seed) = seed { + let mut rng = StdRng::seed_from_u64(seed); + let (lower, upper) = if interval < 2.0 { + (1.0, 1.0) + } else if interval < 3.0 { + (2.0, 3.0) + } else if interval < 7.0 { + fuzz_range(interval, 0.25, 0.0) + } else if interval < 30.0 { + fuzz_range(interval, 0.15, 2.0) + } else { + fuzz_range(interval, 0.05, 4.0) + }; + rng.gen_range(lower, upper + 1.0) + } else { + interval + } + .round() as u32 +} diff --git a/rslib/src/sched/states/steps.rs b/rslib/src/sched/states/steps.rs new file mode 100644 index 000000000..62633af1d --- /dev/null +++ b/rslib/src/sched/states/steps.rs @@ -0,0 +1,82 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +const DEFAULT_SECS_IF_MISSING: u32 = 60; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LearningSteps<'a> { + steps: &'a [f32], +} + +fn to_secs(v: f32) -> u32 { + (v * 60.0) as u32 +} + +impl<'a> LearningSteps<'a> { + pub(crate) fn new(steps: &[f32]) -> LearningSteps<'_> { + LearningSteps { steps } + } + + /// Strip off 'learning today', and ensure index is in bounds. + fn get_index(self, remaining: u32) -> usize { + let total = self.steps.len(); + total + .saturating_sub((remaining % 1000) as usize) + .min(total.saturating_sub(1)) + } + + fn secs_at_index(&self, index: usize) -> Option { + self.steps.get(index).copied().map(to_secs) + } + + /// Cards in learning must always have at least one learning step. + pub(crate) fn again_delay_secs_learn(&self) -> u32 { + self.secs_at_index(0).unwrap_or(DEFAULT_SECS_IF_MISSING) + } + + pub(crate) fn again_delay_secs_relearn(&self) -> Option { + self.secs_at_index(0) + } + + // fixme: the logic here is not ideal, but tries to match + // the current python code + + pub(crate) fn hard_delay_secs(self, remaining: u32) -> Option { + let idx = self.get_index(remaining); + if let Some(current) = self + .secs_at_index(idx) + // if current is invalid, try first step + .or_else(|| self.steps.first().copied().map(to_secs)) + { + let next = if self.steps.len() > 1 { + self.secs_at_index(idx + 1).unwrap_or(60) + } else { + current * 2 + } + .max(current); + + Some((current + next) / 2) + } else { + None + } + } + + pub(crate) fn good_delay_secs(self, remaining: u32) -> Option { + let idx = self.get_index(remaining); + self.secs_at_index(idx + 1) + } + + pub(crate) fn current_delay_secs(self, remaining: u32) -> u32 { + let idx = self.get_index(remaining); + self.secs_at_index(idx).unwrap_or_default() + } + + pub(crate) fn remaining_for_good(self, remaining: u32) -> u32 { + let idx = self.get_index(remaining); + self.steps.len().saturating_sub(idx + 1) as u32 + } + + pub(crate) fn remaining_for_failed(self) -> u32 { + self.steps.len() as u32 + } +} diff --git a/rslib/src/sched/timespan.rs b/rslib/src/sched/timespan.rs index d8dfe15e4..2c8ce3ff4 100644 --- a/rslib/src/sched/timespan.rs +++ b/rslib/src/sched/timespan.rs @@ -18,6 +18,17 @@ pub fn answer_button_time(seconds: f32, i18n: &I18n) -> String { i18n.trn(key, args) } +/// Short string like '4d' to place above answer buttons. +/// Times within the collapse time are represented like '<10m' +pub fn answer_button_time_collapsible(seconds: u32, collapse_secs: u32, i18n: &I18n) -> String { + let string = answer_button_time(seconds as f32, i18n); + if seconds < collapse_secs { + format!("<{}", string) + } else { + string + } +} + /// Describe the given seconds using the largest appropriate unit. /// If precise is true, show to two decimal places, eg /// eg 70 seconds -> "1.17 minutes" diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 121386caf..46000bf4d 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -417,7 +417,7 @@ fn render_into( current_text: "".into(), }); } - Replacement { key, filters } if key == "" && !filters.is_empty() => { + Replacement { key, filters } if key.is_empty() && !filters.is_empty() => { // if a filter is provided, we accept an empty field name to // mean 'pass an empty string to the filter, and it will add // its own text' diff --git a/rslib/src/text.rs b/rslib/src/text.rs index 003bdd967..e4ed3e8d2 100644 --- a/rslib/src/text.rs +++ b/rslib/src/text.rs @@ -117,7 +117,7 @@ pub fn strip_av_tags(text: &str) -> Cow { } /// Extract audio tags from string, replacing them with [anki:play] refs -pub fn extract_av_tags<'a>(text: &'a str, question_side: bool) -> (Cow<'a, str>, Vec) { +pub fn extract_av_tags(text: &str, question_side: bool) -> (Cow, Vec) { let mut tags = vec![]; let context = if question_side { 'q' } else { 'a' }; let replaced_text = AV_TAGS.replace_all(text, |caps: &Captures| {