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.
This commit is contained in:
Damien Elmes 2020-06-15 14:14:18 +10:00
parent 772c7a945c
commit b51f03085e
21 changed files with 510 additions and 142 deletions

View file

@ -97,6 +97,10 @@ service BackendService {
rpc ExtendLimits (ExtendLimitsIn) returns (Empty); rpc ExtendLimits (ExtendLimitsIn) returns (Empty);
rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut); rpc CountsForDeckToday (DeckID) returns (CountsForDeckTodayOut);
// stats
rpc CardStats (CardID) returns (String);
// media // media
rpc CheckMedia (Empty) returns (CheckMediaOut); rpc CheckMedia (Empty) returns (CheckMediaOut);

View file

@ -489,16 +489,35 @@ class Collection:
# Stats # Stats
########################################################################## ##########################################################################
def cardStats(self, card: Card) -> str:
from anki.stats import CardStats
return CardStats(self, card).report()
def stats(self) -> "anki.stats.CollectionStats": def stats(self) -> "anki.stats.CollectionStats":
from anki.stats import CollectionStats from anki.stats import CollectionStats
return CollectionStats(self) 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"""<style>
.revlog-learn {{ color: {st.colLearn} }}
.revlog-review {{ color: {st.colMature} }}
.revlog-relearn {{ color: {st.colRelearn} }}
.revlog-filtered {{ color: {st.colCram} }}
.revlog-ease1 {{ color: {st.colRelearn} }}
table.review-log {{ {revlog_style} }}
</style>"""
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 # Timeboxing
########################################################################## ##########################################################################

View file

@ -18,59 +18,22 @@ from anki.utils import ids2str
########################################################################## ##########################################################################
PERIOD_MONTH = 0
PERIOD_YEAR = 1
PERIOD_LIFE = 2
class CardStats: 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: def __init__(self, col: anki.collection.Collection, card: anki.cards.Card) -> None:
if col: if col:
self.col = col.weakref() self.col = col.weakref()
self.card = card self.card = card
self.txt = "" self.txt = ""
def report(self) -> str: def report(self, include_revlog: bool = False) -> str:
c = self.card return self.col.card_stats(self.card.id, include_revlog=include_revlog)
self.txt = "<table width=100%>"
self.addLine(_("Added"), self.date(c.id / 1000)) # legacy
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 += "</table>"
return self.txt
def addLine(self, k: str, v: Union[int, str]) -> None: def addLine(self, k: str, v: Union[int, str]) -> None:
self.txt += self.makeLine(k, v) self.txt += self.makeLine(k, v)
@ -90,6 +53,10 @@ class CardStats:
# Collection stats # Collection stats
########################################################################## ##########################################################################
PERIOD_MONTH = 0
PERIOD_YEAR = 1
PERIOD_LIFE = 2
colYoung = "#7c7" colYoung = "#7c7"
colMature = "#070" colMature = "#070"
colCum = "rgba(0,0,0,0.9)" colCum = "rgba(0,0,0,0.9)"

View file

