From ce49ca940167c4da181f124bf2cd0104559c2642 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 2 Sep 2020 17:18:29 +1000 Subject: [PATCH] log manual reschedule, but ignore the log entry in the stats --- proto/backend.proto | 6 ++- pylib/anki/collection.py | 3 ++ pylib/anki/stats.py | 4 +- qt/aqt/deckbrowser.py | 11 +--- rslib/ftl/card-stats.ftl | 2 + rslib/src/backend/mod.rs | 16 ++++-- rslib/src/card.rs | 30 +---------- rslib/src/revlog.rs | 38 ++++++++++++++ rslib/src/sched/learning.rs | 58 ++++++++++++++++++++++ rslib/src/sched/mod.rs | 1 + rslib/src/sched/reviews.rs | 1 + rslib/src/sched/timespan.rs | 44 ++++------------ rslib/src/stats/card.rs | 2 + rslib/src/stats/mod.rs | 3 ++ rslib/src/stats/today.rs | 45 +++++++++++++++++ rslib/src/storage/revlog/add.sql | 24 ++++++++- rslib/src/storage/revlog/mod.rs | 20 ++++++++ rslib/src/storage/revlog/studied_today.sql | 5 ++ rslib/src/timestamp.rs | 8 +++ ts/src/stats/reviews.ts | 4 ++ ts/src/stats/today.ts | 6 ++- 21 files changed, 249 insertions(+), 82 deletions(-) create mode 100644 rslib/src/sched/learning.rs create mode 100644 rslib/src/stats/today.rs create mode 100644 rslib/src/storage/revlog/studied_today.sql diff --git a/proto/backend.proto b/proto/backend.proto index b6fba6f84..95af6b8f1 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -95,7 +95,8 @@ service BackendService { rpc LocalMinutesWest (Int64) returns (Int32); rpc SetLocalMinutesWest (Int32) returns (Empty); rpc SchedTimingToday (Empty) returns (SchedTimingTodayOut); - rpc StudiedToday (StudiedTodayIn) returns (String); + rpc StudiedToday (Empty) returns (String); + rpc StudiedTodayMessage (StudiedTodayMessageIn) returns (String); rpc UpdateStats (UpdateStatsIn) returns (Empty); rpc ExtendLimits (ExtendLimitsIn) returns (Empty); rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut); @@ -685,7 +686,7 @@ message FormatTimespanIn { Context context = 2; } -message StudiedTodayIn { +message StudiedTodayMessageIn { uint32 cards = 1; double seconds = 2; } @@ -1016,6 +1017,7 @@ message RevlogEntry { REVIEW = 1; RELEARNING = 2; EARLY_REVIEW = 3; + MANUAL = 4; } int64 id = 1; int64 cid = 2; diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 320a6565b..d423f42a9 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -516,6 +516,9 @@ table.review-log {{ {revlog_style} }} return style + self.backend.card_stats(card_id) + def studied_today(self) -> str: + return self.backend.studied_today() + # legacy def cardStats(self, card: Card) -> str: diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py index e3f34fe3a..aedf641ec 100644 --- a/pylib/anki/stats.py +++ b/pylib/anki/stats.py @@ -145,7 +145,9 @@ from revlog where id > ? """ return "" + str(s) + "" if cards: - b += self.col.backend.studied_today(cards=cards, seconds=float(thetime)) + b += self.col.backend.studied_today_message( + cards=cards, seconds=float(thetime) + ) # again/pass count b += "
" + _("Again count: %s") % bold(failed) if cards: diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index b08eff116..d79aa7c08 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -138,16 +138,7 @@ class DeckBrowser: self.web.eval("$(function() { window.scrollTo(0, %d, 'instant'); });" % offset) def _renderStats(self): - cards, thetime = self.mw.col.db.first( - """ -select count(), sum(time)/1000 from revlog -where id > ?""", - (self.mw.col.sched.dayCutoff - 86400) * 1000, - ) - cards = cards or 0 - thetime = thetime or 0 - buf = self.mw.col.backend.studied_today(cards=cards, seconds=float(thetime)) - return buf + return self.mw.col.studied_today() def _renderDeckTree(self, top: DeckTreeNode) -> str: buf = """ diff --git a/rslib/ftl/card-stats.ftl b/rslib/ftl/card-stats.ftl index 63d78df88..ddaf797d9 100644 --- a/rslib/ftl/card-stats.ftl +++ b/rslib/ftl/card-stats.ftl @@ -21,3 +21,5 @@ card-stats-review-log-type-learn = Learn card-stats-review-log-type-review = Review card-stats-review-log-type-relearn = Relearn card-stats-review-log-type-filtered = Filtered +card-stats-review-log-type-manual = Manual + diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 755baacbf..e157ba0fc 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -31,8 +31,9 @@ use crate::{ RenderCardOutput, }, sched::cutoff::local_minutes_west_for_stamp, - sched::timespan::{answer_button_time, studied_today, time_span}, + sched::timespan::{answer_button_time, time_span}, search::SortMode, + stats::studied_today, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage, @@ -472,8 +473,17 @@ impl BackendService for Backend { }) } - fn studied_today(&mut self, input: pb::StudiedTodayIn) -> BackendResult { - Ok(studied_today(input.cards as usize, input.seconds as f32, &self.i18n).into()) + /// Fetch data from DB and return rendered string. + fn studied_today(&mut self, _input: pb::Empty) -> BackendResult { + self.with_col(|col| col.studied_today().map(Into::into)) + } + + /// Message rendering only, for old graphs. + fn studied_today_message( + &mut self, + input: pb::StudiedTodayMessageIn, + ) -> BackendResult { + Ok(studied_today(input.cards, input.seconds as f32, &self.i18n).into()) } fn update_stats(&mut self, input: pb::UpdateStatsIn) -> BackendResult { diff --git a/rslib/src/card.rs b/rslib/src/card.rs index e50235ec3..42404c21b 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -6,8 +6,8 @@ use crate::define_newtype; use crate::err::{AnkiError, Result}; use crate::notes::NoteID; use crate::{ - collection::Collection, config::SchedulerVersion, deckconf::INITIAL_EASE_FACTOR, - timestamp::TimestampSecs, types::Usn, undo::Undoable, + collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn, + undo::Undoable, }; use num_enum::TryFromPrimitive; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -200,32 +200,6 @@ impl Card { self.original_due = 0; } - - /// Remove the card from the (re)learning queue. - /// This will reset cards in learning. - /// Only used in the V1 scheduler. - /// Unlike the legacy Python code, this sets the due# to 0 instead of - /// one past the previous max due number. - pub(crate) fn remove_from_learning(&mut self) { - if !matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn) { - return; - } - - if self.ctype == CardType::Review { - // reviews are removed from relearning - self.due = self.original_due; - self.original_due = 0; - self.queue = CardQueue::Review; - } else { - // other cards are reset to new - self.ctype = CardType::New; - self.queue = CardQueue::New; - self.interval = 0; - self.due = 0; - self.original_due = 0; - self.ease_factor = INITIAL_EASE_FACTOR; - } - } } #[derive(Debug)] pub(crate) struct UpdateCardUndo(Card); diff --git a/rslib/src/revlog.rs b/rslib/src/revlog.rs index 1d4d9dd6c..71ab56a14 100644 --- a/rslib/src/revlog.rs +++ b/rslib/src/revlog.rs @@ -42,6 +42,7 @@ pub enum RevlogReviewKind { Review = 1, Relearning = 2, EarlyReview = 3, + Manual = 4, } impl Default for RevlogReviewKind { @@ -59,3 +60,40 @@ impl RevlogEntry { }) as u32 } } + +impl Card { + fn last_interval_for_revlog_todo(&self) -> i32 { + self.interval as i32 + + // fixme: need to pass in delays for (re)learning + // if let Some(delay) = self.current_learning_delay_seconds(&[]) { + // -(delay as i32) + // } else { + // self.interval as i32 + // } + } +} + +impl Collection { + pub(crate) fn log_manually_scheduled_review( + &mut self, + card: &Card, + usn: Usn, + next_interval: u32, + ) -> Result<()> { + println!("fixme: learning last_interval"); + // let deck = self.get_deck(card.deck_id)?.ok_or(AnkiError::NotFound)?; + let entry = RevlogEntry { + id: TimestampMillis::now(), + cid: card.id, + usn, + button_chosen: 0, + interval: next_interval as i32, + last_interval: card.last_interval_for_revlog_todo(), + ease_factor: card.ease_factor as u32, + taken_millis: 0, + review_kind: RevlogReviewKind::Manual, + }; + self.storage.add_revlog_entry(&entry) + } +} diff --git a/rslib/src/sched/learning.rs b/rslib/src/sched/learning.rs new file mode 100644 index 000000000..80f797bfa --- /dev/null +++ b/rslib/src/sched/learning.rs @@ -0,0 +1,58 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + card::{Card, CardQueue, CardType}, + deckconf::INITIAL_EASE_FACTOR, +}; + +impl Card { + /// Remove the card from the (re)learning queue. + /// This will reset cards in learning. + /// Only used in the V1 scheduler. + /// Unlike the legacy Python code, this sets the due# to 0 instead of + /// one past the previous max due number. + pub(crate) fn remove_from_learning(&mut self) { + if !matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn) { + return; + } + + if self.ctype == CardType::Review { + // reviews are removed from relearning + self.due = self.original_due; + self.original_due = 0; + self.queue = CardQueue::Review; + } else { + // other cards are reset to new + self.ctype = CardType::New; + self.queue = CardQueue::New; + self.interval = 0; + self.due = 0; + self.original_due = 0; + self.ease_factor = INITIAL_EASE_FACTOR; + } + } + + fn all_remaining_steps(&self) -> u32 { + self.remaining_steps % 1000 + } + + #[allow(dead_code)] + fn remaining_steps_today(&self) -> u32 { + self.remaining_steps / 1000 + } + + #[allow(dead_code)] + pub(crate) fn current_learning_delay_seconds(&self, delays: &[u32]) -> Option { + if self.queue == CardQueue::Learn { + let remaining = self.all_remaining_steps(); + delays + .iter() + .nth_back(remaining.saturating_sub(0) as usize) + .or(Some(&0)) + .map(|n| n * 60) + } else { + None + } + } +} diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs index af0df2997..8cd709e81 100644 --- a/rslib/src/sched/mod.rs +++ b/rslib/src/sched/mod.rs @@ -8,6 +8,7 @@ use crate::{ pub mod bury_and_suspend; pub(crate) mod congrats; pub mod cutoff; +mod learning; mod reviews; pub mod timespan; diff --git a/rslib/src/sched/reviews.rs b/rslib/src/sched/reviews.rs index 0ad8062b3..e39b22585 100644 --- a/rslib/src/sched/reviews.rs +++ b/rslib/src/sched/reviews.rs @@ -40,6 +40,7 @@ impl Collection { for mut card in col.storage.all_searched_cards()? { let original = card.clone(); let interval = distribution.sample(&mut rng); + col.log_manually_scheduled_review(&card, usn, interval)?; card.schedule_as_review(interval, today); col.update_card(&mut card, &original, usn)?; } diff --git a/rslib/src/sched/timespan.rs b/rslib/src/sched/timespan.rs index ff74e186e..d8dfe15e4 100644 --- a/rslib/src/sched/timespan.rs +++ b/rslib/src/sched/timespan.rs @@ -41,21 +41,6 @@ pub fn time_span(seconds: f32, i18n: &I18n, precise: bool) -> String { i18n.trn(key, args) } -// fixme: this doesn't belong here -pub fn studied_today(cards: usize, secs: f32, i18n: &I18n) -> String { - let span = Timespan::from_secs(secs).natural_span(); - let amount = span.as_unit(); - let unit = span.unit().as_str(); - let secs_per = if cards > 0 { - secs / (cards as f32) - } else { - 0.0 - }; - let args = tr_args!["amount" => amount, "unit" => unit, - "cards" => cards, "secs-per-card" => secs_per]; - i18n.trn(TR::StatisticsStudiedToday, args) -} - const SECOND: f32 = 1.0; const MINUTE: f32 = 60.0 * SECOND; const HOUR: f32 = 60.0 * MINUTE; @@ -64,7 +49,7 @@ const MONTH: f32 = 30.0 * DAY; const YEAR: f32 = 12.0 * MONTH; #[derive(Clone, Copy)] -enum TimespanUnit { +pub(crate) enum TimespanUnit { Seconds, Minutes, Hours, @@ -74,7 +59,7 @@ enum TimespanUnit { } impl TimespanUnit { - fn as_str(self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { TimespanUnit::Seconds => "seconds", TimespanUnit::Minutes => "minutes", @@ -87,13 +72,13 @@ impl TimespanUnit { } #[derive(Clone, Copy)] -struct Timespan { +pub(crate) struct Timespan { seconds: f32, unit: TimespanUnit, } impl Timespan { - fn from_secs(seconds: f32) -> Self { + pub fn from_secs(seconds: f32) -> Self { Timespan { seconds, unit: TimespanUnit::Seconds, @@ -102,7 +87,7 @@ impl Timespan { /// Return the value as the configured unit, eg seconds=70/unit=Minutes /// returns 1.17 - fn as_unit(self) -> f32 { + pub fn as_unit(self) -> f32 { let s = self.seconds; match self.unit { TimespanUnit::Seconds => s, @@ -116,7 +101,7 @@ impl Timespan { /// Round seconds and days to integers, otherwise /// truncates to one decimal place. - fn as_rounded_unit(self) -> f32 { + pub fn as_rounded_unit(self) -> f32 { match self.unit { // seconds/days as integer TimespanUnit::Seconds | TimespanUnit::Days => self.as_unit().round(), @@ -125,13 +110,13 @@ impl Timespan { } } - fn unit(self) -> TimespanUnit { + pub fn unit(self) -> TimespanUnit { self.unit } /// Return a new timespan in the most appropriate unit, eg /// 70 secs -> timespan in minutes - fn natural_span(self) -> Timespan { + pub fn natural_span(self) -> Timespan { let secs = self.seconds.abs(); let unit = if secs < MINUTE { TimespanUnit::Seconds @@ -158,7 +143,7 @@ impl Timespan { mod test { use crate::i18n::I18n; use crate::log; - use crate::sched::timespan::{answer_button_time, studied_today, time_span, MONTH}; + use crate::sched::timespan::{answer_button_time, time_span, MONTH}; #[test] fn answer_buttons() { @@ -180,15 +165,4 @@ mod test { assert_eq!(time_span(45.0 * 86_400.0, &i18n, false), "1.5 months"); assert_eq!(time_span(365.0 * 86_400.0 * 1.5, &i18n, false), "1.5 years"); } - - #[test] - fn combo() { - // temporary test of fluent term handling - let log = log::terminal(); - let i18n = I18n::new(&["zz"], "", log); - assert_eq!( - &studied_today(3, 13.0, &i18n).replace("\n", " "), - "Studied 3 cards in 13 seconds today (4.33s/card)" - ); - } } diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index c5a07bb12..863dafd61 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -229,12 +229,14 @@ fn revlog_to_text(e: RevlogEntry, i18n: &I18n, offset: FixedOffset) -> RevlogTex RevlogReviewKind::Review => i18n.tr(TR::CardStatsReviewLogTypeReview).into(), RevlogReviewKind::Relearning => i18n.tr(TR::CardStatsReviewLogTypeRelearn).into(), RevlogReviewKind::EarlyReview => i18n.tr(TR::CardStatsReviewLogTypeFiltered).into(), + RevlogReviewKind::Manual => i18n.tr(TR::CardStatsReviewLogTypeManual).into(), }; let kind_class = match e.review_kind { RevlogReviewKind::Learning => String::from("revlog-learn"), RevlogReviewKind::Review => String::from("revlog-review"), RevlogReviewKind::Relearning => String::from("revlog-relearn"), RevlogReviewKind::EarlyReview => String::from("revlog-filtered"), + RevlogReviewKind::Manual => String::from("revlog-manual"), }; let rating = e.button_chosen.to_string(); let interval = if e.interval == 0 { diff --git a/rslib/src/stats/mod.rs b/rslib/src/stats/mod.rs index 33b8dd19b..b855d8ce5 100644 --- a/rslib/src/stats/mod.rs +++ b/rslib/src/stats/mod.rs @@ -3,3 +3,6 @@ mod card; mod graphs; +mod today; + +pub use today::studied_today; diff --git a/rslib/src/stats/today.rs b/rslib/src/stats/today.rs new file mode 100644 index 000000000..27b1a0b4e --- /dev/null +++ b/rslib/src/stats/today.rs @@ -0,0 +1,45 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{i18n::I18n, prelude::*, sched::timespan::Timespan}; + +pub fn studied_today(cards: u32, secs: f32, i18n: &I18n) -> String { + let span = Timespan::from_secs(secs).natural_span(); + let amount = span.as_unit(); + let unit = span.unit().as_str(); + let secs_per = if cards > 0 { + secs / (cards as f32) + } else { + 0.0 + }; + let args = tr_args!["amount" => amount, "unit" => unit, + "cards" => cards, "secs-per-card" => secs_per]; + i18n.trn(TR::StatisticsStudiedToday, args) +} + +impl Collection { + pub fn studied_today(&self) -> Result { + let today = self + .storage + .studied_today(self.timing_today()?.next_day_at)?; + Ok(studied_today(today.cards, today.seconds as f32, &self.i18n)) + } +} + +#[cfg(test)] +mod test { + use super::studied_today; + use crate::i18n::I18n; + use crate::log; + + #[test] + fn today() { + // temporary test of fluent term handling + let log = log::terminal(); + let i18n = I18n::new(&["zz"], "", log); + assert_eq!( + &studied_today(3, 13.0, &i18n).replace("\n", " "), + "Studied 3 cards in 13 seconds today (4.33s/card)" + ); + } +} diff --git a/rslib/src/storage/revlog/add.sql b/rslib/src/storage/revlog/add.sql index a3155a942..b63301200 100644 --- a/rslib/src/storage/revlog/add.sql +++ b/rslib/src/storage/revlog/add.sql @@ -10,5 +10,25 @@ insert time, type ) -values - (?, ?, ?, ?, ?, ?, ?, ?, ?) \ No newline at end of file +values ( + ( + case + when ?1 in ( + select id + from revlog + ) then ( + select max(id) + 1 + from revlog + ) + else ?1 + end + ), + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ? + ) \ No newline at end of file diff --git a/rslib/src/storage/revlog/mod.rs b/rslib/src/storage/revlog/mod.rs index fd9434ea2..991f16929 100644 --- a/rslib/src/storage/revlog/mod.rs +++ b/rslib/src/storage/revlog/mod.rs @@ -15,6 +15,11 @@ use rusqlite::{ }; use std::convert::TryFrom; +pub(crate) struct StudiedToday { + pub cards: u32, + pub seconds: f64, +} + impl FromSql for RevlogReviewKind { fn column_result(value: ValueRef<'_>) -> std::result::Result { if let ValueRef::Integer(i) = value { @@ -113,4 +118,19 @@ impl SqliteStorage { })? .collect() } + + pub(crate) fn studied_today(&self, day_cutoff: i64) -> Result { + let start = (day_cutoff - 86_400) * 1_000; + self.db + .prepare_cached(include_str!("studied_today.sql"))? + .query_map(&[start, RevlogReviewKind::Manual as i64], |row| { + Ok(StudiedToday { + cards: row.get(0)?, + seconds: row.get(1)?, + }) + })? + .next() + .unwrap() + .map_err(Into::into) + } } diff --git a/rslib/src/storage/revlog/studied_today.sql b/rslib/src/storage/revlog/studied_today.sql new file mode 100644 index 000000000..73f8cfba3 --- /dev/null +++ b/rslib/src/storage/revlog/studied_today.sql @@ -0,0 +1,5 @@ +select count(), + coalesce(sum(time) / 1000.0, 0.0) +from revlog +where id > ? + and type != ? \ No newline at end of file diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index 1645ac17d..0189c718a 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -15,6 +15,10 @@ impl TimestampSecs { Self(elapsed().as_secs() as i64) } + pub fn zero() -> Self { + Self(0) + } + pub fn elapsed_secs(self) -> u64 { (Self::now().0 - self.0).max(0) as u64 } @@ -30,6 +34,10 @@ impl TimestampMillis { Self(elapsed().as_millis() as i64) } + pub fn zero() -> Self { + Self(0) + } + pub fn as_secs(self) -> TimestampSecs { TimestampSecs(self.0 / 1000) } diff --git a/ts/src/stats/reviews.ts b/ts/src/stats/reviews.ts index b6a41d9df..a33b878a8 100644 --- a/ts/src/stats/reviews.ts +++ b/ts/src/stats/reviews.ts @@ -47,6 +47,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut): GraphData { const empty = { mature: 0, young: 0, learn: 0, relearn: 0, early: 0 }; for (const review of data.revlog as pb.BackendProto.RevlogEntry[]) { + if (review.reviewKind == ReviewKind.MANUAL) { + // don't count days with only manual scheduling + continue; + } const day = Math.ceil( ((review.id as number) / 1000 - data.nextDayAtSecs) / 86400 ); diff --git a/ts/src/stats/today.ts b/ts/src/stats/today.ts index 1c861d4c8..db77073bc 100644 --- a/ts/src/stats/today.ts +++ b/ts/src/stats/today.ts @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import pb from "../backend/proto"; +import pb, { BackendProto } from "../backend/proto"; import { studiedToday } from "../time"; import { I18n } from "../i18n"; @@ -30,6 +30,10 @@ export function gatherData(data: pb.BackendProto.GraphsOut, i18n: I18n): TodayDa continue; } + if (review.reviewKind == ReviewKind.MANUAL) { + continue; + } + // total answerCount += 1; answerMillis += review.takenMillis;