From 91e729be1e2afd259a01703d1191f5bb7690a492 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Tue, 24 Jun 2025 17:49:25 +0800 Subject: [PATCH] Add `last_review_time` to card data --- proto/anki/cards.proto | 1 + rslib/src/browser_table.rs | 25 +++++++++++++++---------- rslib/src/card/mod.rs | 2 ++ rslib/src/card/service.rs | 2 ++ rslib/src/scheduler/answering/mod.rs | 14 +++++++++----- rslib/src/scheduler/reviews.rs | 22 ++++++++++++++-------- rslib/src/stats/card.rs | 13 ++++++++----- rslib/src/storage/card/data.rs | 8 ++++++++ rslib/src/storage/card/mod.rs | 1 + rslib/src/storage/sqlite.rs | 8 +++++++- rslib/src/sync/collection/chunks.rs | 1 + 11 files changed, 68 insertions(+), 29 deletions(-) diff --git a/proto/anki/cards.proto b/proto/anki/cards.proto index c120440e8..5c9838571 100644 --- a/proto/anki/cards.proto +++ b/proto/anki/cards.proto @@ -51,6 +51,7 @@ message Card { optional FsrsMemoryState memory_state = 20; optional float desired_retention = 21; optional float decay = 22; + optional int64 last_review_time_secs = 23; string custom_data = 19; } diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index 022708a80..e141c7f55 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -128,17 +128,22 @@ impl Card { /// This uses card.due and card.ivl to infer the elapsed time. If 'set due /// date' or an add-on has changed the due date, this won't be accurate. pub(crate) fn days_since_last_review(&self, timing: &SchedTimingToday) -> Option { - if !self.is_due_in_days() { - Some( - (timing.next_day_at.0 as u32).saturating_sub(self.original_or_current_due() as u32) - / 86_400, - ) + if let Some(last_review_time) = self.last_review_time { + Some(timing.next_day_at.elapsed_days_since(last_review_time) as u32) } else { - self.due_time(timing).map(|due| { - (due.adding_secs(-86_400 * self.interval as i64) - .elapsed_secs() - / 86_400) as u32 - }) + if !self.is_due_in_days() { + Some( + (timing.next_day_at.0 as u32) + .saturating_sub(self.original_or_current_due() as u32) + / 86_400, + ) + } else { + self.due_time(timing).map(|due| { + (due.adding_secs(-86_400 * self.interval as i64) + .elapsed_secs() + / 86_400) as u32 + }) + } } } } diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 49d952ecf..82203ace6 100644 --- a/rslib/src/card/mod.rs +++ b/rslib/src/card/mod.rs @@ -96,6 +96,7 @@ pub struct Card { pub(crate) memory_state: Option, pub(crate) desired_retention: Option, pub(crate) decay: Option, + pub(crate) last_review_time: Option, /// JSON object or empty; exposed through the reviewer for persisting custom /// state pub(crate) custom_data: String, @@ -147,6 +148,7 @@ impl Default for Card { memory_state: None, desired_retention: None, decay: None, + last_review_time: None, custom_data: String::new(), } } diff --git a/rslib/src/card/service.rs b/rslib/src/card/service.rs index 8f1421f25..cc3fc6b05 100644 --- a/rslib/src/card/service.rs +++ b/rslib/src/card/service.rs @@ -107,6 +107,7 @@ impl TryFrom for Card { memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, + last_review_time: c.last_review_time_secs.map(TimestampSecs), custom_data: c.custom_data, }) } @@ -136,6 +137,7 @@ impl From for anki_proto::cards::Card { memory_state: c.memory_state.map(Into::into), desired_retention: c.desired_retention, decay: c.decay, + last_review_time_secs: c.last_review_time.map(|t| t.0), custom_data: c.custom_data, } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index d498f7eaf..5b2f6720c 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -334,6 +334,7 @@ impl Collection { self.maybe_bury_siblings(&original, &updater.config)?; let timing = updater.timing; let mut card = updater.into_card(); + card.last_review_time = Some(answer.answered_at.as_secs()); if let Some(data) = answer.custom_data.take() { card.custom_data = data; card.validate_custom_data()?; @@ -448,11 +449,14 @@ impl Collection { )?; card.set_memory_state(&fsrs, item, config.inner.historical_retention)?; } - let days_elapsed = self - .storage - .time_of_last_review(card.id)? - .map(|ts| timing.next_day_at.elapsed_days_since(ts)) - .unwrap_or_default() as u32; + let days_elapsed = if let Some(last_review_time) = card.last_review_time { + timing.next_day_at.elapsed_days_since(last_review_time) as u32 + } else { + self.storage + .time_of_last_review(card.id)? + .map(|ts| timing.next_day_at.elapsed_days_since(ts)) + .unwrap_or_default() as u32 + }; Some(fsrs.next_states( card.memory_state.map(Into::into), config.inner.desired_retention, diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index 06390e57d..f8d433f42 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -36,15 +36,21 @@ impl Card { let new_due = (today + days_from_today) as i32; let fsrs_enabled = self.memory_state.is_some(); let new_interval = if fsrs_enabled { - let due = self.original_or_current_due(); - let due_diff = if is_unix_epoch_timestamp(due) { - let offset = (due as i64 - next_day_start) / 86_400; - let due = (today as i64 + offset) as i32; - new_due - due + if let Some(last_review_time) = self.last_review_time { + let elapsed_days = + TimestampSecs(next_day_start).elapsed_days_since(last_review_time); + elapsed_days as u32 + days_from_today } else { - new_due - due - }; - self.interval.saturating_add_signed(due_diff) + let due = self.original_or_current_due(); + let due_diff = if is_unix_epoch_timestamp(due) { + let offset = (due as i64 - next_day_start) / 86_400; + let due = (today as i64 + offset) as i32; + new_due - due + } else { + new_due - due + }; + self.interval.saturating_add_signed(due_diff) + } } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { days_from_today.max(1) } else { diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index b04539717..fdab209c8 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,11 +30,14 @@ impl Collection { let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let timing = self.timing_today()?; - let seconds_elapsed = self - .storage - .time_of_last_review(card.id)? - .map(|ts| timing.now.elapsed_secs_since(ts)) - .unwrap_or_default() as u32; + let seconds_elapsed = if let Some(last_review_time) = card.last_review_time { + timing.now.elapsed_secs_since(last_review_time) as u32 + } else { + self.storage + .time_of_last_review(card.id)? + .map(|ts| timing.now.elapsed_secs_since(ts)) + .unwrap_or_default() as u32 + }; let fsrs_retrievability = card .memory_state .zip(Some(seconds_elapsed)) diff --git a/rslib/src/storage/card/data.rs b/rslib/src/storage/card/data.rs index 6545a6c60..aeee0fbb5 100644 --- a/rslib/src/storage/card/data.rs +++ b/rslib/src/storage/card/data.rs @@ -47,6 +47,12 @@ pub(crate) struct CardData { deserialize_with = "default_on_invalid" )] pub(crate) decay: Option, + #[serde( + rename = "lrt", + skip_serializing_if = "Option::is_none", + deserialize_with = "default_on_invalid" + )] + pub(crate) last_review_time: Option, /// A string representation of a JSON object storing optional data /// associated with the card, so v3 custom scheduling code can persist @@ -63,6 +69,7 @@ impl CardData { fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty), fsrs_desired_retention: card.desired_retention, decay: card.decay, + last_review_time: card.last_review_time, custom_data: card.custom_data.clone(), } } @@ -169,6 +176,7 @@ mod test { fsrs_difficulty: Some(1.234567), fsrs_desired_retention: Some(0.987654), decay: Some(0.123456), + last_review_time: None, custom_data: "".to_string(), }; assert_eq!( diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 38cf5ef0f..e7da70897 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -97,6 +97,7 @@ fn row_to_card(row: &Row) -> result::Result { memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, + last_review_time: data.last_review_time, custom_data: data.custom_data, }) } diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index e31fdd46a..fa7be2eb5 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -314,7 +314,13 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> { let Ok(due) = ctx.get_raw(1).as_i64() else { return Ok(None); }; - let days_elapsed = if due > 365_000 { + let days_elapsed = if let Some(last_review_time) = card_data.last_review_time { + // Use last_review_time to calculate days_elapsed + let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { + return Ok(None); + }; + (next_day_at as u32).saturating_sub(last_review_time.0 as u32) / 86_400 + } else if due > 365_000 { // (re)learning card in seconds let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { return Ok(None); diff --git a/rslib/src/sync/collection/chunks.rs b/rslib/src/sync/collection/chunks.rs index 9d74ddb6c..7873c89c1 100644 --- a/rslib/src/sync/collection/chunks.rs +++ b/rslib/src/sync/collection/chunks.rs @@ -333,6 +333,7 @@ impl From for Card { memory_state: data.memory_state(), desired_retention: data.fsrs_desired_retention, decay: data.decay, + last_review_time: data.last_review_time, custom_data: data.custom_data, } }