mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 15:32:23 -04:00
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:
parent
54fa322f3d
commit
ab790c1d14
41 changed files with 2258 additions and 398 deletions
|
@ -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;
|
||||
}
|
||||
|
|
76
rslib/src/backend/generic.rs
Normal file
76
rslib/src/backend/generic.rs
Normal 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 }
|
||||
}
|
||||
}
|
|
@ -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(",")
|
||||
}
|
||||
}
|
||||
|
|
32
rslib/src/backend/sched/answering.rs
Normal file
32
rslib/src/backend/sched/answering.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
5
rslib/src/backend/sched/mod.rs
Normal file
5
rslib/src/backend/sched/mod.rs
Normal 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;
|
35
rslib/src/backend/sched/states/filtered.rs
Normal file
35
rslib/src/backend/sched/states/filtered.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
22
rslib/src/backend/sched/states/learning.rs
Normal file
22
rslib/src/backend/sched/states/learning.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
66
rslib/src/backend/sched/states/mod.rs
Normal file
66
rslib/src/backend/sched/states/mod.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
20
rslib/src/backend/sched/states/new.rs
Normal file
20
rslib/src/backend/sched/states/new.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
41
rslib/src/backend/sched/states/normal.rs
Normal file
41
rslib/src/backend/sched/states/normal.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
22
rslib/src/backend/sched/states/preview.rs
Normal file
22
rslib/src/backend/sched/states/preview.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
22
rslib/src/backend/sched/states/relearning.rs
Normal file
22
rslib/src/backend/sched/states/relearning.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
20
rslib/src/backend/sched/states/rescheduling.rs
Normal file
20
rslib/src/backend/sched/states/rescheduling.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
26
rslib/src/backend/sched/states/review.rs
Normal file
26
rslib/src/backend/sched/states/review.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -43,6 +43,7 @@ pub enum RevlogReviewKind {
|
|||
Relearning = 2,
|
||||
EarlyReview = 3,
|
||||
Manual = 4,
|
||||
// Preview = 5,
|
||||
}
|
||||
|
||||
impl Default for RevlogReviewKind {
|
||||
|
|
506
rslib/src/sched/answering.rs
Normal file
506
rslib/src/sched/answering.rs
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
35
rslib/src/sched/states/filtered.rs
Normal file
35
rslib/src/sched/states/filtered.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
38
rslib/src/sched/states/interval_kind.rs
Normal file
38
rslib/src/sched/states/interval_kind.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
78
rslib/src/sched/states/learning.rs
Normal file
78
rslib/src/sched/states/learning.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
129
rslib/src/sched/states/mod.rs
Normal file
129
rslib/src/sched/states/mod.rs
Normal 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))
|
||||
}
|
||||
}
|
23
rslib/src/sched/states/new.rs
Normal file
23
rslib/src/sched/states/new.rs
Normal 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
|
||||
}
|
||||
}
|
82
rslib/src/sched/states/normal.rs
Normal file
82
rslib/src/sched/states/normal.rs
Normal 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)
|
||||
}
|
||||
}
|
26
rslib/src/sched/states/preview_filter.rs
Normal file
26
rslib/src/sched/states/preview_filter.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
105
rslib/src/sched/states/relearning.rs
Normal file
105
rslib/src/sched/states/relearning.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
60
rslib/src/sched/states/rescheduling_filter.rs
Normal file
60
rslib/src/sched/states/rescheduling_filter.rs
Normal 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!()
|
||||
}
|
||||
}
|
||||
}
|
234
rslib/src/sched/states/review.rs
Normal file
234
rslib/src/sched/states/review.rs
Normal 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
|
||||
}
|
82
rslib/src/sched/states/steps.rs
Normal file
82
rslib/src/sched/states/steps.rs
Normal 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
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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| {
|
||||
|
|
Loading…
Reference in a new issue