// 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, 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 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 { 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.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("%Y-%m-%d @ %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(()) } }