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 = "%s | " % _("Date")
- s += "%s | " % _("Type")
- s += "%s | " % _("Rating")
- s += "%s | " % _("Interval")
- s += ("%s | " * 2) % (_("Ease"), _("Time"),)
- cnt = 0
- for (date, ease, ivl, factor, taken, type) in reversed(entries):
- cnt += 1
- s += "
---|
%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 += "%s | " % tstr
- s += "%s | " % ease
- s += "%s | " % ivl
-
- s += ("%s | " * 2) % (
- "%d%%" % (factor / 10) if factor else "",
- self.col.format_timespan(taken),
- ) + "
"
- 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 %}
+
+
+ {{ row.0 }}
+ |
+ {{ row.1 }} |
+
+ {% endfor %}
+
+
+{% if !revlog.is_empty() %}
+
+
+ {{ revlog_titles.time }} |
+ {{ revlog_titles.kind }} |
+ {{ revlog_titles.rating }} |
+ {{ revlog_titles.interval }} |
+ {{ revlog_titles.ease }} |
+ {{ revlog_titles.taken_secs }} |
+
+
+ {% for entry in revlog %}
+
+ {{ entry.time|safe }} |
+ {{ entry.kind }} |
+ {{ entry.rating }} |
+ {{ entry.interval }} |
+ {{ entry.ease }} |
+ {{ entry.taken_secs }} |
+
+ {% endfor %}
+
+{% 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