From 532a1866998eba238537f50e9aa3ac1a7fd2a005 Mon Sep 17 00:00:00 2001 From: Thomas Rixen Date: Mon, 4 Aug 2025 14:56:19 +0200 Subject: [PATCH 01/21] Statistics Reviews graph, make the color of New and Learning cards consistent with the color of card count --- ts/routes/graphs/reviews.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ts/routes/graphs/reviews.ts b/ts/routes/graphs/reviews.ts index fd3786b5f..41b500917 100644 --- a/ts/routes/graphs/reviews.ts +++ b/ts/routes/graphs/reviews.ts @@ -20,6 +20,7 @@ import { curveBasis, interpolateBlues, interpolateGreens, + interpolateOranges, interpolatePurples, interpolateReds, max, @@ -181,7 +182,7 @@ export function renderReviews( const reds = scaleSequential((n) => interpolateReds(cappedRange(n)!)).domain( x.domain() as any, ); - const blues = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain( + const oranges = scaleSequential((n) => interpolateOranges(cappedRange(n)!)).domain( x.domain() as any, ); const purples = scaleSequential((n) => interpolatePurples(cappedRange(n)!)).domain( @@ -195,7 +196,7 @@ export function renderReviews( case BinIndex.Young: return lighterGreens; case BinIndex.Learn: - return blues; + return oranges; case BinIndex.Relearn: return reds; case BinIndex.Filtered: From ad4cf33c0090c08f4aef39ddf5df99ba03d8df4c Mon Sep 17 00:00:00 2001 From: Thomas Rixen Date: Mon, 4 Aug 2025 15:09:45 +0200 Subject: [PATCH 02/21] removing bleu warning --- ts/routes/graphs/reviews.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ts/routes/graphs/reviews.ts b/ts/routes/graphs/reviews.ts index 41b500917..ed0e07f8c 100644 --- a/ts/routes/graphs/reviews.ts +++ b/ts/routes/graphs/reviews.ts @@ -18,7 +18,6 @@ import { bin, cumsum, curveBasis, - interpolateBlues, interpolateGreens, interpolateOranges, interpolatePurples, From e0233188460e3a2f05b6871bbc81edee73120987 Mon Sep 17 00:00:00 2001 From: Thomas Rixen Date: Mon, 4 Aug 2025 15:19:54 +0200 Subject: [PATCH 03/21] contributors --- CONTRIBUTORS | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index b5dfe1d53..3f6a37ba8 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -236,6 +236,7 @@ Marvin Kopf Kevin Nakamura Bradley Szoke jcznk +Thomas Rixen ******************** From bcc5519701bf0236e6f14f66cb074bed30a6b523 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Wed, 6 Aug 2025 08:55:50 +0100 Subject: [PATCH 04/21] Feat/Card stats update review time (#4236) * Feat/Card stats update review time * Update rslib/src/stats/card.rs Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * fix * self.storage.update_card --------- Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> --- rslib/src/stats/card.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index fdab209c8..ee8592d91 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,14 +30,24 @@ impl Collection { let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let timing = self.timing_today()?; - let seconds_elapsed = if let Some(last_review_time) = card.last_review_time { - timing.now.elapsed_secs_since(last_review_time) as u32 + + let last_review_time = if let Some(last_review_time) = card.last_review_time { + last_review_time } else { - self.storage + let mut new_card = card.clone(); + let last_review_time = self + .storage .time_of_last_review(card.id)? - .map(|ts| timing.now.elapsed_secs_since(ts)) - .unwrap_or_default() as u32 + .unwrap_or_default(); + + new_card.last_review_time = Some(last_review_time); + + self.storage.update_card(&new_card)?; + last_review_time }; + + let seconds_elapsed = timing.now.elapsed_secs_since(last_review_time) as u32; + let fsrs_retrievability = card .memory_state .zip(Some(seconds_elapsed)) From 1fabe6f618ae8dd9cdf4f3026be010c9bd145fd4 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Wed, 6 Aug 2025 09:01:06 +0100 Subject: [PATCH 05/21] Feat/expected_workload_with_existing_cards implementation (#4243) * https://github.com/open-spaced-repetition/fsrs-rs/pull/355 * add is_included card * bump version * ./check * update package.lock * parallellify * bump fsrs --- Cargo.lock | 4 ++-- Cargo.toml | 4 +--- cargo/licenses.json | 2 +- rslib/src/deckconfig/service.rs | 20 +++++++++++++++++++- rslib/src/scheduler/fsrs/simulator.rs | 17 +++++++++++------ 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9c09b75b..2d50d5388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,9 +2214,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "5.0.1" +version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df5aee516ebf9d4968364363b092371f988cd9fb628f7cae94ea422b6dd52f9c" +checksum = "04954cc67c3c11ee342a2ee1f5222bf76d73f7772df08d37dc9a6cdd73c467eb" dependencies = [ "burn", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 4a6634909..27d14ce8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,8 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "5.0.1" +version = "5.1.0" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" -# branch = "Refactor/expected_workload_via_dp" -# rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f" # path = "../open-spaced-repetition/fsrs-rs" [workspace.dependencies] diff --git a/cargo/licenses.json b/cargo/licenses.json index 7bd0d984a..92713c098 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1450,7 +1450,7 @@ }, { "name": "fsrs", - "version": "5.0.1", + "version": "5.1.0", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index 8cc33fc3a..11c4288d3 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use anki_proto::generic; +use rayon::iter::IntoParallelIterator; +use rayon::iter::ParallelIterator; use crate::collection::Collection; use crate::deckconfig::DeckConfSchema11; @@ -11,6 +13,7 @@ use crate::deckconfig::DeckConfigId; use crate::deckconfig::UpdateDeckConfigsRequest; use crate::error::Result; use crate::scheduler::fsrs::params::ignore_revlogs_before_date_to_ms; +use crate::scheduler::fsrs::simulator::is_included_card; impl crate::services::DeckConfigService for Collection { fn add_or_update_deck_config_legacy( @@ -103,6 +106,7 @@ impl crate::services::DeckConfigService for Collection { &mut self, input: anki_proto::deck_config::GetRetentionWorkloadRequest, ) -> Result { + let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let guard = self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; @@ -112,12 +116,26 @@ impl crate::services::DeckConfigService for Collection { .get_revlog_entries_for_searched_cards_in_card_order()?; let config = guard.col.get_optimal_retention_parameters(revlogs)?; + let cards = guard + .col + .storage + .all_searched_cards()? + .into_iter() + .filter(is_included_card) + .filter_map(|c| crate::card::Card::convert(c.clone(), days_elapsed, c.memory_state?)) + .collect::>(); let costs = (70u32..=99u32) + .into_par_iter() .map(|dr| { Ok(( dr, - fsrs::expected_workload(&input.w, dr as f32 / 100., &config)?, + fsrs::expected_workload_with_existing_cards( + &input.w, + dr as f32 / 100., + &config, + &cards, + )?, )) }) .collect::>>()?; diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 262768dee..a26afda9c 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -121,6 +121,12 @@ fn create_review_priority_fn( } } +pub(crate) fn is_included_card(c: &Card) -> bool { + c.queue != CardQueue::Suspended + && c.queue != CardQueue::PreviewRepeat + && c.ctype != CardType::New +} + impl Collection { pub fn simulate_request_to_config( &mut self, @@ -133,11 +139,6 @@ impl Collection { .get_revlog_entries_for_searched_cards_in_card_order()?; let mut cards = guard.col.storage.all_searched_cards()?; drop(guard); - fn is_included_card(c: &Card) -> bool { - c.queue != CardQueue::Suspended - && c.queue != CardQueue::PreviewRepeat - && c.ctype != CardType::New - } // calculate any missing memory state for c in &mut cards { if is_included_card(c) && c.memory_state.is_none() { @@ -306,7 +307,11 @@ impl Collection { } impl Card { - fn convert(card: Card, days_elapsed: i32, memory_state: FsrsMemoryState) -> Option { + pub(crate) fn convert( + card: Card, + days_elapsed: i32, + memory_state: FsrsMemoryState, + ) -> Option { match card.queue { CardQueue::DayLearn | CardQueue::Review => { let due = card.original_or_current_due(); From 47624f29801f9023ba04ac57b5e98d3801f43a2d Mon Sep 17 00:00:00 2001 From: llama Date: Wed, 6 Aug 2025 16:22:43 +0800 Subject: [PATCH 06/21] set min height for simulator graph (#4248) --- ts/routes/deck-options/SimulatorModal.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index 1ec477b13..5493b0093 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -646,7 +646,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html .svg-container { width: 100%; - max-height: calc(100vh - 400px); /* Account for modal header, controls, etc */ + /* Account for modal header, controls, etc */ + max-height: max(calc(100vh - 400px), 200px); aspect-ratio: 600 / 250; display: flex; align-items: center; From 78fe5d0521cb20930c91ec7d3c8c263cbb820ff4 Mon Sep 17 00:00:00 2001 From: llama Date: Wed, 6 Aug 2025 17:07:32 +0800 Subject: [PATCH 07/21] Fix show_exception's messagebox always formatting as plaintext (#4246) * fix show_exception's messagebox always formatting as plaintext * Revert "fix show_exception's messagebox always formatting as plaintext" This reverts commit aec6dd9be889c3f9635dc87efdb4d973535fca34. * convert SearchError msg to markdown when in browser --- pylib/anki/_backend.py | 2 +- qt/aqt/browser/browser.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pylib/anki/_backend.py b/pylib/anki/_backend.py index 03fbb30d6..9a68cf644 100644 --- a/pylib/anki/_backend.py +++ b/pylib/anki/_backend.py @@ -246,7 +246,7 @@ def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception: return BackendError(err.message, help_page, context, backtrace) elif val == kind.SEARCH_ERROR: - return SearchError(markdown(err.message), help_page, context, backtrace) + return SearchError(err.message, help_page, context, backtrace) elif val == kind.UNDO_EMPTY: return UndoEmpty(err.message, help_page, context, backtrace) diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 5d41f4ac9..e222f62c2 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -10,6 +10,8 @@ import re from collections.abc import Callable, Sequence from typing import Any, cast +from markdown import markdown + import aqt import aqt.browser import aqt.editor @@ -20,7 +22,7 @@ from anki.cards import Card, CardId from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.decks import DeckId -from anki.errors import NotFoundError +from anki.errors import NotFoundError, SearchError from anki.lang import without_unicode_isolation from anki.models import NotetypeId from anki.notes import NoteId @@ -498,6 +500,8 @@ class Browser(QMainWindow): text = self.current_search() try: normed = self.col.build_search_string(text) + except SearchError as err: + showWarning(markdown(str(err))) except Exception as err: showWarning(str(err)) else: From c916c39a8b22111afcdfdc053925f47f68805ad2 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:00:44 +0530 Subject: [PATCH 08/21] Limit time studied today to minutes (#4242) * Limit time studied today to minutes * Update timespan.rs * Update today.rs * Update timespan.rs * Update today.rs * Update today.rs * Update time.ts * Update time.ts * Update timespan.rs * Update timespan.rs * Update timespan.rs * Update today.rs --- rslib/src/scheduler/timespan.rs | 11 +++++++++-- rslib/src/stats/today.rs | 11 ++++++++--- ts/lib/tslib/time.ts | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/rslib/src/scheduler/timespan.rs b/rslib/src/scheduler/timespan.rs index b015e3e1e..9ae53d78c 100644 --- a/rslib/src/scheduler/timespan.rs +++ b/rslib/src/scheduler/timespan.rs @@ -57,10 +57,10 @@ const SECOND: f32 = 1.0; const MINUTE: f32 = 60.0 * SECOND; const HOUR: f32 = 60.0 * MINUTE; const DAY: f32 = 24.0 * HOUR; -const MONTH: f32 = 30.417 * DAY; // 365/12 ≈ 30.417 const YEAR: f32 = 365.0 * DAY; +const MONTH: f32 = YEAR / 12.0; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum TimespanUnit { Seconds, Minutes, @@ -111,6 +111,13 @@ impl Timespan { } } + pub fn to_unit(self, unit: TimespanUnit) -> Timespan { + Timespan { + seconds: self.seconds, + unit, + } + } + /// Round seconds and days to integers, otherwise /// truncates to one decimal place. pub fn as_rounded_unit(self) -> f32 { diff --git a/rslib/src/stats/today.rs b/rslib/src/stats/today.rs index f856ce271..d9680c282 100644 --- a/rslib/src/stats/today.rs +++ b/rslib/src/stats/today.rs @@ -5,17 +5,18 @@ use anki_i18n::I18n; use crate::prelude::*; use crate::scheduler::timespan::Timespan; +use crate::scheduler::timespan::TimespanUnit; pub fn studied_today(cards: u32, secs: f32, tr: &I18n) -> String { let span = Timespan::from_secs(secs).natural_span(); - let amount = span.as_unit(); - let unit = span.unit().as_str(); + let unit = std::cmp::min(span.unit(), TimespanUnit::Minutes); + let amount = span.to_unit(unit).as_unit(); let secs_per_card = if cards > 0 { secs / (cards as f32) } else { 0.0 }; - tr.statistics_studied_today(unit, secs_per_card, amount, cards) + tr.statistics_studied_today(unit.as_str(), secs_per_card, amount, cards) .into() } @@ -41,5 +42,9 @@ mod test { &studied_today(3, 13.0, &tr).replace('\n', " "), "Studied 3 cards in 13 seconds today (4.33s/card)" ); + assert_eq!( + &studied_today(300, 5400.0, &tr).replace('\n', " "), + "Studied 300 cards in 90 minutes today (18s/card)" + ); } } diff --git a/ts/lib/tslib/time.ts b/ts/lib/tslib/time.ts index f40758d8d..25d70eef3 100644 --- a/ts/lib/tslib/time.ts +++ b/ts/lib/tslib/time.ts @@ -89,7 +89,7 @@ export function naturalWholeUnit(secs: number): TimespanUnit { } export function studiedToday(cards: number, secs: number): string { - const unit = naturalUnit(secs); + const unit = Math.min(naturalUnit(secs), TimespanUnit.Minutes); const amount = unitAmount(unit, secs); const name = unitName(unit); From e789128ce715879f7f4d17bd141d1325ed897c31 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Wed, 6 Aug 2025 17:49:30 +0800 Subject: [PATCH 09/21] Fix Cards with Missing Last Review Time During Database Check (#4237) * Fix Cards with Missing Last Review Time During Database Check * clippy * Apply suggestions from code review Co-authored-by: Luc Mcgrady * Apply suggestions from code review Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * Add is_reset method to RevlogEntry and update scheduling logic This commit introduces the `is_reset` method to the `RevlogEntry` struct, which identifies entries representing reset operations. Additionally, the scheduling logic in `memory_state.rs` and `params.rs` has been updated to utilize this new method, ensuring that reset entries are handled correctly during review scheduling. * Implement is_cramming method in RevlogEntry and update scheduling logic This commit adds the `is_cramming` method to the `RevlogEntry` struct, which identifies entries representing cramming operations. The scheduling logic in `params.rs` has been updated to utilize this new method, improving the clarity and maintainability of the code. * Refactor rating logic in RevlogEntry and update related scheduling functions This commit introduces a new `has_rating` method in the `RevlogEntry` struct to encapsulate the logic for checking if an entry has a rating. The scheduling logic in `params.rs` and the calculation of normal answer counts in `card.rs` have been updated to use this new method, enhancing code clarity and maintainability. * update revlog test helper function to assign button_chosen correctly * Refactor card property fixing logic to use CardFixStats struct * Add one-way sync trigger for last review time updates in dbcheck * Update documentation for is_reset method in RevlogEntry to clarify ease_factor condition * Apply suggestions from code review Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * Minor wording tweak --------- Co-authored-by: Luc Mcgrady Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> --- ftl/core/database-check.ftl | 5 ++++ rslib/src/dbcheck.rs | 24 ++++++++++++++-- rslib/src/revlog/mod.rs | 36 ++++++++++++++++++++++++ rslib/src/scheduler/fsrs/memory_state.rs | 12 ++++---- rslib/src/scheduler/fsrs/params.rs | 26 ++++++----------- rslib/src/stats/card.rs | 2 +- rslib/src/stats/graphs/retention.rs | 5 +--- rslib/src/storage/card/mod.rs | 29 +++++++++++++++++-- 8 files changed, 107 insertions(+), 32 deletions(-) diff --git a/ftl/core/database-check.ftl b/ftl/core/database-check.ftl index 8a9e4e178..ce2f827cd 100644 --- a/ftl/core/database-check.ftl +++ b/ftl/core/database-check.ftl @@ -5,6 +5,11 @@ database-check-card-properties = [one] Fixed { $count } invalid card property. *[other] Fixed { $count } invalid card properties. } +database-check-card-last-review-time-empty = + { $count -> + [one] Added last review time to { $count } card. + *[other] Added last review time to { $count } cards. + } database-check-missing-templates = { $count -> [one] Deleted { $count } card with missing template. diff --git a/rslib/src/dbcheck.rs b/rslib/src/dbcheck.rs index f58a2184a..42b9977be 100644 --- a/rslib/src/dbcheck.rs +++ b/rslib/src/dbcheck.rs @@ -24,6 +24,7 @@ use crate::notetype::NotetypeId; use crate::notetype::NotetypeKind; use crate::prelude::*; use crate::progress::ThrottlingProgressHandler; +use crate::storage::card::CardFixStats; use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; @@ -40,6 +41,7 @@ pub struct CheckDatabaseOutput { notetypes_recovered: usize, invalid_utf8: usize, invalid_ids: usize, + card_last_review_time_empty: usize, } #[derive(Debug, Clone, Copy, Default)] @@ -69,6 +71,11 @@ impl CheckDatabaseOutput { if self.card_properties_invalid > 0 { probs.push(tr.database_check_card_properties(self.card_properties_invalid)); } + if self.card_last_review_time_empty > 0 { + probs.push( + tr.database_check_card_last_review_time_empty(self.card_last_review_time_empty), + ); + } if self.cards_missing_note > 0 { probs.push(tr.database_check_card_missing_note(self.cards_missing_note)); } @@ -158,14 +165,25 @@ impl Collection { fn check_card_properties(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { let timing = self.timing_today()?; - let (new_cnt, other_cnt) = self.storage.fix_card_properties( + let CardFixStats { + new_cards_fixed, + other_cards_fixed, + last_review_time_fixed, + } = self.storage.fix_card_properties( timing.days_elapsed, TimestampSecs::now(), self.usn()?, self.scheduler_version() == SchedulerVersion::V1, )?; - out.card_position_too_high = new_cnt; - out.card_properties_invalid += other_cnt; + out.card_position_too_high = new_cards_fixed; + out.card_properties_invalid += other_cards_fixed; + out.card_last_review_time_empty = last_review_time_fixed; + + // Trigger one-way sync if last_review_time was updated to avoid conflicts + if last_review_time_fixed > 0 { + self.set_schema_modified()?; + } + Ok(()) } diff --git a/rslib/src/revlog/mod.rs b/rslib/src/revlog/mod.rs index ad7f30261..f52698388 100644 --- a/rslib/src/revlog/mod.rs +++ b/rslib/src/revlog/mod.rs @@ -84,6 +84,42 @@ impl RevlogEntry { }) .unwrap() } + + /// Returns true if this entry represents a reset operation. + /// These entries are created when a card is reset using + /// [`Collection::reschedule_cards_as_new`]. + /// The 0 value of `ease_factor` differentiates it + /// from entry created by [`Collection::set_due_date`] that has + /// `RevlogReviewKind::Manual` but non-zero `ease_factor`. + pub(crate) fn is_reset(&self) -> bool { + self.review_kind == RevlogReviewKind::Manual && self.ease_factor == 0 + } + + /// Returns true if this entry represents a cramming operation. + /// These entries are created when a card is reviewed in a + /// filtered deck with "Reschedule cards based on my answers + /// in this deck" disabled. + /// [`crate::scheduler::answering::CardStateUpdater::apply_preview_state`]. + /// The 0 value of `ease_factor` distinguishes it from the entry + /// created when a card is reviewed before its due date in a + /// filtered deck with reschedule enabled or using Grade Now. + pub(crate) fn is_cramming(&self) -> bool { + self.review_kind == RevlogReviewKind::Filtered && self.ease_factor == 0 + } + + pub(crate) fn has_rating(&self) -> bool { + self.button_chosen > 0 + } + + /// Returns true if the review entry is not manually rescheduled and not + /// cramming. Used to filter out entries that shouldn't be considered + /// for statistics and scheduling. + pub(crate) fn has_rating_and_affects_scheduling(&self) -> bool { + // not rescheduled/set due date/reset + self.has_rating() + // not cramming + && !self.is_cramming() + } } impl Collection { diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 199b19329..062f5bcca 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -306,15 +306,15 @@ pub(crate) fn fsrs_items_for_memory_states( .collect() } -struct LastRevlogInfo { +pub(crate) struct LastRevlogInfo { /// Used to determine the actual elapsed time between the last time the user /// reviewed the card and now, so that we can determine an accurate period /// when the card has subsequently been rescheduled to a different day. - last_reviewed_at: Option, + pub(crate) last_reviewed_at: Option, } -/// Return a map of cards to info about last review/reschedule. -fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap { +/// Return a map of cards to info about last review. +pub(crate) fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap { let mut out = HashMap::new(); revlogs .iter() @@ -323,8 +323,10 @@ fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap= 1 { + if e.has_rating_and_affects_scheduling() { last_reviewed_at = Some(e.id.as_secs()); + } else if e.is_reset() { + last_reviewed_at = None; } } out.insert(card_id, LastRevlogInfo { last_reviewed_at }); diff --git a/rslib/src/scheduler/fsrs/params.rs b/rslib/src/scheduler/fsrs/params.rs index 63bdebe79..726870fe1 100644 --- a/rslib/src/scheduler/fsrs/params.rs +++ b/rslib/src/scheduler/fsrs/params.rs @@ -394,13 +394,13 @@ pub(crate) fn reviews_for_fsrs( let mut revlogs_complete = false; // Working backwards from the latest review... for (index, entry) in entries.iter().enumerate().rev() { - if entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0 { + if entry.is_cramming() { continue; } // For incomplete review histories, initial memory state is based on the first // user-graded review after the cutoff date with interval >= 1d. let within_cutoff = entry.id.0 > ignore_revlogs_before.0; - let user_graded = matches!(entry.button_chosen, 1..=4); + let user_graded = entry.has_rating(); let interday = entry.interval >= 1 || entry.interval <= -86400; if user_graded && within_cutoff && interday { first_user_grade_idx = Some(index); @@ -409,10 +409,7 @@ pub(crate) fn reviews_for_fsrs( if user_graded && entry.review_kind == RevlogReviewKind::Learning { first_of_last_learn_entries = Some(index); revlogs_complete = true; - } else if matches!( - (entry.review_kind, entry.ease_factor), - (RevlogReviewKind::Manual, 0) - ) { + } else if entry.is_reset() { // Ignore entries prior to a `Reset` if a learning step has come after, // but consider revlogs complete. if first_of_last_learn_entries.is_some() { @@ -472,16 +469,7 @@ pub(crate) fn reviews_for_fsrs( } // Filter out unwanted entries - entries.retain(|entry| { - !( - // set due date, reset or rescheduled - (entry.review_kind == RevlogReviewKind::Manual || entry.button_chosen == 0) - || // cram - (entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0) - || // rescheduled - (entry.review_kind == RevlogReviewKind::Rescheduled) - ) - }); + entries.retain(|entry| entry.has_rating_and_affects_scheduling()); // Compute delta_t for each entry let delta_ts = iter::once(0) @@ -560,10 +548,14 @@ pub(crate) mod tests { } pub(crate) fn revlog(review_kind: RevlogReviewKind, days_ago: i64) -> RevlogEntry { + let button_chosen = match review_kind { + RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => 0, + _ => 3, + }; RevlogEntry { review_kind, id: days_ago_ms(days_ago).into(), - button_chosen: 3, + button_chosen, interval: 1, ..Default::default() } diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index ee8592d91..008977fe9 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -197,7 +197,7 @@ impl Collection { } fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) { - let normal_answer_count = revlog.iter().filter(|r| r.button_chosen > 0).count(); + let normal_answer_count = revlog.iter().filter(|r| r.has_rating()).count(); let total_secs: f32 = revlog .iter() .map(|entry| (entry.taken_millis as f32) / 1000.0) diff --git a/rslib/src/stats/graphs/retention.rs b/rslib/src/stats/graphs/retention.rs index c21f43301..231a892f0 100644 --- a/rslib/src/stats/graphs/retention.rs +++ b/rslib/src/stats/graphs/retention.rs @@ -53,10 +53,7 @@ impl GraphsContext { self.revlog .iter() .filter(|review| { - // not rescheduled/set due date/reset - review.button_chosen > 0 - // not cramming - && (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0) + review.has_rating_and_affects_scheduling() // cards with an interval ≥ 1 day && (review.review_kind == RevlogReviewKind::Review || review.last_interval <= -86400 diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index a1db247c3..1d0d62fd7 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -33,6 +33,7 @@ use crate::decks::DeckKind; use crate::error::Result; use crate::notes::NoteId; use crate::scheduler::congrats::CongratsInfo; +use crate::scheduler::fsrs::memory_state::get_last_revlog_info; use crate::scheduler::queue::BuryMode; use crate::scheduler::queue::DueCard; use crate::scheduler::queue::DueCardKind; @@ -42,6 +43,13 @@ use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; use crate::types::Usn; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CardFixStats { + pub new_cards_fixed: usize, + pub other_cards_fixed: usize, + pub last_review_time_fixed: usize, +} + impl FromSql for CardType { fn column_result(value: ValueRef<'_>) -> result::Result { if let ValueRef::Integer(i) = value { @@ -365,7 +373,7 @@ impl super::SqliteStorage { mtime: TimestampSecs, usn: Usn, v1_sched: bool, - ) -> Result<(usize, usize)> { + ) -> Result { let new_cnt = self .db .prepare(include_str!("fix_due_new.sql"))? @@ -390,7 +398,24 @@ impl super::SqliteStorage { .db .prepare(include_str!("fix_ordinal.sql"))? .execute(params![mtime, usn])?; - Ok((new_cnt, other_cnt)) + let mut last_review_time_cnt = 0; + let revlog = self.get_all_revlog_entries_in_card_order()?; + let last_revlog_info = get_last_revlog_info(&revlog); + for (card_id, last_revlog_info) in last_revlog_info { + let card = self.get_card(card_id)?; + if let Some(mut card) = card { + if card.ctype != CardType::New && card.last_review_time.is_none() { + card.last_review_time = last_revlog_info.last_reviewed_at; + self.update_card(&card)?; + last_review_time_cnt += 1; + } + } + } + Ok(CardFixStats { + new_cards_fixed: new_cnt, + other_cards_fixed: other_cnt, + last_review_time_fixed: last_review_time_cnt, + }) } pub(crate) fn delete_orphaned_cards(&self) -> Result { From 040f942723bc6b3a8ce6f80b8967307065308f51 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Thu, 7 Aug 2025 04:36:53 +0100 Subject: [PATCH 10/21] Use space-around for tabbed values (#4252) * space-around * have your cake and eat it --- ts/routes/deck-options/TabbedValue.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ts/routes/deck-options/TabbedValue.svelte b/ts/routes/deck-options/TabbedValue.svelte index daad409c3..87dc4ed2c 100644 --- a/ts/routes/deck-options/TabbedValue.svelte +++ b/ts/routes/deck-options/TabbedValue.svelte @@ -55,7 +55,10 @@ width: 100%; display: flex; flex-wrap: nowrap; - justify-content: space-between; + &:has(li:nth-child(3)) { + justify-content: space-between; + } + justify-content: space-around; padding-inline: 0; margin-bottom: 0.5rem; list-style: none; From 1fff0e617e84a8d37a6418c3c68b39adc33e08ff Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Fri, 8 Aug 2025 08:56:50 +0100 Subject: [PATCH 11/21] Fix/Retention help button bounds (#4253) * Move onTitleClick * rename variable * Fix: Tabbing issues --- ts/lib/components/TitledContainer.svelte | 28 ++++++++++-------------- ts/routes/graphs/Graph.svelte | 4 ++-- ts/routes/graphs/TrueRetention.svelte | 4 ++-- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/ts/lib/components/TitledContainer.svelte b/ts/lib/components/TitledContainer.svelte index 70e4a078c..98983940a 100644 --- a/ts/lib/components/TitledContainer.svelte +++ b/ts/lib/components/TitledContainer.svelte @@ -12,7 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { className as class }; export let title: string; - export let onTitleClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; + export let onHelpClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null;
- {#if onTitleClick} - + {title} + + {#if onHelpClick} +
-

- {title} -

- - {:else} -

- {title} -

+ +
{/if} -
- -
diff --git a/ts/routes/graphs/Graph.svelte b/ts/routes/graphs/Graph.svelte index 2c509639e..1dafed4bb 100644 --- a/ts/routes/graphs/Graph.svelte +++ b/ts/routes/graphs/Graph.svelte @@ -8,7 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // When title is null (default), the graph is inlined, not having TitledContainer wrapper. export let title: string | null = null; export let subtitle: string | null = null; - export let onTitleClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; + export let onHelpClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; {#if title == null} @@ -19,7 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {:else} - +
{#if subtitle} diff --git a/ts/routes/graphs/TrueRetention.svelte b/ts/routes/graphs/TrueRetention.svelte index 6b89ab9b8..9af2b4d85 100644 --- a/ts/routes/graphs/TrueRetention.svelte +++ b/ts/routes/graphs/TrueRetention.svelte @@ -57,12 +57,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const title = tr.statisticsTrueRetentionTitle(); const subtitle = tr.statisticsTrueRetentionSubtitle(); - const onTitleClick = () => { + const onHelpClick = () => { openHelpModal(Object.keys(retentionHelp).indexOf("trueRetention")); }; - + Date: Fri, 8 Aug 2025 15:58:13 +0530 Subject: [PATCH 12/21] Fix/Exclude new cards from is_due_in_days (#4249) https://github.com/ankitects/anki/pull/4231/files#r2238901958 --- rslib/src/browser_table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index c13d9c294..ef7453955 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -105,7 +105,7 @@ impl Card { /// Returns true if the card has a due date in terms of days. fn is_due_in_days(&self) -> bool { - self.original_or_current_due() <= 365_000 // keep consistent with SQL + self.ctype != CardType::New && self.original_or_current_due() <= 365_000 // keep consistent with SQL || matches!(self.queue, CardQueue::DayLearn | CardQueue::Review) || (self.ctype == CardType::Review && self.is_undue_queue()) } From 6667b1dbb1cc374b70a4e60cd0fd4896f73a9777 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Fri, 8 Aug 2025 11:30:10 +0100 Subject: [PATCH 13/21] Feat/Neaten dr graph x-axis (#4251) * Remove "Plotted on x axis" * Add: X tick format * fix formatx * Fix: Regular simualtor x axis --- ftl/core/deck-config.ftl | 2 +- ts/routes/deck-options/SimulatorModal.svelte | 19 ----------------- ts/routes/graphs/simulator.ts | 22 +++++++++++++++----- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 2ca45fcd8..5154f44c1 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -517,7 +517,6 @@ deck-config-smooth-graph = Smooth graph deck-config-suspend-leeches = Suspend leeches deck-config-save-options-to-preset = Save Changes to Preset deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator? -deck-config-plotted-on-x-axis = (Plotted on the X-axis) # Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting # to show the total number of cards that can be recalled or retrieved on a # specific date. @@ -545,6 +544,7 @@ deck-config-fsrs-good-fit = Health Check: ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. +deck-config-plotted-on-x-axis = (Plotted on the X-axis) deck-config-a-100-day-interval = { $days -> [one] A 100 day interval will become { $days } day. diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index 5493b0093..c60f90455 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -44,8 +44,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Warning from "./Warning.svelte"; import type { ComputeRetentionProgress } from "@generated/anki/collection_pb"; import Modal from "bootstrap/js/dist/modal"; - import Row from "$lib/components/Row.svelte"; - import Col from "$lib/components/Col.svelte"; export let state: DeckOptionsState; export let simulateFsrsRequest: SimulateFsrsReviewRequest; @@ -373,23 +371,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {tr.deckConfigDesiredRetention()} - {:else} - - - openHelpModal("desiredRetention")} - > - {tr.deckConfigDesiredRetention()} - - - - - - {/if} formatter.format(n / 100); + const formatY: (value: number) => string = ({ [SimulateWorkloadSubgraph.ratio]: (value: number) => tr.deckConfigFsrsSimulatorRatioTooltip({ time: timeSpan(value) }), @@ -85,7 +92,7 @@ export function renderWorkloadChart( })[subgraph]; function formatX(dr: number) { - return `Desired Retention: ${dr}%
`; + return `${tr.deckConfigDesiredRetention()}: ${xTickFormat(dr)}
`; } return _renderSimulationChart( @@ -93,10 +100,11 @@ export function renderWorkloadChart( bounds, subgraph_data, x, - yTickFormat, formatY, formatX, (_e: MouseEvent, _d: number) => undefined, + yTickFormat, + xTickFormat, ); } @@ -169,10 +177,11 @@ export function renderSimulationChart( bounds, subgraph_data, x, - yTickFormat, formatY, formatX, legendMouseMove, + yTickFormat, + undefined, ); } @@ -181,10 +190,11 @@ function _renderSimulationChart( bounds: GraphBounds, subgraph_data: T[], x: any, - yTickFormat: (n: number) => string, formatY: (n: T["y"]) => string, formatX: (n: T["x"]) => string, legendMouseMove: (e: MouseEvent, d: number) => void, + yTickFormat?: (n: number) => string, + xTickFormat?: (n: number) => string, ): TableDatum[] { const svg = select(svgElem); svg.selectAll(".lines").remove(); @@ -198,7 +208,9 @@ function _renderSimulationChart( const trans = svg.transition().duration(600) as any; svg.select(".x-ticks") - .call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0))) + .call((selection) => + selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0).tickFormat(xTickFormat as any)) + ) .attr("direction", "ltr"); // y scale From 54c1db87d6395a27d390d1fdb4147255e7f24c75 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 8 Aug 2025 20:13:24 +1000 Subject: [PATCH 14/21] Add mirror option to launcher; stop downloading automatically To give users a chance to choose a mirror first, we have to give up the automatic downloading on first run. Closes #4226 --- qt/launcher/src/main.rs | 102 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 9 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 2d9f0aaf3..02fc08e76 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -46,6 +46,7 @@ struct State { uv_lock_path: std::path::PathBuf, sync_complete_marker: std::path::PathBuf, launcher_trigger_file: std::path::PathBuf, + mirror_path: std::path::PathBuf, pyproject_modified_by_user: bool, previous_version: Option, resources_dir: std::path::PathBuf, @@ -71,6 +72,7 @@ pub enum MainMenuChoice { Version(VersionKind), ToggleBetas, ToggleCache, + DownloadMirror, Uninstall, } @@ -108,6 +110,7 @@ fn run() -> Result<()> { uv_lock_path: uv_install_root.join("uv.lock"), sync_complete_marker: uv_install_root.join(".sync_complete"), launcher_trigger_file: uv_install_root.join(".want-launcher"), + mirror_path: uv_install_root.join("mirror"), pyproject_modified_by_user: false, // calculated later previous_version: None, resources_dir, @@ -155,12 +158,7 @@ fn run() -> Result<()> { check_versions(&mut state); - let first_run = !state.venv_folder.exists(); - if first_run { - handle_version_install_or_update(&state, MainMenuChoice::Latest)?; - } else { - main_menu_loop(&state)?; - } + main_menu_loop(&state)?; // Write marker file to indicate we've completed the sync process write_sync_marker(&state)?; @@ -379,6 +377,11 @@ fn main_menu_loop(state: &State) -> Result<()> { println!(); continue; } + MainMenuChoice::DownloadMirror => { + show_mirror_submenu(state)?; + println!(); + continue; + } MainMenuChoice::Uninstall => { if handle_uninstall(state)? { std::process::exit(0); @@ -443,8 +446,13 @@ fn get_main_menu_choice(state: &State) -> Result { "6) Cache downloads: {}", if cache_enabled { "on" } else { "off" } ); + let mirror_enabled = is_mirror_enabled(state); + println!( + "7) Download mirror: {}", + if mirror_enabled { "on" } else { "off" } + ); println!(); - println!("7) Uninstall"); + println!("8) Uninstall"); print!("> "); let _ = stdout().flush(); @@ -483,7 +491,8 @@ fn get_main_menu_choice(state: &State) -> Result { } "5" => MainMenuChoice::ToggleBetas, "6" => MainMenuChoice::ToggleCache, - "7" => MainMenuChoice::Uninstall, + "7" => MainMenuChoice::DownloadMirror, + "8" => MainMenuChoice::Uninstall, _ => { println!("Invalid input. Please try again."); continue; @@ -716,7 +725,15 @@ fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { &format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), ), }; - write_file(&state.user_pyproject_path, &updated_content)?; + + // Add mirror configuration if enabled + let final_content = if let Some((python_mirror, pypi_mirror)) = get_mirror_urls(state)? { + format!("{updated_content}\n\n[[tool.uv.index]]\nname = \"mirror\"\nurl = \"{pypi_mirror}\"\ndefault = true\n\n[tool.uv]\npython-install-mirror = \"{python_mirror}\"\n") + } else { + updated_content + }; + + write_file(&state.user_pyproject_path, &final_content)?; // Update .python-version based on version kind match version_kind { @@ -750,6 +767,9 @@ fn update_pyproject_for_version(menu_choice: MainMenuChoice, state: &State) -> R MainMenuChoice::ToggleCache => { unreachable!(); } + MainMenuChoice::DownloadMirror => { + unreachable!(); + } MainMenuChoice::Uninstall => { unreachable!(); } @@ -939,6 +959,70 @@ fn build_python_command(state: &State, args: &[String]) -> Result { Ok(cmd) } +fn is_mirror_enabled(state: &State) -> bool { + state.mirror_path.exists() +} + +fn get_mirror_urls(state: &State) -> Result> { + if !state.mirror_path.exists() { + return Ok(None); + } + + let content = read_file(&state.mirror_path)?; + let content_str = String::from_utf8(content).context("Invalid UTF-8 in mirror file")?; + + let lines: Vec<&str> = content_str.lines().collect(); + if lines.len() >= 2 { + Ok(Some(( + lines[0].trim().to_string(), + lines[1].trim().to_string(), + ))) + } else { + Ok(None) + } +} + +fn show_mirror_submenu(state: &State) -> Result<()> { + loop { + println!("Download mirror options:"); + println!("1) No mirror"); + println!("2) China"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + match input { + "1" => { + // Remove mirror file + if state.mirror_path.exists() { + let _ = remove_file(&state.mirror_path); + } + println!("Mirror disabled."); + break; + } + "2" => { + // Write China mirror URLs + let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"; + write_file(&state.mirror_path, china_mirrors)?; + println!("China mirror enabled."); + break; + } + "" => { + // Empty input - return to main menu + break; + } + _ => { + println!("Invalid input. Please try again."); + continue; + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From fea66f25b1fe2560ae2e7e7e1b117166fa2aca04 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 8 Aug 2025 20:21:48 +1000 Subject: [PATCH 15/21] Support socks proxies when fetching versions --- qt/launcher/src/main.rs | 2 +- qt/launcher/versions.py | 35 ++++++++++++++++++----------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 02fc08e76..297df5b8b 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -661,7 +661,7 @@ fn fetch_versions(state: &State) -> Result> { let mut cmd = Command::new(&state.uv_path); cmd.current_dir(&state.uv_install_root) .args(["run", "--no-project", "--no-config", "--managed-python"]) - .args(["--with", "pip-system-certs"]); + .args(["--with", "pip-system-certs,requests[socks]"]); let python_version = read_file(&state.dist_python_version_path)?; let python_version_str = diff --git a/qt/launcher/versions.py b/qt/launcher/versions.py index 5d314d84f..0fdf69c84 100644 --- a/qt/launcher/versions.py +++ b/qt/launcher/versions.py @@ -3,9 +3,9 @@ import json import sys -import urllib.request import pip_system_certs.wrapt_requests +import requests pip_system_certs.wrapt_requests.inject_truststore() @@ -15,25 +15,26 @@ def main(): url = "https://pypi.org/pypi/aqt/json" try: - with urllib.request.urlopen(url, timeout=30) as response: - data = json.loads(response.read().decode("utf-8")) - releases = data.get("releases", {}) + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.json() + releases = data.get("releases", {}) - # Create list of (version, upload_time) tuples - version_times = [] - for version, files in releases.items(): - if files: # Only include versions that have files - # Use the upload time of the first file for each version - upload_time = files[0].get("upload_time_iso_8601") - if upload_time: - version_times.append((version, upload_time)) + # Create list of (version, upload_time) tuples + version_times = [] + for version, files in releases.items(): + if files: # Only include versions that have files + # Use the upload time of the first file for each version + upload_time = files[0].get("upload_time_iso_8601") + if upload_time: + version_times.append((version, upload_time)) - # Sort by upload time - version_times.sort(key=lambda x: x[1]) + # Sort by upload time + version_times.sort(key=lambda x: x[1]) - # Extract just the version names - versions = [version for version, _ in version_times] - print(json.dumps(versions)) + # Extract just the version names + versions = [version for version, _ in version_times] + print(json.dumps(versions)) except Exception as e: print(f"Error fetching versions: {e}", file=sys.stderr) sys.exit(1) From b9220dc0cf8299cdd761b0ad5ad449d6e4d45ae9 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 8 Aug 2025 20:31:05 +1000 Subject: [PATCH 16/21] Update translations --- ftl/core-repo | 2 +- ftl/qt-repo | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ftl/core-repo b/ftl/core-repo index a0d0e232d..a599715d3 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit a0d0e232d296ccf5750e39df2442b133267b222b +Subproject commit a599715d3c27ff2eb895c749f3534ab73d83dad1 diff --git a/ftl/qt-repo b/ftl/qt-repo index 9639c96fe..bb4207f3b 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit 9639c96fe5862459aa1ff4e599079cac72a9fd7c +Subproject commit bb4207f3b8e9a7c428db282d12c75b850be532f3 From 82b9ba523a3c5f700e29d4e6d8d4800d4b09b3a8 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 8 Aug 2025 20:37:53 +1000 Subject: [PATCH 17/21] Bump version --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index c64758c2d..6b856e54b 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.08b4 +25.08b5 From 24243a7d552e8e4cff25feafc62b22a3a3080953 Mon Sep 17 00:00:00 2001 From: user1823 <92206575+user1823@users.noreply.github.com> Date: Sat, 9 Aug 2025 11:46:36 +0530 Subject: [PATCH 18/21] Improve elapsed seconds calculation for learning cards in browser table (#4255) * Improve calculation of elapsed seconds for learning cards in browser_table.rs https://github.com/ankitects/anki/pull/4231/files#r2257105522 * Format --- rslib/src/browser_table.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index ef7453955..4d943e408 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -132,15 +132,14 @@ impl Card { pub(crate) fn seconds_since_last_review(&self, timing: &SchedTimingToday) -> Option { if let Some(last_review_time) = self.last_review_time { Some(timing.now.elapsed_secs_since(last_review_time) as u32) - } else if !self.is_due_in_days() { - let last_review_time = - TimestampSecs(self.original_or_current_due() as i64 - self.interval as i64); - Some(timing.now.elapsed_secs_since(last_review_time) as u32) - } else { + } else if self.is_due_in_days() { self.due_time(timing).map(|due| { (due.adding_secs(-86_400 * self.interval as i64) .elapsed_secs()) as u32 }) + } else { + let last_review_time = TimestampSecs(self.original_or_current_due() as i64); + Some(timing.now.elapsed_secs_since(last_review_time) as u32) } } } From d340857c04643bfc1171b8cdefbdc9c3ccfdee2e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 9 Aug 2025 16:46:25 +1000 Subject: [PATCH 19/21] Revert "Fix/Retention help button bounds (#4253)" (#4258) This reverts commit 5462d99255c41ed47b7953ca6487594ba7cabd5d. --- ts/lib/components/TitledContainer.svelte | 28 ++++++++++++++---------- ts/routes/graphs/Graph.svelte | 4 ++-- ts/routes/graphs/TrueRetention.svelte | 4 ++-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/ts/lib/components/TitledContainer.svelte b/ts/lib/components/TitledContainer.svelte index 98983940a..70e4a078c 100644 --- a/ts/lib/components/TitledContainer.svelte +++ b/ts/lib/components/TitledContainer.svelte @@ -12,7 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export { className as class }; export let title: string; - export let onHelpClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; + export let onTitleClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null;
-

- {title} -

- {#if onHelpClick} -
- -
+

+ {title} +

+ + {:else} +

+ {title} +

{/if} +
+ +
diff --git a/ts/routes/graphs/Graph.svelte b/ts/routes/graphs/Graph.svelte index 1dafed4bb..2c509639e 100644 --- a/ts/routes/graphs/Graph.svelte +++ b/ts/routes/graphs/Graph.svelte @@ -8,7 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // When title is null (default), the graph is inlined, not having TitledContainer wrapper. export let title: string | null = null; export let subtitle: string | null = null; - export let onHelpClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; + export let onTitleClick: ((_e: MouseEvent | KeyboardEvent) => void) | null = null; {#if title == null} @@ -19,7 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{:else} - +
{#if subtitle} diff --git a/ts/routes/graphs/TrueRetention.svelte b/ts/routes/graphs/TrueRetention.svelte index 9af2b4d85..6b89ab9b8 100644 --- a/ts/routes/graphs/TrueRetention.svelte +++ b/ts/routes/graphs/TrueRetention.svelte @@ -57,12 +57,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const title = tr.statisticsTrueRetentionTitle(); const subtitle = tr.statisticsTrueRetentionSubtitle(); - const onHelpClick = () => { + const onTitleClick = () => { openHelpModal(Object.keys(retentionHelp).indexOf("trueRetention")); }; - + Date: Mon, 11 Aug 2025 04:44:11 +0100 Subject: [PATCH 20/21] Fix/System locale for simulator percentages (#4260) Co-authored-by: Ross Brown --- ts/routes/graphs/simulator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts index 6de8aedeb..feba9ce57 100644 --- a/ts/routes/graphs/simulator.ts +++ b/ts/routes/graphs/simulator.ts @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import { localizedDate } from "@tslib/i18n"; +import { createLocaleNumberFormat, localizedDate } from "@tslib/i18n"; import { axisBottom, axisLeft, @@ -74,7 +74,7 @@ export function renderWorkloadChart( : n.toString(); }; - const formatter = new Intl.NumberFormat(undefined, { + const formatter = createLocaleNumberFormat({ style: "percent", minimumFractionDigits: 0, maximumFractionDigits: 0, From aec1074e06dcf1fd1ccf3cbf9990d02843e3518d Mon Sep 17 00:00:00 2001 From: Thomas Rixen Date: Sun, 17 Aug 2025 22:57:22 +0200 Subject: [PATCH 21/21] Congratulations Screen with 7-Day future due graph --- proto/anki/scheduler.proto | 9 ++ rslib/src/scheduler/congrats.rs | 53 +++++++- rslib/src/scheduler/mod.rs | 41 ++++++ ts/routes/congrats/CongratsFutureDue.svelte | 142 ++++++++++++++++++++ ts/routes/congrats/CongratsPage.svelte | 21 +++ 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 ts/routes/congrats/CongratsFutureDue.svelte diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 34b350642..6fa94ce3a 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -193,6 +193,15 @@ message CongratsInfoResponse { bool is_filtered_deck = 7; bool bridge_commands_supported = 8; string deck_description = 9; + repeated ReviewForecastDay forecast = 10; +} + +message ReviewForecastDay { + uint32 day_offset = 1; + uint32 total = 2; + uint32 review = 3; + uint32 learn = 4; + uint32 new = 5; } message UnburyDeckRequest { diff --git a/rslib/src/scheduler/congrats.rs b/rslib/src/scheduler/congrats.rs index 721bd417b..c99526b95 100644 --- a/rslib/src/scheduler/congrats.rs +++ b/rslib/src/scheduler/congrats.rs @@ -20,6 +20,7 @@ impl Collection { let info = self.storage.congrats_info(&deck, today)?; let is_filtered_deck = deck.is_filtered(); let deck_description = deck.rendered_description(); + let forecast = self.sched_forecast(8).unwrap_or_default(); let secs_until_next_learn = if info.next_learn_due == 0 { // signal to the frontend that no learning cards are due later 86_400 @@ -37,6 +38,7 @@ impl Collection { secs_until_next_learn, bridge_commands_supported: true, deck_description, + forecast, }) } } @@ -49,6 +51,15 @@ mod test { fn empty() { let mut col = Collection::new(); let info = col.congrats_info().unwrap(); + let expected_forecast = (0..7) + .map(|offset| anki_proto::scheduler::ReviewForecastDay { + day_offset: offset, + total: 0, + review: 0, + learn: 0, + new: 0, + }) + .collect(); assert_eq!( info, anki_proto::scheduler::CongratsInfoResponse { @@ -60,8 +71,48 @@ mod test { is_filtered_deck: false, secs_until_next_learn: 86_400, bridge_commands_supported: true, - deck_description: "".to_string() + deck_description: "".to_string(), + forecast: expected_forecast } ) } + + #[test] + fn cards_added_to_graph() { + let mut col = Collection::new(); + let timing = col.timing_today().unwrap(); + let today = timing.days_elapsed; + // Create a simple card directly in the database + col.storage.db.execute_batch(&format!( + "INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data) + VALUES + (1, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, ''), + (2, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, ''), + (3, 1, 1, 0, {}, 0, 2, 2, {}, 1, 2500, 1, 0, 0, 0, 0, 0, '')", + timing.now.0, + today, // Card 1 due today + timing.now.0, + today + 1, // Card 2 due tomorrow + timing.now.0, + today + 2, // Card 3 due day after tomorrow + )).unwrap(); + let forecast = col.sched_forecast(7).unwrap(); + // Check that cards appear on the correct days + assert_eq!(forecast[0].total, 1); // Today: 1 card + assert_eq!(forecast[0].review, 1); + assert_eq!(forecast[1].total, 1); // Tomorrow: 1 card + assert_eq!(forecast[1].review, 1); + assert_eq!(forecast[2].total, 1); // Day 2: 1 card + assert_eq!(forecast[2].review, 1); + // Days 3-6 should have no cards + for day in forecast.iter().skip(3).take(4) { + assert_eq!(day.total, 0); + assert_eq!(day.review, 0); + } + // All days should have learn = 0, new = 0 (current implementation) + for day in &forecast { + assert_eq!(day.learn, 0); + assert_eq!(day.new, 0); + } + } } diff --git a/rslib/src/scheduler/mod.rs b/rslib/src/scheduler/mod.rs index 93aee3c9b..f95e12c47 100644 --- a/rslib/src/scheduler/mod.rs +++ b/rslib/src/scheduler/mod.rs @@ -31,6 +31,15 @@ pub struct SchedulerInfo { pub timing: SchedTimingToday, } +#[derive(Debug, Clone)] +pub struct ReviewForecastDay { + pub day_offset: u32, + pub total: u32, + pub review: u32, + pub learn: u32, + pub new: u32, +} + impl Collection { pub fn scheduler_info(&mut self) -> Result { let now = TimestampSecs::now(); @@ -132,4 +141,36 @@ impl Collection { self.state.scheduler_info = None; self.storage.set_creation_stamp(stamp) } + + /// Return forecast data for the next `days` days (capped at 7). + pub(crate) fn sched_forecast( + &mut self, + days: u32, + ) -> Result> { + use anki_proto::scheduler::ReviewForecastDay as PbDay; + let timing = self.timing_for_timestamp(TimestampSecs::now())?; + let today = timing.days_elapsed; + let mut out = Vec::new(); + let want = days.min(7); + + for offset in 0..want { + let target_day = today + offset; + let rev_cnt = self + .storage + .db + .prepare_cached("SELECT COUNT(*) FROM cards WHERE queue = 2 AND due = ?") + .and_then(|mut stmt| { + stmt.query_row([(target_day as i64)], |row| row.get::<_, u32>(0)) + }) + .unwrap_or(0); + out.push(PbDay { + day_offset: offset, + total: rev_cnt, + review: rev_cnt, + learn: 0, + new: 0, + }); + } + Ok(out) + } } diff --git a/ts/routes/congrats/CongratsFutureDue.svelte b/ts/routes/congrats/CongratsFutureDue.svelte new file mode 100644 index 000000000..17b84d484 --- /dev/null +++ b/ts/routes/congrats/CongratsFutureDue.svelte @@ -0,0 +1,142 @@ + + + +
+
+

{title}

+
+ +
+ {#if chartData.some((d) => d.count > 0)} + + {:else} +
No cards due in the next 7 days
+ {/if} +
+
diff --git a/ts/routes/congrats/CongratsPage.svelte b/ts/routes/congrats/CongratsPage.svelte index 94187479e..34c41c7f7 100644 --- a/ts/routes/congrats/CongratsPage.svelte +++ b/ts/routes/congrats/CongratsPage.svelte @@ -10,6 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Col from "$lib/components/Col.svelte"; import Container from "$lib/components/Container.svelte"; + import CongratsFutureDue from "./CongratsFutureDue.svelte"; import { buildNextLearnMsg } from "./lib"; import { onMount } from "svelte"; @@ -30,6 +31,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html customStudy, }); + $: forecastData = (() => { + const forecast = (info as any).forecast || []; + return forecast.map((day: any) => ({ + review: day.review || 0, + learn: day.learn || 0, + new: day.new || 0, + total: (day.review || 0) + (day.learn || 0) + (day.new || 0), + })); + })(); + onMount(() => { if (refreshPeriodically) { setInterval(async () => { @@ -77,6 +88,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {@html info.deckDescription}
{/if} + + {#if forecastData.length > 0 && forecastData.some((d) => d.total > 0)} +
+

+ Below is your study forecast for the upcoming week. This shows + how many cards you'll need to review each day for this deck. +

+ +
+ {/if}