mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
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:
parent
772c7a945c
commit
b51f03085e
21 changed files with 510 additions and 142 deletions
|
@ -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);
|
||||
|
|
|
@ -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"""<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
|
||||
##########################################################################
|
||||
|
||||
|
|
|
@ -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 = "<table width=100%>"
|
||||
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 += "</table>"
|
||||
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)"
|
||||
|
|
|
@ -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 = (
|
||||
"""
|
||||
<div style='width: 400px; margin: 0 auto 0;
|
||||
border: 1px solid #000; padding: 3px; '>%s</div>"""
|
||||
% 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 = "<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
|
||||
# legacy - revlog used to be generated here, and some add-ons
|
||||
# wrapped this function
|
||||
|
||||
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
|
||||
def _revlogData(self, cs: CardStats) -> str:
|
||||
return ""
|
||||
|
||||
# Menu helpers
|
||||
######################################################################
|
||||
|
|
|
@ -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"
|
||||
|
|
24
rslib/ftl/card-stats.ftl
Normal file
24
rslib/ftl/card-stats.ftl
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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<pb::String> {
|
||||
self.with_col(|col| col.card_stats(input.into()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
// decks
|
||||
//-----------------------------------------------
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<SchedTimingToday> {
|
||||
pub fn timing_today(&self) -> Result<SchedTimingToday> {
|
||||
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<SchedTimingToday> {
|
||||
pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result<SchedTimingToday> {
|
||||
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<u8> {
|
||||
match self.sched_ver() {
|
||||
SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp(
|
||||
|
|
312
rslib/src/stats/card.rs
Normal file
312
rslib/src/stats/card.rs
Normal 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(())
|
||||
}
|
||||
}
|
34
rslib/src/stats/card_stats.html
Normal file
34
rslib/src/stats/card_stats.html
Normal 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
4
rslib/src/stats/mod.rs
Normal 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;
|
|
@ -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<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 {
|
||||
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>> {
|
||||
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<Vec<ReviewLogEntry>> {
|
||||
self.db
|
||||
.prepare_cached(concat!(include_str!("get.sql"), " where cid=?"))?
|
||||
.query_and_then(&[cid], row_to_revlog_entry)?
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
0
rslib/templates/.empty
Normal file
0
rslib/templates/.empty
Normal file
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue