Anki/rslib/src/stats/card.rs
2020-07-28 09:49:11 +10:00

289 lines
8.8 KiB
Rust

// 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::*,
revlog::{RevlogEntry, RevlogReviewKind},
sched::timespan::time_span,
};
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<RevlogEntry>,
}
#[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 RevlogText {
time: String,
kind: String,
kind_class: String,
rating: String,
rating_class: String,
interval: String,
ease: String,
taken_secs: String,
}
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.taken_millis 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::CardStatsCardId).into(), cs.cid.0.to_string()));
stats.push((i18n.tr(TR::CardStatsNoteId).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: RevlogEntry, i18n: &I18n, offset: FixedOffset) -> RevlogText {
let dt = offset.timestamp(e.id.as_secs().0, 0);
let time = dt.format("<b>%Y-%m-%d</b> @ %H:%M").to_string();
let kind = match e.review_kind {
RevlogReviewKind::Learning => i18n.tr(TR::CardStatsReviewLogTypeLearn).into(),
RevlogReviewKind::Review => i18n.tr(TR::CardStatsReviewLogTypeReview).into(),
RevlogReviewKind::Relearning => i18n.tr(TR::CardStatsReviewLogTypeRelearn).into(),
RevlogReviewKind::EarlyReview => i18n.tr(TR::CardStatsReviewLogTypeFiltered).into(),
};
let kind_class = match e.review_kind {
RevlogReviewKind::Learning => String::from("revlog-learn"),
RevlogReviewKind::Review => String::from("revlog-review"),
RevlogReviewKind::Relearning => String::from("revlog-relearn"),
RevlogReviewKind::EarlyReview => String::from("revlog-filtered"),
};
let rating = e.button_chosen.to_string();
let interval = if e.interval == 0 {
String::from("")
} else {
let interval_secs = e.interval_secs();
time_span(interval_secs as f32, i18n, true)
};
let ease = if e.ease_factor > 0 {
format!("{}%", e.ease_factor / 10)
} else {
"".to_string()
};
let rating_class = if e.button_chosen == 1 {
String::from("revlog-ease1")
} else {
"".to_string()
};
let taken_secs = i18n.trn(
TR::StatisticsSecondsTaken,
tr_args!["seconds"=>(e.taken_millis / 1000) 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(())
}
}