initial work on moving v2 card answering into backend

Not plugged into the Python code yet. Still a work in progress.

Other changes:

- move a bunch of From implementations out of the giant backend/mod.rs
file into separate submodules.
- reorder backend methods to match proto order
- fix some clippy lints
This commit is contained in:
Damien Elmes 2021-02-20 14:13:03 +10:00
parent 54fa322f3d
commit ab790c1d14
41 changed files with 2258 additions and 398 deletions

View file

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

View file

@ -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<Vec<u8>> for pb::Json {
fn from(json: Vec<u8>) -> Self {
pb::Json { json }
}
}
impl From<String> for pb::String {
fn from(val: String) -> Self {
pb::String { val }
}
}
impl From<i64> for pb::Int64 {
fn from(val: i64) -> Self {
pb::Int64 { val }
}
}
impl From<u32> for pb::UInt32 {
fn from(val: u32) -> Self {
pb::UInt32 { val }
}
}
impl From<()> for pb::Empty {
fn from(_val: ()) -> Self {
pb::Empty {}
}
}
impl From<pb::CardId> for CardID {
fn from(cid: pb::CardId) -> Self {
CardID(cid.cid)
}
}
impl Into<Vec<CardID>> for pb::CardIDs {
fn into(self) -> Vec<CardID> {
self.cids.into_iter().map(CardID).collect()
}
}
impl From<pb::NoteId> for NoteID {
fn from(nid: pb::NoteId) -> Self {
NoteID(nid.nid)
}
}
impl From<pb::NoteTypeId> for NoteTypeID {
fn from(ntid: pb::NoteTypeId) -> Self {
NoteTypeID(ntid.ntid)
}
}
impl From<pb::DeckId> for DeckID {
fn from(did: pb::DeckId) -> Self {
DeckID(did.did)
}
}
impl From<pb::DeckConfigId> for DeckConfID {
fn from(dcid: pb::DeckConfigId) -> Self {
DeckConfID(dcid.dcid)
}
}
impl From<Vec<String>> for pb::StringList {
fn from(vals: Vec<String>) -> Self {
pb::StringList { vals }
}
}

View file