@ -9,7 +9,7 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from operator import itemgetter from operator import itemgetter
from typing import Callable, List, Optional, Sequence, Union from typing import Callable, List, Optional, Sequence, Tuple, Union
import anki import anki
import aqt import aqt
@ -21,6 +21,7 @@ from anki.lang import _, ngettext
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note from anki.notes import Note
from anki.rsbackend import TR, DeckTreeNode, InvalidInput from anki.rsbackend import TR, DeckTreeNode, InvalidInput
from anki.stats import CardStats
from anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin from anki.utils import htmlToTextLine, ids2str, intTime, isMac, isWin
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.editor import Editor from aqt.editor import Editor
@ -1414,6 +1415,7 @@ QTableView {{ gridline-color: {grid} }}
def showCardInfo(self): def showCardInfo(self):
if not self.card: if not self.card:
return return
info, cs = self._cardInfoData() info, cs = self._cardInfoData()
reps = self._revlogData(cs) reps = self._revlogData(cs)
@ -1432,79 +1434,16 @@ QTableView {{ gridline-color: {grid} }}
restoreGeom(card_info_dialog, "revlog") restoreGeom(card_info_dialog, "revlog")
card_info_dialog.show() card_info_dialog.show()
def _cardInfoData(self): def _cardInfoData(self) -> Tuple[str, CardStats]:
from anki.stats import CardStats
cs = CardStats(self.col, self.card) cs = CardStats(self.col, self.card)
rep = cs.report() rep = cs.report(include_revlog=True)
m = self.card.model()
rep = (
"""
<div style='width: 400px; margin: 0 auto 0;
border: 1px solid #000; padding: 3px; '>%s</div>"""
% rep
)
return rep, cs return rep, cs
def _revlogData(self, cs): # legacy - revlog used to be generated here, and some add-ons
entries = self.mw.col.db.all( # wrapped this function
"select id/1000.0, ease, ivl, factor, time/1000.0, type "
"from revlog where cid = ?", def _revlogData(self, cs: CardStats) -> str:
self.card.id,
)
if not entries:
return "" return ""
s = "<table width=100%%><tr><th align=left>%s</th>" % _("Date")
s += "<th align=right>%s</th>" % _("Type")
s += "<th align=center>%s</th>" % _("Rating")
s += "<th align=left>%s</th>" % _("Interval")
s += ("<th align=right>%s</th>" * 2) % (_("Ease"), _("Time"),)
cnt = 0
for (date, ease, ivl, factor, taken, type) in reversed(entries):
cnt += 1
s += "<tr><td>%s</td>" % time.strftime(
_("<b>%Y-%m-%d</b> @ %H:%M"), time.localtime(date)
)
tstr = [_("Learn"), _("Review"), _("Relearn"), _("Filtered"), _("Resched")][
type
]
import anki.stats as st
fmt = "<span style='color:%s'>%s</span>"
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 += "<td align=right>%s</td>" % tstr
s += "<td align=center>%s</td>" % ease
s += "<td align=left>%s</td>" % ivl
s += ("<td align=right>%s</td>" * 2) % (
"%d%%" % (factor / 10) if factor else "",
self.col.format_timespan(taken),
) + "</tr>"
s += "</table>"
if cnt < self.card.reps:
s += _(
"""\
Note: Some of the history is missing. For more information, \
please see the browser documentation."""
)
return s
# Menu helpers # Menu helpers
###################################################################### ######################################################################

View file

@ -49,6 +49,7 @@ itertools = "0.9.0"
flate2 = "1.0.14" flate2 = "1.0.14"
pin-project = "0.4.17" pin-project = "0.4.17"
async-compression = { version = "0.3.4", features = ["stream", "gzip"] } async-compression = { version = "0.3.4", features = ["stream", "gzip"] }
askama = "0.9.0"
[target.'cfg(target_vendor="apple")'.dependencies.rusqlite] [target.'cfg(target_vendor="apple")'.dependencies.rusqlite]
version = "0.23.1" version = "0.23.1"

24
rslib/ftl/card-stats.ftl Normal file
View file

@ -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

View file

@ -72,3 +72,6 @@ statistics-studied-today =
*[years] { statistics-in-time-span-years } *[years] { statistics-in-time-span-years }
} today } today
({$secs-per-card}s/card) ({$secs-per-card}s/card)
# eg, "Time taken to review card: 5s"
statistics-seconds-taken = { $seconds }s

View file

@ -504,6 +504,14 @@ impl BackendService for Backend {
self.with_col(|col| col.counts_for_deck_today(input.did.into())) self.with_col(|col| col.counts_for_deck_today(input.did.into()))
} }
// statistics
//-----------------------------------------------
fn card_stats(&mut self, input: pb::CardId) -> BackendResult<pb::String> {
self.with_col(|col| col.card_stats(input.into()))
.map(Into::into)
}
// decks // decks
//----------------------------------------------- //-----------------------------------------------

