diff --git a/rslib/ftl/card-stats.ftl b/rslib/ftl/card-stats.ftl index f8d661226..63d78df88 100644 --- a/rslib/ftl/card-stats.ftl +++ b/rslib/ftl/card-stats.ftl @@ -21,4 +21,3 @@ 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/src/revlog.rs b/rslib/src/revlog.rs index 58ac235a4..37d78767c 100644 --- a/rslib/src/revlog.rs +++ b/rslib/src/revlog.rs @@ -1,6 +1,60 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::define_newtype; +use crate::{define_newtype, prelude::*}; +use num_enum::TryFromPrimitive; +use serde::Deserialize; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_tuple::Serialize_tuple; define_newtype!(RevlogID, i64); + +#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq)] +pub struct RevlogEntry { + pub id: TimestampMillis, + pub cid: CardID, + pub usn: Usn, + /// - In the V1 scheduler, 3 represents easy in the learning case. + /// - 0 represents manual rescheduling. + #[serde(rename = "ease")] + pub button_chosen: u8, + /// Positive values are in days, negative values in seconds. + #[serde(rename = "ivl")] + pub interval: i32, + /// Positive values are in days, negative values in seconds. + #[serde(rename = "lastIvl")] + pub last_interval: i32, + /// Card's ease after answering, stored as 10x the %, eg 2500 represents 250%. + #[serde(rename = "factor")] + pub ease_factor: u32, + /// Amount of milliseconds taken to answer the card. + #[serde(rename = "time")] + pub taken_millis: u32, + #[serde(rename = "type")] + pub review_kind: RevlogReviewKind, +} + +#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, TryFromPrimitive, Clone, Copy)] +#[repr(u8)] +pub enum RevlogReviewKind { + Learning = 0, + Review = 1, + Relearning = 2, + EarlyReview = 3, +} + +impl Default for RevlogReviewKind { + fn default() -> Self { + RevlogReviewKind::Learning + } +} + +impl RevlogEntry { + pub(crate) fn interval_secs(&self) -> u32 { + (if self.interval > 0 { + self.interval * 86_400 + } else { + -self.interval + }) as u32 + } +} diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index 1adb5952d..f71a27566 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -2,7 +2,11 @@ // 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, + card::CardQueue, + i18n::I18n, + prelude::*, + revlog::{RevlogEntry, RevlogReviewKind}, + sched::timespan::time_span, }; use askama::Template; use chrono::prelude::*; @@ -23,7 +27,7 @@ struct CardStats { deck: String, nid: NoteID, cid: CardID, - revlog: Vec, + revlog: Vec, } #[derive(Template)] @@ -40,15 +44,6 @@ enum Due { Unknown, } -struct BasicRevlog { - time: TimestampSecs, - kind: u8, - rating: u8, - interval: i32, - ease: u32, - taken_secs: f32, -} - struct RevlogText { time: String, kind: String, @@ -60,19 +55,6 @@ struct RevlogText { 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)?; @@ -98,7 +80,10 @@ impl Collection { average_secs = 0.0; total_secs = 0.0; } else { - total_secs = revlog.iter().map(|e| (e.time as f32) / 1000.0).sum(); + total_secs = revlog + .iter() + .map(|e| (e.taken_millis as f32) / 1000.0) + .sum(); average_secs = total_secs / (revlog.len() as f32); } @@ -233,49 +218,41 @@ impl Collection { } } -fn revlog_to_text(e: BasicRevlog, i18n: &I18n, offset: FixedOffset) -> RevlogText { - let dt = offset.timestamp(e.time.0, 0); +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.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 = 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.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 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.rating.to_string(); + let rating = e.button_chosen.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() - }; + let interval_secs = e.interval_secs(); time_span(interval_secs as f32, i18n, true) }; - let ease = if e.ease > 0 { - format!("{:.0}%", (e.ease as f32) / 10.0) + let ease = if e.ease_factor > 0 { + format!("{}%", e.ease_factor / 10) } else { "".to_string() }; - let rating_class = if e.rating == 1 { + 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_secs as i32], + tr_args!["seconds"=>(e.taken_millis / 1000) as i32], ); RevlogText { diff --git a/rslib/src/storage/revlog/mod.rs b/rslib/src/storage/revlog/mod.rs index e3a1b5174..43d7f1776 100644 --- a/rslib/src/storage/revlog/mod.rs +++ b/rslib/src/storage/revlog/mod.rs @@ -2,21 +2,39 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::SqliteStorage; -use crate::prelude::*; -use crate::{err::Result, sync::ReviewLogEntry}; -use rusqlite::{params, Row, NO_PARAMS}; +use crate::err::Result; +use crate::{ + prelude::*, + revlog::{RevlogReviewKind, RevlogEntry}, +}; +use rusqlite::{ + params, + types::{FromSql, FromSqlError, ValueRef}, + Row, NO_PARAMS, +}; +use std::convert::TryFrom; -fn row_to_revlog_entry(row: &Row) -> Result { - Ok(ReviewLogEntry { +impl FromSql for RevlogReviewKind { + fn column_result(value: ValueRef<'_>) -> std::result::Result { + if let ValueRef::Integer(i) = value { + Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?) + } else { + Err(FromSqlError::InvalidType) + } + } +} + +fn row_to_revlog_entry(row: &Row) -> Result { + Ok(RevlogEntry { id: row.get(0)?, cid: row.get(1)?, usn: row.get(2)?, - ease: row.get(3)?, + button_chosen: row.get(3)?, interval: row.get(4)?, last_interval: row.get(5)?, - factor: row.get(6)?, - time: row.get(7)?, - kind: row.get(8)?, + ease_factor: row.get(6)?, + taken_millis: row.get(7)?, + review_kind: row.get(8)?, }) } @@ -35,24 +53,24 @@ impl SqliteStorage { Ok(()) } - pub(crate) fn add_revlog_entry(&self, entry: &ReviewLogEntry) -> Result<()> { + pub(crate) fn add_revlog_entry(&self, entry: &RevlogEntry) -> Result<()> { self.db .prepare_cached(include_str!("add.sql"))? .execute(params![ entry.id, entry.cid, entry.usn, - entry.ease, + entry.button_chosen, entry.interval, entry.last_interval, - entry.factor, - entry.time, - entry.kind + entry.ease_factor, + entry.taken_millis, + entry.review_kind as u8 ])?; Ok(()) } - pub(crate) fn get_revlog_entry(&self, id: RevlogID) -> Result> { + pub(crate) fn get_revlog_entry(&self, id: RevlogID) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where id=?"))? .query_and_then(&[id], row_to_revlog_entry)? @@ -60,7 +78,7 @@ impl SqliteStorage { .transpose() } - pub(crate) fn get_revlog_entries_for_card(&self, cid: CardID) -> Result> { + pub(crate) fn get_revlog_entries_for_card(&self, cid: CardID) -> Result> { self.db .prepare_cached(concat!(include_str!("get.sql"), " where cid=?"))? .query_and_then(&[cid], row_to_revlog_entry)? diff --git a/rslib/src/sync/mod.rs b/rslib/src/sync/mod.rs index 64568e785..1aff2d954 100644 --- a/rslib/src/sync/mod.rs +++ b/rslib/src/sync/mod.rs @@ -12,6 +12,7 @@ use crate::{ notes::{guid, Note}, notetype::{NoteType, NoteTypeSchema11}, prelude::*, + revlog::RevlogEntry, serde::default_on_invalid, tags::{join_tags, split_tags}, version::sync_client_version, @@ -102,7 +103,7 @@ pub struct UnchunkedChanges { pub struct Chunk { done: bool, #[serde(skip_serializing_if = "Vec::is_empty", default)] - revlog: Vec, + revlog: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] cards: Vec, #[serde(skip_serializing_if = "Vec::is_empty", default)] @@ -115,22 +116,6 @@ struct ChunkableIDs { notes: Vec, } -#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq)] -pub struct ReviewLogEntry { - pub id: TimestampMillis, - pub cid: CardID, - pub usn: Usn, - pub ease: u8, - #[serde(rename = "ivl")] - pub interval: i32, - #[serde(rename = "lastIvl")] - pub last_interval: i32, - pub factor: u32, - pub time: u32, - #[serde(rename = "type")] - pub kind: u8, -} - #[derive(Serialize_tuple, Deserialize, Debug)] pub struct NoteEntry { pub id: NoteID, @@ -904,7 +889,7 @@ impl Collection { self.merge_notes(chunk.notes) } - fn merge_revlog(&self, entries: Vec) -> Result<()> { + fn merge_revlog(&self, entries: Vec) -> Result<()> { for entry in entries { self.storage.add_revlog_entry(&entry)?; } @@ -1275,7 +1260,7 @@ mod test { col1.add_note(&mut note, deck.id)?; // mock revlog entry - col1.storage.add_revlog_entry(&ReviewLogEntry { + col1.storage.add_revlog_entry(&RevlogEntry { id: TimestampMillis(123), cid: CardID(456), usn: Usn(-1),