@ -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<Mutex<ProgressState>>,
@ -218,82 +220,6 @@ pub fn init_backend(init_msg: &[u8]) -> std::result::Result<Backend, String> {
Ok(Backend::new(i18n, input.server))
}
impl From<Vec<u8>> for pb::Json {
fn from(json: Vec<u8>) -> Self {
pb::Json { json }
}
}
impl From<String> for pb::String {
fn from(val: String) -> Self {
pb::String { val }
}
}
impl From<i64> for pb::Int64 {
fn from(val: i64) -> Self {
pb::Int64 { val }
}
}
impl From<u32> for pb::UInt32 {
fn from(val: u32) -> Self {
pb::UInt32 { val }
}
}
impl From<()> for pb::Empty {
fn from(_val: ()) -> Self {
pb::Empty {}
}
}
impl From<pb::CardId> for CardID {
fn from(cid: pb::CardId) -> Self {
CardID(cid.cid)
}
}
impl pb::CardIDs {
fn into_native(self) -> Vec<CardID> {
self.cids.into_iter().map(CardID).collect()
}
}
impl From<pb::NoteId> 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::<Vec<_>>()
.join(",")
}
}
impl From<pb::NoteTypeId> for NoteTypeID {
fn from(ntid: pb::NoteTypeId) -> Self {
NoteTypeID(ntid.ntid)
}
}
impl From<pb::DeckId> for DeckID {
fn from(did: pb::DeckId) -> Self {
DeckID(did.did)
}
}
impl From<pb::DeckConfigId> for DeckConfID {
fn from(dcid: pb::DeckConfigId) -> Self {
DeckConfID(dcid.dcid)
}
}
impl TryFrom<pb::SearchNode> for Node {
type Error = AnkiError;
@ -436,62 +362,6 @@ impl BackendService for Backend {
// card rendering
fn render_existing_card(
&self,
input: pb::RenderExistingCardIn,
) -> BackendResult<pb::RenderCardOut> {
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<pb::RenderCardOut> {
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<pb::EmptyCardsReport> {
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<pb::String> {
Ok(pb::String {
val: strip_av_tags(&input.val).into(),
})
}
fn extract_av_tags(&self, input: pb::ExtractAvTagsIn) -> BackendResult<pb::ExtractAvTagsOut> {
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<pb::EmptyCardsReport> {
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<pb::RenderCardOut> {
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<pb::RenderCardOut> {
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<pb::String> {
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<Empty> {
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<pb::NextCardStates> {
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<pb::StringList> {
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<pb::Empty> {
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<pb::CheckMediaOut> {
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<Empty> {
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<pb::String> {
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<Empty> {
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<Empty> {
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<pb::DeckId> {
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<pb::DeckTreeNode> {
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<pb::Json> {
fn get_all_decks_legacy(&self, _input: Empty) -> BackendResult<pb::Json> {
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<pb::DeckId> {
@ -818,12 +870,17 @@ impl BackendService for Backend {
})
}
fn get_all_decks_legacy(&self, _input: Empty) -> BackendResult<pb::Json> {
fn get_deck_legacy(&self, input: pb::DeckId) -> Result<pb::Json> {
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<pb::DeckNames> {
@ -842,22 +899,6 @@ impl BackendService for Backend {
})
}
fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result<pb::DeckId> {
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<pb::Json> {
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<pb::Json> {
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<pb::Json> {
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<pb::Json> {
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<pb::FieldNamesForNotesOut> {
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<Empty> {
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<pb::FieldNamesForNotesOut> {
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<pb::NoteTypeId> {
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<pb::Json> {
// 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<pb::Json> {
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<pb::NoteTypeNames> {
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<pb::Json> {
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<pb::NoteTypeId> {
self.with_col(|col| {
col.storage
@ -1188,120 +1245,14 @@ impl BackendService for Backend {
})
}
fn add_or_update_notetype(
&self,
input: pb::AddOrUpdateNotetypeIn,
) -> BackendResult<pb::NoteTypeId> {
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<Empty> {
self.with_col(|col| col.remove_notetype(input.into()))
.map(Into::into)
}
// media
//-------------------------------------------------------------------
fn add_media_file(&self, input: pb::AddMediaFileIn) -> BackendResult<pb::String> {
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<Empty> {
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<Empty> {
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<Empty> {
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<pb::CheckMediaOut> {
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<pb::CheckDatabaseOut> {
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<Empty> {
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<pb::CheckDatabaseOut> {
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<pb::SyncAuth> {
self.sync_login_inner(input)
}
fn sync_status(&self, input: pb::SyncAuth) -> BackendResult<pb::SyncStatusOut> {
self.sync_status_inner(input)
}
fn sync_collection(&self, input: pb::SyncAuth) -> BackendResult<pb::SyncCollectionOut> {
self.sync_collection_inner(input)
}
fn full_upload(&self, input: pb::SyncAuth) -> BackendResult<Empty> {
self.full_sync_inner(input, true)?;
Ok(().into())
}
fn full_download(&self, input: pb::SyncAuth) -> BackendResult<Empty> {
self.full_sync_inner(input, false)?;
Ok(().into())
}
fn sync_media(&self, input: pb::SyncAuth) -> BackendResult<Empty> {
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<pb::SyncAuth> {
self.sync_login_inner(input)
}
fn sync_status(&self, input: pb::SyncAuth) -> BackendResult<pb::SyncStatusOut> {
self.sync_status_inner(input)
}
fn sync_collection(&self, input: pb::SyncAuth) -> BackendResult<pb::SyncCollectionOut> {
self.sync_collection_inner(input)
}
fn full_upload(&self, input: pb::SyncAuth) -> BackendResult<Empty> {
self.full_sync_inner(input, true)?;
Ok(().into())
}
fn full_download(&self, input: pb::SyncAuth) -> BackendResult<Empty> {
self.full_sync_inner(input, false)?;
Ok(().into())
}
fn sync_server_method(&self, input: pb::SyncServerMethodIn) -> BackendResult<pb::Json> {
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<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
}
fn all_tags(&self, _input: Empty) -> BackendResult<pb::StringList> {
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<pb::Empty> {
self.with_col(|col| col.transact(None, |col| col.clear_unused_tags().map(Into::into)))
}
fn clear_tag(&self, tag: pb::String) -> BackendResult<pb::Empty> {
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<pb::Preferences> {
self.with_col(|col| col.get_preferences())
}
fn set_preferences(&self, input: pb::Preferences) -> BackendResult<Empty> {
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<pb::Bool> {
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<pb::Preferences> {
self.with_col(|col| col.get_preferences())
}
fn set_preferences(&self, input: pb::Preferences) -> BackendResult<Empty> {
self.with_col(|col| col.transact(None, |col| col.set_preferences(input)))
.map(Into::into)
}
}
impl Backend {
@ -2172,3 +2136,13 @@ impl From<NormalSyncProgress> 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::<Vec<_>>()
.join(",")
}
}

View file

@ -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<pb::AnswerCardIn> 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<pb::answer_card_in::Rating> 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,
}
}
}

View file

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

View file

@ -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<FilteredState> 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<pb::scheduling_state::Filtered> 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())
}
}
}
}

View file

@ -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<pb::scheduling_state::Learning> for LearnState {
fn from(state: pb::scheduling_state::Learning) -> Self {
LearnState {
remaining_steps: state.remaining_steps,
scheduled_secs: state.scheduled_secs,
}
}
}
impl From<LearnState> for pb::scheduling_state::Learning {
fn from(state: LearnState) -> Self {
pb::scheduling_state::Learning {
remaining_steps: state.remaining_steps,
scheduled_secs: state.scheduled_secs,
}
}
}

View file

@ -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<NextCardStates> 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<pb::NextCardStates> 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<CardState> 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<pb::SchedulingState> 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()))
}
}
}

View file

@ -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<pb::scheduling_state::New> for NewState {
fn from(state: pb::scheduling_state::New) -> Self {
NewState {
position: state.position,
}
}
}
impl From<NewState> for pb::scheduling_state::New {
fn from(state: NewState) -> Self {
pb::scheduling_state::New {
position: state.position,
}
}
}

View file

@ -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<NormalState> 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<pb::scheduling_state::Normal> 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())
}
}
}
}

View file

@ -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<pb::scheduling_state::Preview> 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<PreviewState> 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()),
}
}
}

View file

@ -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<pb::scheduling_state::Relearning> 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<RelearnState> for pb::scheduling_state::Relearning {
fn from(state: RelearnState) -> Self {
pb::scheduling_state::Relearning {
review: Some(state.review.into()),
learning: Some(state.learning.into()),
}
}
}

View file

@ -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<pb::scheduling_state::ReschedulingFilter> for ReschedulingFilterState {
fn from(state: pb::scheduling_state::ReschedulingFilter) -> Self {
ReschedulingFilterState {
original_state: state.original_state.unwrap_or_default().into(),
}
}
}
impl From<ReschedulingFilterState> for pb::scheduling_state::ReschedulingFilter {
fn from(state: ReschedulingFilterState) -> Self {
pb::scheduling_state::ReschedulingFilter {
original_state: Some(state.original_state.into()),
}
}
}

View file

@ -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<pb::scheduling_state::Review> 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<ReviewState> 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,
}
}
}

View file

@ -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<F, T>(&mut self, cid: CardID, func: F) -> Result<Card>
@ -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<DeckConf> {
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)]

View file

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

View file

@ -243,7 +243,7 @@ impl From<DeckConfSchema11> 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<DeckConf> 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,
},

View file

@ -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()
}),
}
}

View file

@ -8,7 +8,7 @@ use reqwest::StatusCode;
use std::{io, num::ParseIntError, str::Utf8Error};
use tempfile::PathPersistError;
pub type Result<T> = std::result::Result<T, AnkiError>;
pub type Result<T, E = AnkiError> = std::result::Result<T, E>;
#[derive(Debug, Fail, PartialEq)]
pub enum AnkiError {

View file

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

View file

@ -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()
},
}
}
}

View file

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

View file

@ -43,8 +43,10 @@ fn fieldref<S: AsRef<str>>(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());

View file

@ -43,6 +43,7 @@ pub enum RevlogReviewKind {
Relearning = 2,
EarlyReview = 3,
Manual = 4,
// Preview = 5,
}
impl Default for RevlogReviewKind {

View file

@ -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<u64>,
}
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<Option<RevlogEntryPartial>> {
// 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<Option<RevlogEntryPartial>> {
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<Option<RevlogEntryPartial>> {
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<Option<RevlogEntryPartial>> {
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<Option<RevlogEntryPartial>> {
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<Option<RevlogEntryPartial>> {
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<Option<RevlogEntryPartial>> {
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<Self> {
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<AnswerContext> {
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<NextCardStates> {
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))
}
}

View file

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

View file

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

View file

@ -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<RevlogReviewKind> {
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),
}
}
}

View file

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

View file

@ -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()
}
}
}

View file

@ -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<RevlogReviewKind> {
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<u64>,
// 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<NewState> for CardState {
fn from(state: NewState) -> Self {
CardState::Normal(state.into())
}
}
impl From<ReviewState> for CardState {
fn from(state: ReviewState) -> Self {
CardState::Normal(state.into())
}
}
impl From<LearnState> for CardState {
fn from(state: LearnState) -> Self {
CardState::Normal(state.into())
}
}
impl From<RelearnState> for CardState {
fn from(state: RelearnState) -> Self {
CardState::Normal(state.into())
}
}
impl From<NormalState> for CardState {
fn from(state: NormalState) -> Self {
CardState::Normal(state)
}
}
impl From<PreviewState> for CardState {
fn from(state: PreviewState) -> Self {
CardState::Filtered(FilteredState::Preview(state))
}
}
impl From<ReschedulingFilterState> for CardState {
fn from(state: ReschedulingFilterState) -> Self {
CardState::Filtered(FilteredState::Rescheduling(state))
}
}

View file

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

View file

@ -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<NewState> for NormalState {
fn from(state: NewState) -> Self {
NormalState::New(state)
}
}
impl From<ReviewState> for NormalState {
fn from(state: ReviewState) -> Self {
NormalState::Review(state)
}
}
impl From<LearnState> for NormalState {
fn from(state: LearnState) -> Self {
NormalState::Learning(state)
}
}
impl From<RelearnState> for NormalState {
fn from(state: RelearnState) -> Self {
NormalState::Relearning(state)
}
}

View file

@ -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(),
}
}
}

View file

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

View file

@ -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!()
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -117,7 +117,7 @@ pub fn strip_av_tags(text: &str) -> Cow<str> {
}
/// 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<AVTag>) {
pub fn extract_av_tags(text: &str, question_side: bool) -> (Cow<str>, Vec<AVTag>) {
let mut tags = vec![];
let context = if question_side { 'q' } else { 'a' };
let replaced_text = AV_TAGS.replace_all(text, |caps: &Captures| {