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;