From b51f03085e892c03e6f757a43f89e4fc55e0e7dd Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 15 Jun 2020 14:14:18 +1000 Subject: [PATCH] migrate card stats to backend Currently this renders the HTML directly like the previous Python implementation - doing it in JS would probably make more sense in the future. --- proto/backend.proto | 4 + pylib/anki/collection.py | 29 ++- pylib/anki/stats.py | 59 ++---- qt/aqt/browser.py | 79 +------- rslib/Cargo.toml | 1 + rslib/ftl/card-stats.ftl | 24 +++ rslib/ftl/statistics.ftl | 3 + rslib/src/backend/mod.rs | 8 + rslib/src/card.rs | 6 + rslib/src/lib.rs | 1 + rslib/src/notetype/mod.rs | 12 ++ rslib/src/prelude.rs | 3 +- rslib/src/sched/cutoff.rs | 2 +- rslib/src/sched/mod.rs | 22 ++- rslib/src/stats/card.rs | 312 ++++++++++++++++++++++++++++++++ rslib/src/stats/card_stats.html | 34 ++++ rslib/src/stats/mod.rs | 4 + rslib/src/storage/revlog/mod.rs | 37 ++-- rslib/src/timestamp.rs | 11 +- rslib/templates/.empty | 0 rspy/src/lib.rs | 1 + 21 files changed, 510 insertions(+), 142 deletions(-) create mode 100644 rslib/ftl/card-stats.ftl create mode 100644 rslib/src/stats/card.rs create mode 100644 rslib/src/stats/card_stats.html create mode 100644 rslib/src/stats/mod.rs create mode 100644 rslib/templates/.empty diff --git a/proto/backend.proto b/proto/backend.proto index 318132622..b8962c6cf 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -97,6 +97,10 @@ service BackendService { rpc ExtendLimits (ExtendLimitsIn) returns (Empty); rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut); + // stats + + rpc CardStats (CardID) returns (String); + // media rpc CheckMedia (Empty) returns (CheckMediaOut); diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 825b05eba..1f8a55a21 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -489,16 +489,35 @@ class Collection: # Stats ########################################################################## - def cardStats(self, card: Card) -> str: - from anki.stats import CardStats - - return CardStats(self, card).report() - def stats(self) -> "anki.stats.CollectionStats": from anki.stats import CollectionStats return CollectionStats(self) + def card_stats(self, card_id: int, include_revlog: bool) -> str: + import anki.stats as st + + if include_revlog: + revlog_style = "margin-top: 2em;" + else: + revlog_style = "display: none;" + + style = f"""""" + + return style + self.backend.card_stats(card_id) + + # legacy + + def cardStats(self, card: Card) -> str: + return self.card_stats(card.id, include_revlog=False) + # Timeboxing ########################################################################## diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py index 1748bc303..16c424950 100644 --- a/pylib/anki/stats.py +++ b/pylib/anki/stats.py @@ -18,59 +18,22 @@ from anki.utils import ids2str ########################################################################## -PERIOD_MONTH = 0 -PERIOD_YEAR = 1 -PERIOD_LIFE = 2 - - class CardStats: + """ + New code should just call collection.card_stats() directly - this class + is only left around for backwards compatibility. + """ + def __init__(self, col: anki.collection.Collection, card: anki.cards.Card) -> None: if col: self.col = col.weakref() self.card = card self.txt = "" - def report(self) -> str: - c = self.card - self.txt = "" - self.addLine(_("Added"), self.date(c.id / 1000)) - first = self.col.db.scalar("select min(id) from revlog where cid = ?", c.id) - last = self.col.db.scalar("select max(id) from revlog where cid = ?", c.id) - if first: - self.addLine(_("First Review"), self.date(first / 1000)) - self.addLine(_("Latest Review"), self.date(last / 1000)) - if c.type in (CARD_TYPE_LRN, CARD_TYPE_REV): - next: Optional[str] = None - if c.odid or c.queue < QUEUE_TYPE_NEW: - pass - else: - if c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN): - n = time.time() + ((c.due - self.col.sched.today) * 86400) - else: - n = c.due - next = self.date(n) - if next: - self.addLine(self.col.tr(TR.STATISTICS_DUE_DATE), next) - if c.queue == QUEUE_TYPE_REV: - self.addLine(_("Interval"), self.col.format_timespan(c.ivl * 86400)) - self.addLine(_("Ease"), "%d%%" % (c.factor / 10.0)) - self.addLine(_("Reviews"), "%d" % c.reps) - self.addLine(_("Lapses"), "%d" % c.lapses) - (cnt, total) = self.col.db.first( - "select count(), sum(time)/1000 from revlog where cid = ?", c.id - ) - if cnt: - self.addLine(_("Average Time"), self.time(total / float(cnt))) - self.addLine(_("Total Time"), self.time(total)) - elif c.queue == QUEUE_TYPE_NEW: - self.addLine(_("Position"), c.due) - self.addLine(_("Card Type"), c.template()["name"]) - self.addLine(_("Note Type"), c.model()["name"]) - self.addLine(_("Deck"), self.col.decks.name(c.did)) - self.addLine(_("Note ID"), c.nid) - self.addLine(_("Card ID"), c.id) - self.txt += "
" - return self.txt + def report(self, include_revlog: bool = False) -> str: + return self.col.card_stats(self.card.id, include_revlog=include_revlog) + + # legacy def addLine(self, k: str, v: Union[int, str]) -> None: self.txt += self.makeLine(k, v) @@ -90,6 +53,10 @@ class CardStats: # Collection stats ########################################################################## +PERIOD_MONTH = 0 +PERIOD_YEAR = 1 +PERIOD_LIFE = 2 + colYoung = "#7c7" colMature = "#070" colCum = "rgba(0,0,0,0.9)" diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 81115b400..1e1faf377 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -9,7 +9,7 @@ import time from dataclasses import dataclass from enum import Enum from operator import itemgetter -from typing import Callable, List, Optional, Sequence, Union +from typing import Callable, List, Optional, Sequence, Tuple, Union import anki import aqt @@ -21,6 +21,7 @@ from anki.lang import _, ngettext from anki.models import NoteType from anki.notes import Note from anki.rsbackend import TR, DeckTreeNode, InvalidInput +from anki.stats import CardStats from anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin from aqt import AnkiQt, gui_hooks from aqt.editor import Editor @@ -1414,6 +1415,7 @@ QTableView {{ gridline-color: {grid} }} def showCardInfo(self): if not self.card: return + info, cs = self._cardInfoData() reps = self._revlogData(cs) @@ -1432,79 +1434,16 @@ QTableView {{ gridline-color: {grid} }} restoreGeom(card_info_dialog, "revlog") card_info_dialog.show() - def _cardInfoData(self): - from anki.stats import CardStats - + def _cardInfoData(self) -> Tuple[str, CardStats]: cs = CardStats(self.col, self.card) - rep = cs.report() - m = self.card.model() - rep = ( - """ -
%s
""" - % rep - ) + rep = cs.report(include_revlog=True) return rep, cs - def _revlogData(self, cs): - entries = self.mw.col.db.all( - "select id/1000.0, ease, ivl, factor, time/1000.0, type " - "from revlog where cid = ?", - self.card.id, - ) - if not entries: - return "" - s = "" % _("Date") - s += "" % _("Type") - s += "" % _("Rating") - s += "" % _("Interval") - s += ("" * 2) % (_("Ease"), _("Time"),) - cnt = 0 - for (date, ease, ivl, factor, taken, type) in reversed(entries): - cnt += 1 - s += "" % time.strftime( - _("%Y-%m-%d @ %H:%M"), time.localtime(date) - ) - tstr = [_("Learn"), _("Review"), _("Relearn"), _("Filtered"), _("Resched")][ - type - ] - import anki.stats as st + # legacy - revlog used to be generated here, and some add-ons + # wrapped this function - fmt = "%s" - if type == CARD_TYPE_NEW: - tstr = fmt % (st.colLearn, tstr) - elif type == CARD_TYPE_LRN: - tstr = fmt % (st.colMature, tstr) - elif type == 2: - tstr = fmt % (st.colRelearn, tstr) - elif type == 3: - tstr = fmt % (st.colCram, tstr) - else: - tstr = fmt % ("#000", tstr) - if ease == 1: - ease = fmt % (st.colRelearn, ease) - if ivl == 0: - ivl = "" - else: - if ivl > 0: - ivl *= 86_400 - ivl = cs.time(abs(ivl)) - s += "" % tstr - s += "" % ease - s += "" % ivl - - s += ("" * 2) % ( - "%d%%" % (factor / 10) if factor else "", - self.col.format_timespan(taken), - ) + "" - s += "
%s%s%s%s%s
%s%s%s%s%s
" - if cnt < self.card.reps: - s += _( - """\ -Note: Some of the history is missing. For more information, \ -please see the browser documentation.""" - ) - return s + def _revlogData(self, cs: CardStats) -> str: + return "" # Menu helpers ###################################################################### diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 6e94bf8ca..4ddfe9c1e 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -49,6 +49,7 @@ itertools = "0.9.0" flate2 = "1.0.14" pin-project = "0.4.17" async-compression = { version = "0.3.4", features = ["stream", "gzip"] } +askama = "0.9.0" [target.'cfg(target_vendor="apple")'.dependencies.rusqlite] version = "0.23.1" diff --git a/rslib/ftl/card-stats.ftl b/rslib/ftl/card-stats.ftl new file mode 100644 index 000000000..f8d661226 --- /dev/null +++ b/rslib/ftl/card-stats.ftl @@ -0,0 +1,24 @@ +card-stats-added = Added +card-stats-first-review = First Review +card-stats-latest-review = Latest Review +card-stats-interval = Interval +card-stats-ease = Ease +card-stats-review-count = Reviews +card-stats-lapse-count = Lapses +card-stats-average-time = Average Time +card-stats-total-time = Total Time +card-stats-new-card-position = Position +card-stats-card-template = Card Type +card-stats-note-type = Note Type +card-stats-deck-name = Deck +card-stats-note-id = Note ID +card-stats-card-id = Card ID +card-stats-review-log-rating = Rating +card-stats-review-log-type = Type +card-stats-review-log-date = Date +card-stats-review-log-time-taken = Time +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-rescheduled = Resched diff --git a/rslib/ftl/statistics.ftl b/rslib/ftl/statistics.ftl index 110591a53..a680613b2 100644 --- a/rslib/ftl/statistics.ftl +++ b/rslib/ftl/statistics.ftl @@ -72,3 +72,6 @@ statistics-studied-today = *[years] { statistics-in-time-span-years } } today ({$secs-per-card}s/card) + +# eg, "Time taken to review card: 5s" +statistics-seconds-taken = { $seconds }s diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 5cd7f4312..4a0a1e12e 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -504,6 +504,14 @@ impl BackendService for Backend { self.with_col(|col| col.counts_for_deck_today(input.did.into())) } + // statistics + //----------------------------------------------- + + fn card_stats(&mut self, input: pb::CardId) -> BackendResult { + self.with_col(|col| col.card_stats(input.into())) + .map(Into::into) + } + // decks //----------------------------------------------- diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 60994210b..98180066e 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -15,6 +15,12 @@ use std::collections::HashSet; define_newtype!(CardID, i64); +impl CardID { + pub fn as_secs(self) -> TimestampSecs { + TimestampSecs(self.0 / 1000) + } +} + #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, TryFromPrimitive, Clone, Copy)] #[repr(u8)] pub enum CardType { diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index 8a9d4a319..574569f58 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -26,6 +26,7 @@ pub mod revlog; pub mod sched; pub mod search; pub mod serde; +mod stats; pub mod storage; mod sync; pub mod tags; diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index ad76b55ae..234c20869 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -100,6 +100,18 @@ impl NoteType { } } + /// Return the template for the given card ordinal. Cloze notetypes + /// always return the first and only template. + pub fn get_template(&self, card_ord: u16) -> Result<&CardTemplate> { + let template = if self.config.kind() == NoteTypeKind::Cloze { + self.templates.get(0) + } else { + self.templates.get(card_ord as usize) + }; + + template.ok_or(AnkiError::NotFound) + } + pub(crate) fn set_modified(&mut self, usn: Usn) { self.mtime_secs = TimestampSecs::now(); self.usn = usn; diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs index 2cc165a95..e9ce12ea8 100644 --- a/rslib/src/prelude.rs +++ b/rslib/src/prelude.rs @@ -2,11 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html pub use crate::{ - card::CardID, + card::{Card, CardID}, collection::Collection, deckconf::DeckConfID, decks::DeckID, err::{AnkiError, Result}, + i18n::{tr_args, tr_strs, TR}, notes::NoteID, notetype::NoteTypeID, revlog::RevlogID, diff --git a/rslib/src/sched/cutoff.rs b/rslib/src/sched/cutoff.rs index ed588c9f1..8483eabb1 100644 --- a/rslib/src/sched/cutoff.rs +++ b/rslib/src/sched/cutoff.rs @@ -67,7 +67,7 @@ fn days_elapsed( } /// Build a FixedOffset struct, capping minutes_west if out of bounds. -fn fixed_offset_from_minutes(minutes_west: i32) -> FixedOffset { +pub(crate) fn fixed_offset_from_minutes(minutes_west: i32) -> FixedOffset { let bounded_minutes = minutes_west.max(-23 * 60).min(23 * 60); FixedOffset::west(bounded_minutes * 60) } diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs index 034734a46..96aa6ab50 100644 --- a/rslib/src/sched/mod.rs +++ b/rslib/src/sched/mod.rs @@ -8,13 +8,14 @@ use crate::{ pub mod cutoff; pub mod timespan; +use chrono::FixedOffset; use cutoff::{ - sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, - SchedTimingToday, + fixed_offset_from_minutes, local_minutes_west_for_stamp, sched_timing_today, + v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, SchedTimingToday, }; impl Collection { - pub fn timing_today(&mut self) -> Result { + pub fn timing_today(&self) -> Result { self.timing_for_timestamp(TimestampSecs::now()) } @@ -22,7 +23,7 @@ impl Collection { Ok(((self.timing_today()?.days_elapsed as i32) + delta).max(0) as u32) } - pub(crate) fn timing_for_timestamp(&mut self, now: TimestampSecs) -> Result { + pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result { let local_offset = if self.server { self.get_local_mins_west() } else { @@ -38,6 +39,19 @@ impl Collection { )) } + /// Get the local timezone. + /// We could use this to simplify timing_for_timestamp() in the future + pub(crate) fn local_offset(&self) -> FixedOffset { + let local_mins_west = if self.server { + self.get_local_mins_west() + } else { + None + }; + let local_mins_west = + local_mins_west.unwrap_or_else(|| local_minutes_west_for_stamp(TimestampSecs::now().0)); + fixed_offset_from_minutes(local_mins_west) + } + pub fn rollover_for_current_scheduler(&self) -> Result { match self.sched_ver() { SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp( diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs new file mode 100644 index 000000000..1adb5952d --- /dev/null +++ b/rslib/src/stats/card.rs @@ -0,0 +1,312 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + card::CardQueue, i18n::I18n, prelude::*, sched::timespan::time_span, sync::ReviewLogEntry, +}; +use askama::Template; +use chrono::prelude::*; + +struct CardStats { + added: TimestampSecs, + first_review: Option, + latest_review: Option, + due: Due, + interval_secs: u32, + ease: u32, + reviews: u32, + lapses: u32, + average_secs: f32, + total_secs: f32, + card_type: String, + note_type: String, + deck: String, + nid: NoteID, + cid: CardID, + revlog: Vec, +} + +#[derive(Template)] +#[template(path = "../src/stats/card_stats.html")] +struct CardStatsTemplate { + stats: Vec<(String, String)>, + revlog: Vec, + revlog_titles: RevlogText, +} + +enum Due { + Time(TimestampSecs), + Position(i32), + Unknown, +} + +struct BasicRevlog { + time: TimestampSecs, + kind: u8, + rating: u8, + interval: i32, + ease: u32, + taken_secs: f32, +} + +struct RevlogText { + time: String, + kind: String, + kind_class: String, + rating: String, + rating_class: String, + interval: String, + ease: String, + taken_secs: String, +} + +impl From for BasicRevlog { + fn from(e: ReviewLogEntry) -> Self { + BasicRevlog { + time: e.id.as_secs(), + kind: e.kind, + rating: e.ease, + interval: e.interval, + ease: e.factor, + taken_secs: (e.time as f32) / 1000.0, + } + } +} + +impl Collection { + pub fn card_stats(&mut self, cid: CardID) -> Result { + let stats = self.gather_card_stats(cid)?; + Ok(self.card_stats_to_string(stats)) + } + + fn gather_card_stats(&mut self, cid: CardID) -> Result { + let card = self.storage.get_card(cid)?.ok_or(AnkiError::NotFound)?; + let note = self + .storage + .get_note(card.nid)? + .ok_or(AnkiError::NotFound)?; + let nt = self.get_notetype(note.ntid)?.ok_or(AnkiError::NotFound)?; + let deck = self + .storage + .get_deck(card.did)? + .ok_or(AnkiError::NotFound)?; + + let revlog = self.storage.get_revlog_entries_for_card(card.id)?; + let average_secs; + let total_secs; + if revlog.is_empty() { + average_secs = 0.0; + total_secs = 0.0; + } else { + total_secs = revlog.iter().map(|e| (e.time as f32) / 1000.0).sum(); + average_secs = total_secs / (revlog.len() as f32); + } + + let due = match card.queue { + CardQueue::New => Due::Position(card.due), + CardQueue::Learn => Due::Time(TimestampSecs::now()), + CardQueue::Review | CardQueue::DayLearn => Due::Time({ + let days_remaining = card.due - (self.timing_today()?.days_elapsed as i32); + let mut due = TimestampSecs::now(); + due.0 += (days_remaining as i64) * 86_400; + due + }), + _ => Due::Unknown, + }; + + Ok(CardStats { + added: card.id.as_secs(), + first_review: revlog.first().map(|e| e.id.as_secs()), + latest_review: revlog.last().map(|e| e.id.as_secs()), + due, + interval_secs: card.ivl * 86_400, + ease: (card.factor as u32) / 10, + reviews: card.reps, + lapses: card.lapses, + average_secs, + total_secs, + card_type: nt.get_template(card.ord)?.name.clone(), + note_type: nt.name.clone(), + deck: deck.human_name(), + nid: card.nid, + cid: card.id, + revlog: revlog.into_iter().map(Into::into).collect(), + }) + } + + fn card_stats_to_string(&self, cs: CardStats) -> String { + let offset = self.local_offset(); + let i18n = &self.i18n; + + let mut stats = vec![( + i18n.tr(TR::CardStatsAdded).to_string(), + cs.added.date_string(offset), + )]; + if let Some(first) = cs.first_review { + stats.push(( + i18n.tr(TR::CardStatsFirstReview).into(), + first.date_string(offset), + )) + } + if let Some(last) = cs.latest_review { + stats.push(( + i18n.tr(TR::CardStatsLatestReview).into(), + last.date_string(offset), + )) + } + + match cs.due { + Due::Time(secs) => { + stats.push(( + i18n.tr(TR::StatisticsDueDate).into(), + secs.date_string(offset), + )); + } + Due::Position(pos) => { + stats.push(( + i18n.tr(TR::CardStatsNewCardPosition).into(), + pos.to_string(), + )); + } + Due::Unknown => {} + }; + + if cs.interval_secs > 0 { + stats.push(( + i18n.tr(TR::CardStatsInterval).into(), + time_span(cs.interval_secs as f32, i18n, true), + )); + } + + if cs.ease > 0 { + stats.push((i18n.tr(TR::CardStatsEase).into(), format!("{}%", cs.ease))); + } + stats.push(( + i18n.tr(TR::CardStatsReviewCount).into(), + cs.reviews.to_string(), + )); + stats.push(( + i18n.tr(TR::CardStatsLapseCount).into(), + cs.lapses.to_string(), + )); + + if cs.total_secs > 0.0 { + stats.push(( + i18n.tr(TR::CardStatsAverageTime).into(), + time_span(cs.average_secs, i18n, true), + )); + stats.push(( + i18n.tr(TR::CardStatsTotalTime).into(), + time_span(cs.total_secs, i18n, true), + )); + } + + stats.push((i18n.tr(TR::CardStatsCardTemplate).into(), cs.card_type)); + stats.push((i18n.tr(TR::CardStatsNoteType).into(), cs.note_type)); + stats.push((i18n.tr(TR::CardStatsDeckName).into(), cs.deck)); + stats.push((i18n.tr(TR::CardStatsNoteId).into(), cs.cid.0.to_string())); + stats.push((i18n.tr(TR::CardStatsCardId).into(), cs.nid.0.to_string())); + + let revlog = cs + .revlog + .into_iter() + .map(|e| revlog_to_text(e, i18n, offset)) + .collect(); + let revlog_titles = RevlogText { + time: i18n.tr(TR::CardStatsReviewLogDate).into(), + kind: i18n.tr(TR::CardStatsReviewLogType).into(), + kind_class: "".to_string(), + rating: i18n.tr(TR::CardStatsReviewLogRating).into(), + interval: i18n.tr(TR::CardStatsInterval).into(), + ease: i18n.tr(TR::CardStatsEase).into(), + rating_class: "".to_string(), + taken_secs: i18n.tr(TR::CardStatsReviewLogTimeTaken).into(), + }; + + CardStatsTemplate { + stats, + revlog, + revlog_titles, + } + .render() + .unwrap() + } +} + +fn revlog_to_text(e: BasicRevlog, i18n: &I18n, offset: FixedOffset) -> RevlogText { + let dt = offset.timestamp(e.time.0, 0); + let time = dt.format("%Y-%m-%d @ %H:%M").to_string(); + let kind = match e.kind { + 0 => i18n.tr(TR::CardStatsReviewLogTypeLearn).into(), + 1 => i18n.tr(TR::CardStatsReviewLogTypeReview).into(), + 2 => i18n.tr(TR::CardStatsReviewLogTypeRelearn).into(), + 3 => i18n.tr(TR::CardStatsReviewLogTypeFiltered).into(), + 4 => i18n.tr(TR::CardStatsReviewLogTypeRescheduled).into(), + _ => String::from("?"), + }; + let kind_class = match e.kind { + 0 => String::from("revlog-learn"), + 1 => String::from("revlog-review"), + 2 => String::from("revlog-relearn"), + 3 => String::from("revlog-filtered"), + 4 => String::from("revlog-rescheduled"), + _ => String::from(""), + }; + let rating = e.rating.to_string(); + let interval = if e.interval == 0 { + String::from("") + } else { + let interval_secs = if e.interval > 0 { + e.interval * 86_400 + } else { + e.interval.abs() + }; + time_span(interval_secs as f32, i18n, true) + }; + let ease = if e.ease > 0 { + format!("{:.0}%", (e.ease as f32) / 10.0) + } else { + "".to_string() + }; + let rating_class = if e.rating == 1 { + String::from("revlog-ease1") + } else { + "".to_string() + }; + let taken_secs = i18n.trn( + TR::StatisticsSecondsTaken, + tr_args!["seconds"=>e.taken_secs as i32], + ); + + RevlogText { + time, + kind, + kind_class, + rating, + rating_class, + interval, + ease, + taken_secs, + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{collection::open_test_collection, search::SortMode}; + + #[test] + fn stats() -> Result<()> { + let mut col = open_test_collection(); + + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, DeckID(1))?; + + let cid = col.search_cards("", SortMode::NoOrder)?[0]; + let _report = col.card_stats(cid)?; + //println!("report {}", report); + + Ok(()) + } +} diff --git a/rslib/src/stats/card_stats.html b/rslib/src/stats/card_stats.html new file mode 100644 index 000000000..0c97e95c6 --- /dev/null +++ b/rslib/src/stats/card_stats.html @@ -0,0 +1,34 @@ + + {% for row in stats %} + + + + + {% endfor %} +
+ {{ row.0 }} + {{ row.1 }}
+ +{% if !revlog.is_empty() %} + + + + + + + + + + + {% for entry in revlog %} + + + + + + + + + {% endfor %} +
{{ revlog_titles.time }}{{ revlog_titles.kind }}{{ revlog_titles.rating }}{{ revlog_titles.interval }}{{ revlog_titles.ease }}{{ revlog_titles.taken_secs }}
{{ entry.time|safe }}{{ entry.kind }}{{ entry.rating }}{{ entry.interval }}{{ entry.ease }}{{ entry.taken_secs }}
+{% endif %} diff --git a/rslib/src/stats/mod.rs b/rslib/src/stats/mod.rs new file mode 100644 index 000000000..904a8e4ed --- /dev/null +++ b/rslib/src/stats/mod.rs @@ -0,0 +1,4 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod card; diff --git a/rslib/src/storage/revlog/mod.rs b/rslib/src/storage/revlog/mod.rs index e3712a7b4..e3a1b5174 100644 --- a/rslib/src/storage/revlog/mod.rs +++ b/rslib/src/storage/revlog/mod.rs @@ -4,7 +4,21 @@ use super::SqliteStorage; use crate::prelude::*; use crate::{err::Result, sync::ReviewLogEntry}; -use rusqlite::{params, NO_PARAMS}; +use rusqlite::{params, Row, NO_PARAMS}; + +fn row_to_revlog_entry(row: &Row) -> Result { + Ok(ReviewLogEntry { + id: row.get(0)?, + cid: row.get(1)?, + usn: row.get(2)?, + ease: row.get(3)?, + interval: row.get(4)?, + last_interval: row.get(5)?, + factor: row.get(6)?, + time: row.get(7)?, + kind: row.get(8)?, + }) +} impl SqliteStorage { pub(crate) fn fix_revlog_properties(&self) -> Result { @@ -41,20 +55,15 @@ impl SqliteStorage { pub(crate) fn get_revlog_entry(&self, id: RevlogID) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id=?"))? - .query_and_then(&[id], |row| { - Ok(ReviewLogEntry { - id: row.get(0)?, - cid: row.get(1)?, - usn: row.get(2)?, - ease: row.get(3)?, - interval: row.get(4)?, - last_interval: row.get(5)?, - factor: row.get(6)?, - time: row.get(7)?, - kind: row.get(8)?, - }) - })? + .query_and_then(&[id], row_to_revlog_entry)? .next() .transpose() } + + pub(crate) fn get_revlog_entries_for_card(&self, cid: CardID) -> Result> { + self.db + .prepare_cached(concat!(include_str!("get.sql"), " where cid=?"))? + .query_and_then(&[cid], row_to_revlog_entry)? + .collect() + } } diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index d4a9b23d6..1645ac17d 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::define_newtype; +use chrono::prelude::*; use lazy_static::lazy_static; use std::env; use std::time; @@ -17,12 +18,21 @@ impl TimestampSecs { pub fn elapsed_secs(self) -> u64 { (Self::now().0 - self.0).max(0) as u64 } + + /// YYYY-mm-dd + pub(crate) fn date_string(self, offset: FixedOffset) -> String { + offset.timestamp(self.0, 0).format("%Y-%m-%d").to_string() + } } impl TimestampMillis { pub fn now() -> Self { Self(elapsed().as_millis() as i64) } + + pub fn as_secs(self) -> TimestampSecs { + TimestampSecs(self.0 / 1000) + } } lazy_static! { @@ -33,7 +43,6 @@ fn elapsed() -> time::Duration { if *TESTING { // shift clock around rollover time to accomodate Python tests that make bad assumptions. // we should update the tests in the future and remove this hack. - use chrono::{Local, Timelike}; let mut elap = time::SystemTime::now() .duration_since(time::SystemTime::UNIX_EPOCH) .unwrap(); diff --git a/rslib/templates/.empty b/rslib/templates/.empty new file mode 100644 index 000000000..e69de29bb diff --git a/rspy/src/lib.rs b/rspy/src/lib.rs index 11b53978f..33607efd1 100644 --- a/rspy/src/lib.rs +++ b/rspy/src/lib.rs @@ -117,6 +117,7 @@ fn want_release_gil(method: u32) -> bool { BackendMethod::UpdateStats => true, BackendMethod::ExtendLimits => true, BackendMethod::CountsForDeckToday => true, + BackendMethod::CardStats => true, } } else { false