View file

@ -15,6 +15,12 @@ use std::collections::HashSet;
define_newtype!(CardID, i64); 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)] #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, TryFromPrimitive, Clone, Copy)]
#[repr(u8)] #[repr(u8)]
pub enum CardType { pub enum CardType {

View file

@ -26,6 +26,7 @@ pub mod revlog;
pub mod sched; pub mod sched;
pub mod search; pub mod search;
pub mod serde; pub mod serde;
mod stats;
pub mod storage; pub mod storage;
mod sync; mod sync;
pub mod tags; pub mod tags;

View file

@ -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) { pub(crate) fn set_modified(&mut self, usn: Usn) {
self.mtime_secs = TimestampSecs::now(); self.mtime_secs = TimestampSecs::now();
self.usn = usn; self.usn = usn;

View file

@ -2,11 +2,12 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
pub use crate::{ pub use crate::{
card::CardID, card::{Card, CardID},
collection::Collection, collection::Collection,
deckconf::DeckConfID, deckconf::DeckConfID,
decks::DeckID, decks::DeckID,
err::{AnkiError, Result}, err::{AnkiError, Result},
i18n::{tr_args, tr_strs, TR},
notes::NoteID, notes::NoteID,
notetype::NoteTypeID, notetype::NoteTypeID,
revlog::RevlogID, revlog::RevlogID,

View file

@ -67,7 +67,7 @@ fn days_elapsed(
} }
/// Build a FixedOffset struct, capping minutes_west if out of bounds. /// 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); let bounded_minutes = minutes_west.max(-23 * 60).min(23 * 60);
FixedOffset::west(bounded_minutes * 60) FixedOffset::west(bounded_minutes * 60)
} }

View file

@ -8,13 +8,14 @@ use crate::{
pub mod cutoff; pub mod cutoff;
pub mod timespan; pub mod timespan;
use chrono::FixedOffset;
use cutoff::{ use cutoff::{
sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, fixed_offset_from_minutes, local_minutes_west_for_stamp, sched_timing_today,
SchedTimingToday, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, SchedTimingToday,
}; };
impl Collection { impl Collection {
pub fn timing_today(&mut self) -> Result<SchedTimingToday> { pub fn timing_today(&self) -> Result<SchedTimingToday> {
self.timing_for_timestamp(TimestampSecs::now()) 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) Ok(((self.timing_today()?.days_elapsed as i32) + delta).max(0) as u32)
} }
pub(crate) fn timing_for_timestamp(&mut self, now: TimestampSecs) -> Result<SchedTimingToday> { pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result<SchedTimingToday> {
let local_offset = if self.server { let local_offset = if self.server {
self.get_local_mins_west() self.get_local_mins_west()
} else { } 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<u8> { pub fn rollover_for_current_scheduler(&self) -> Result<u8> {
match self.sched_ver() { match self.sched_ver() {
SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp( SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp(

312
rslib/src/stats/card.rs Normal file
View file

@ -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<TimestampSecs>,
latest_review: Option<TimestampSecs>,
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<BasicRevlog>,
}
#[derive(Template)]
#[template(path = "../src/stats/card_stats.html")]
struct CardStatsTemplate {
stats: Vec<(String, String)>,
revlog: Vec<RevlogText>,
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<ReviewLogEntry> 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<String> {
let stats = self.gather_card_stats(cid)?;
Ok(self.card_stats_to_string(stats))
}
fn gather_card_stats(&mut self, cid: CardID) -> Result<CardStats> {
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("<b>%Y-%m-%d</b> @ %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(())
}
}

View file

@ -0,0 +1,34 @@
<table class="card-stats" width="100%">
{% for row in stats %}
<tr>
<td align="left" style="padding-right: 3px;">
<b>{{ row.0 }}</b>
</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% if !revlog.is_empty() %}
<table class="review-log" width="100%">
<tr>
<th>{{ revlog_titles.time }}</th>
<th align="right">{{ revlog_titles.kind }}</th>
<th align="center">{{ revlog_titles.rating }}</th>
<th>{{ revlog_titles.interval }}</th>
<th align="right">{{ revlog_titles.ease }}</th>
<th align="right">{{ revlog_titles.taken_secs }}</th>
</tr>
{% for entry in revlog %}
<tr>
<td>{{ entry.time|safe }}</td>
<td align="right" class="{{ entry.kind_class }}">{{ entry.kind }}</td>
<td align="center" class="{{ entry.rating_class }}">{{ entry.rating }}</td>
<td>{{ entry.interval }}</td>
<td align="right">{{ entry.ease }}</td>
<td align="right">{{ entry.taken_secs }}</td>
</tr>
{% endfor %}
</table>
{% endif %}

4
rslib/src/stats/mod.rs Normal file
View file

@ -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;

View file

@ -4,7 +4,21 @@
use super::SqliteStorage; use super::SqliteStorage;
use crate::prelude::*; use crate::prelude::*;
use crate::{err::Result, sync::ReviewLogEntry}; 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<ReviewLogEntry> {
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 { impl SqliteStorage {
pub(crate) fn fix_revlog_properties(&self) -> Result<usize> { pub(crate) fn fix_revlog_properties(&self) -> Result<usize> {
@ -41,20 +55,15 @@ impl SqliteStorage {
pub(crate) fn get_revlog_entry(&self, id: RevlogID) -> Result<Option<ReviewLogEntry>> { pub(crate) fn get_revlog_entry(&self, id: RevlogID) -> Result<Option<ReviewLogEntry>> {
self.db self.db
.prepare_cached(concat!(include_str!("get.sql"), " where id=?"))? .prepare_cached(concat!(include_str!("get.sql"), " where id=?"))?
.query_and_then(&[id], |row| { .query_and_then(&[id], row_to_revlog_entry)?
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)?,
})
})?
.next() .next()
.transpose() .transpose()
} }
pub(crate) fn get_revlog_entries_for_card(&self, cid: CardID) -> Result<Vec<ReviewLogEntry>> {
self.db
.prepare_cached(concat!(include_str!("get.sql"), " where cid=?"))?
.query_and_then(&[cid], row_to_revlog_entry)?
.collect()
}
} }

View file

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::define_newtype; use crate::define_newtype;
use chrono::prelude::*;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::env; use std::env;
use std::time; use std::time;
@ -17,12 +18,21 @@ impl TimestampSecs {
pub fn elapsed_secs(self) -> u64 { pub fn elapsed_secs(self) -> u64 {
(Self::now().0 - self.0).max(0) as 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 { impl TimestampMillis {
pub fn now() -> Self { pub fn now() -> Self {
Self(elapsed().as_millis() as i64) Self(elapsed().as_millis() as i64)
} }
pub fn as_secs(self) -> TimestampSecs {
TimestampSecs(self.0 / 1000)
}
} }
lazy_static! { lazy_static! {
@ -33,7 +43,6 @@ fn elapsed() -> time::Duration {
if *TESTING { if *TESTING {
// shift clock around rollover time to accomodate Python tests that make bad assumptions. // 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. // we should update the tests in the future and remove this hack.
use chrono::{Local, Timelike};
let mut elap = time::SystemTime::now() let mut elap = time::SystemTime::now()
.duration_since(time::SystemTime::UNIX_EPOCH) .duration_since(time::SystemTime::UNIX_EPOCH)
.unwrap(); .unwrap();

0
rslib/templates/.empty Normal file
View file

View file

@ -117,6 +117,7 @@ fn want_release_gil(method: u32) -> bool {
BackendMethod::UpdateStats => true, BackendMethod::UpdateStats => true,
BackendMethod::ExtendLimits => true, BackendMethod::ExtendLimits => true,
BackendMethod::CountsForDeckToday => true, BackendMethod::CountsForDeckToday => true,
BackendMethod::CardStats => true,
} }
} else { } else {
false false