// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use std::collections::HashMap; use std::sync::LazyLock; use rand::distr::Distribution; use rand::distr::Uniform; use regex::Regex; use super::answering::CardAnswer; use crate::card::Card; use crate::card::CardId; use crate::card::CardQueue; use crate::card::CardType; use crate::collection::Collection; use crate::config::StringKey; use crate::error::Result; use crate::prelude::*; use crate::scheduler::timing::is_unix_epoch_timestamp; impl Card { /// Make card due in `days_from_today`. /// If card is not a review card, convert it into one. /// Review/relearning cards have their interval preserved unless /// `force_reset` is true. /// If the card has no ease factor (it's new), `ease_factor` is used. fn set_due_date( &mut self, today: u32, next_day_start: i64, days_from_today: u32, ease_factor: f32, force_reset: bool, ) { let new_due = (today + days_from_today) as i32; let fsrs_enabled = self.memory_state.is_some(); let new_interval = if fsrs_enabled { 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 { 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 { self.interval.max(1) }; let ease_factor = (ease_factor * 1000.0).round() as u16; self.schedule_as_review(new_interval, new_due, ease_factor); } fn schedule_as_review(&mut self, interval: u32, due: i32, ease_factor: u16) { self.original_position = self.last_position(); self.remove_from_filtered_deck_before_reschedule(); self.interval = interval; self.due = due; self.ctype = CardType::Review; self.queue = CardQueue::Review; if self.ease_factor == 0 { // unlike the old Python code, we leave the ease factor alone // if it's already set self.ease_factor = ease_factor; } } } #[derive(Debug, PartialEq, Eq)] pub struct DueDateSpecifier { min: u32, max: u32, force_reset: bool, } pub fn parse_due_date_str(s: &str) -> Result { static RE: LazyLock = LazyLock::new(|| { Regex::new( r"(?x)^ # a number (?P\d+) # an optional hyphen and another number (?: - (?P\d+) )? # optional exclamation mark (?P!)? $ ", ) .unwrap() }); let caps = RE.captures(s).or_invalid(s)?; let min: u32 = caps.name("min").unwrap().as_str().parse()?; let max = if let Some(max) = caps.name("max") { max.as_str().parse()? } else { min }; let force_reset = caps.name("bang").is_some(); Ok(DueDateSpecifier { min: min.min(max), max: max.max(min), force_reset, }) } impl Collection { /// `days` should be in a format parseable by `parse_due_date_str`. /// If `context` is provided, provided key will be updated with the new /// value of `days`. pub fn set_due_date( &mut self, cids: &[CardId], days: &str, context: Option, ) -> Result> { let spec = parse_due_date_str(days)?; let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; let next_day_start = self.timing_today()?.next_day_at.0; let mut rng = rand::rng(); let distribution = Uniform::new_inclusive(spec.min, spec.max).unwrap(); let mut decks_initial_ease: HashMap = HashMap::new(); self.transact(Op::SetDueDate, |col| { for mut card in col.all_cards_for_ids(cids, false)? { let deck_id = card.original_deck_id.or(card.deck_id); let ease_factor = match decks_initial_ease.get(&deck_id) { Some(ease) => *ease, None => { let deck = col.get_deck(deck_id)?.or_not_found(deck_id)?; let config_id = deck.config_id().or_invalid("home deck is filtered")?; let ease = col .get_deck_config(config_id, true)? // just for compiler; get_deck_config() is guaranteed to return a value .unwrap_or_default() .inner .initial_ease; decks_initial_ease.insert(deck_id, ease); ease } }; let original = card.clone(); let days_from_today = distribution.sample(&mut rng); card.set_due_date( today, next_day_start, days_from_today, ease_factor, spec.force_reset, ); col.log_manually_scheduled_review(&card, original.interval, usn)?; col.update_card_inner(&mut card, original, usn)?; } if let Some(key) = context { col.set_config_string_inner(key, days)?; } Ok(()) }) } pub fn grade_now(&mut self, cids: &[CardId], rating: i32) -> Result> { self.transact(Op::GradeNow, |col| { for &card_id in cids { let states = col.get_scheduling_states(card_id)?; let new_state = match rating { 0 => states.again, 1 => states.hard, 2 => states.good, 3 => states.easy, _ => invalid_input!("invalid rating"), }; let mut answer: CardAnswer = anki_proto::scheduler::CardAnswer { card_id: card_id.into(), current_state: Some(states.current.into()), new_state: Some(new_state.into()), rating, milliseconds_taken: 0, answered_at_millis: TimestampMillis::now().into(), } .into(); // Process the card without updating queues yet answer.from_queue = false; col.answer_card_inner(&mut answer)?; } Ok(()) }) } } #[cfg(test)] mod test { use super::*; use crate::prelude::*; #[test] fn parse() -> Result<()> { type S = DueDateSpecifier; assert!(parse_due_date_str("").is_err()); assert!(parse_due_date_str("x").is_err()); assert!(parse_due_date_str("-5").is_err()); assert_eq!( parse_due_date_str("5")?, S { min: 5, max: 5, force_reset: false } ); assert_eq!( parse_due_date_str("5!")?, S { min: 5, max: 5, force_reset: true } ); assert_eq!( parse_due_date_str("50-70")?, S { min: 50, max: 70, force_reset: false } ); assert_eq!( parse_due_date_str("70-50!")?, S { min: 50, max: 70, force_reset: true } ); Ok(()) } #[test] fn due_date() { let mut c = Card::new(NoteId(0), 0, DeckId(0), 0); // setting the due date of a new card will convert it c.set_due_date(5, 0, 2, 1.8, false); assert_eq!(c.ctype, CardType::Review); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // reschedule it again the next day, shifting it from day 7 to day 9 c.set_due_date(6, 0, 3, 2.5, false); assert_eq!(c.due, 9); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // interval doesn't change // we can bring cards forward too - return it to its original due date c.set_due_date(6, 0, 1, 2.4, false); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 1800); // interval doesn't change // we can force the interval to be reset instead of shifted c.set_due_date(6, 0, 3, 2.3, true); assert_eq!(c.due, 9); assert_eq!(c.interval, 3); assert_eq!(c.ease_factor, 1800); // interval doesn't change // should work in a filtered deck c.interval = 2; c.ease_factor = 0; c.original_due = 7; c.original_deck_id = DeckId(1); c.due = -10000; c.queue = CardQueue::New; c.set_due_date(6, 0, 1, 2.2, false); assert_eq!(c.due, 7); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 2200); assert_eq!(c.queue, CardQueue::Review); assert_eq!(c.original_due, 0); assert_eq!(c.original_deck_id, DeckId(0)); // relearning treated like review c.ctype = CardType::Relearn; c.original_due = c.due; c.due = 12345678; c.set_due_date(6, 0, 10, 2.1, false); assert_eq!(c.due, 16); assert_eq!(c.interval, 2); assert_eq!(c.ease_factor, 2200); // interval doesn't change } }