Merge branch 'main' of https://github.com/ankitects/anki into fix-duplicate-deck-drag-drop

This commit is contained in:
Kris Cherven 2025-03-21 21:04:22 -04:00
commit 52347819dd
12 changed files with 131 additions and 59 deletions

2
Cargo.lock generated
View file

@ -2099,7 +2099,7 @@ dependencies = [
[[package]] [[package]]
name = "fsrs" name = "fsrs"
version = "3.0.0" version = "3.0.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=22f8e453c120f5bc5996f86558a559c6b7abfc49#22f8e453c120f5bc5996f86558a559c6b7abfc49" source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=08d90d1363b0c4722422bf0ef71ed8fd7d053f8a#08d90d1363b0c4722422bf0ef71ed8fd7d053f8a"
dependencies = [ dependencies = [
"burn", "burn",
"itertools 0.12.1", "itertools 0.12.1",

View file

@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs] [workspace.dependencies.fsrs]
# version = "=2.0.3" # version = "=2.0.3"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git" git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "22f8e453c120f5bc5996f86558a559c6b7abfc49" rev = "08d90d1363b0c4722422bf0ef71ed8fd7d053f8a"
# path = "../open-spaced-repetition/fsrs-rs" # path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies] [workspace.dependencies]

View file

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "nightly-2023-09-02" channel = "nightly-2025-03-20"
profile = "minimal" profile = "minimal"
components = ["rustfmt"] components = ["rustfmt"]

View file

@ -8,3 +8,5 @@ about-if-you-have-contributed-and-are = If you have contributed and are not on t
about-version = Version { $val } about-version = Version { $val }
about-visit-website = <a href='{ $val }'>Visit website</a> about-visit-website = <a href='{ $val }'>Visit website</a>
about-written-by-damien-elmes-with-patches = Written by Damien Elmes, with patches, translation, testing and design from:<p>{ $cont } about-written-by-damien-elmes-with-patches = Written by Damien Elmes, with patches, translation, testing and design from:<p>{ $cont }
# appended to the end of the contributor list in the about screen
about-and-others = and others

View file

@ -248,6 +248,10 @@ is_mac = sys.platform.startswith("darwin")
is_win = sys.platform.startswith("win32") is_win = sys.platform.startswith("win32")
# also covers *BSD # also covers *BSD
is_lin = not is_mac and not is_win is_lin = not is_mac and not is_win
is_gnome = (
"gnome" in os.getenv("XDG_CURRENT_DESKTOP", "").lower()
or "gnome" in os.getenv("DESKTOP_SESSION", "").lower()
)
dev_mode = os.getenv("ANKIDEV", "") dev_mode = os.getenv("ANKIDEV", "")
hmr_mode = os.getenv("HMR", "") hmr_mode = os.getenv("HMR", "")

View file

@ -59,7 +59,7 @@ from anki._backend import RustBackend
from anki.buildinfo import version as _version from anki.buildinfo import version as _version
from anki.collection import Collection from anki.collection import Collection
from anki.consts import HELP_SITE from anki.consts import HELP_SITE
from anki.utils import checksum, is_lin, is_mac from anki.utils import checksum, is_gnome, is_lin, is_mac
from aqt import gui_hooks from aqt import gui_hooks
from aqt.log import setup_logging from aqt.log import setup_logging
from aqt.qt import * from aqt.qt import *
@ -614,7 +614,7 @@ def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None:
) )
wayland_forced = os.getenv("ANKI_WAYLAND") wayland_forced = os.getenv("ANKI_WAYLAND")
if packaged and wayland_configured: if (packaged or is_gnome) and wayland_configured:
if wayland_forced or not x11_available: if wayland_forced or not x11_available:
# Work around broken fractional scaling in Wayland # Work around broken fractional scaling in Wayland
# https://bugreports.qt.io/browse/QTBUG-113574 # https://bugreports.qt.io/browse/QTBUG-113574

View file

@ -222,7 +222,7 @@ def show(mw: aqt.AnkiQt) -> QDialog:
) )
abouttext += "<p>" + tr.about_written_by_damien_elmes_with_patches( abouttext += "<p>" + tr.about_written_by_damien_elmes_with_patches(
cont=", ".join(allusers) cont=", ".join(allusers) + f", {tr.about_and_others()}"
) )
abouttext += f"<p>{tr.about_if_you_have_contributed_and_are()}" abouttext += f"<p>{tr.about_if_you_have_contributed_and_are()}"
abouttext += f"<p>{tr.about_a_big_thanks_to_all_the()}" abouttext += f"<p>{tr.about_a_big_thanks_to_all_the()}"

View file

@ -121,19 +121,34 @@ impl Collection {
} }
fn parse_meta_value(&mut self, key: &str, value: &str, metadata: &mut CsvMetadata) { fn parse_meta_value(&mut self, key: &str, value: &str, metadata: &mut CsvMetadata) {
// trim potential delimiters past the first char* if
// metadata line was mistakenly exported as a record
// *to allow cases like #separator:,
// ASSUMPTION: delimiters are not ascii-alphanumeric
let trimmed_value = value
.char_indices()
.nth(1)
.and_then(|(i, _)| {
value[i..] // SAFETY: char_indices are on char boundaries
.find(|c| !char::is_ascii_alphanumeric(&c))
.map(|j| value.split_at(i + j).0)
})
.unwrap_or(value);
match key.trim().to_ascii_lowercase().as_str() { match key.trim().to_ascii_lowercase().as_str() {
"separator" => { "separator" => {
if let Some(delimiter) = delimiter_from_value(value) { if let Some(delimiter) = delimiter_from_value(trimmed_value) {
metadata.delimiter = delimiter as i32; metadata.delimiter = delimiter as i32;
metadata.force_delimiter = true; metadata.force_delimiter = true;
} }
} }
"html" => { "html" => {
if let Ok(is_html) = value.to_lowercase().parse() { if let Ok(is_html) = trimmed_value.to_lowercase().parse() {
metadata.is_html = is_html; metadata.is_html = is_html;
metadata.force_is_html = true; metadata.force_is_html = true;
} }
} }
// freeform values cannot be trimmed thus without knowing the exact delimiter
"tags" => metadata.global_tags = collect_tags(value), "tags" => metadata.global_tags = collect_tags(value),
"columns" => { "columns" => {
if let Ok(columns) = parse_columns(value, metadata.delimiter()) { if let Ok(columns) = parse_columns(value, metadata.delimiter()) {
@ -151,22 +166,22 @@ impl Collection {
} }
} }
"notetype column" => { "notetype column" => {
if let Ok(n) = value.trim().parse() { if let Ok(n) = trimmed_value.trim().parse() {
metadata.notetype = Some(CsvNotetype::NotetypeColumn(n)); metadata.notetype = Some(CsvNotetype::NotetypeColumn(n));
} }
} }
"deck column" => { "deck column" => {
if let Ok(n) = value.trim().parse() { if let Ok(n) = trimmed_value.trim().parse() {
metadata.deck = Some(CsvDeck::DeckColumn(n)); metadata.deck = Some(CsvDeck::DeckColumn(n));
} }
} }
"tags column" => { "tags column" => {
if let Ok(n) = value.trim().parse() { if let Ok(n) = trimmed_value.trim().parse() {
metadata.tags_column = n; metadata.tags_column = n;
} }
} }
"guid column" => { "guid column" => {
if let Ok(n) = value.trim().parse() { if let Ok(n) = trimmed_value.trim().parse() {
metadata.guid_column = n; metadata.guid_column = n;
} }
} }
@ -891,4 +906,32 @@ pub(in crate::import_export) mod test {
maybe_set_tags_column(&mut metadata, &meta_columns); maybe_set_tags_column(&mut metadata, &meta_columns);
assert_eq!(metadata.tags_column, 4); assert_eq!(metadata.tags_column, 4);
} }
#[test]
fn should_allow_non_freeform_metadata_lines_to_be_suffixed_by_delimiters() {
let mut col = Collection::new();
let metadata = metadata!(
col,
r#"
#separator:Pipe,,,,,,,
#html:true|||||
#tags:foo bar::,,,
#guid column:8
#tags column:123abc
"#
.trim()
);
assert_eq!(metadata.delimiter(), Delimiter::Pipe);
assert!(metadata.is_html);
assert_eq!(metadata.guid_column, 8);
// tags is freeform, potential delimiters aren't trimmed
assert_eq!(metadata.global_tags, ["foo", "bar::世界,,,"]);
// ascii alphanumerics aren't trimmed away
assert_eq!(metadata.tags_column, 0);
assert_eq!(
metadata!(col, "#separator:\t|,:\n").delimiter(),
Delimiter::Tab
);
}
} }

View file

@ -242,6 +242,7 @@ pub(crate) struct FsrsItemForMemoryState {
/// When revlogs have been truncated, this stores the initial state at first /// When revlogs have been truncated, this stores the initial state at first
/// review /// review
pub starting_state: Option<MemoryState>, pub starting_state: Option<MemoryState>,
pub filtered_revlogs: Vec<RevlogEntry>,
} }
/// Like [fsrs_item_for_memory_state], but for updating multiple cards at once. /// Like [fsrs_item_for_memory_state], but for updating multiple cards at once.
@ -330,6 +331,7 @@ pub(crate) fn fsrs_item_for_memory_state(
Ok(Some(FsrsItemForMemoryState { Ok(Some(FsrsItemForMemoryState {
item, item,
starting_state: None, starting_state: None,
filtered_revlogs: output.filtered_revlogs,
})) }))
} else if let Some(first_user_grade) = output.filtered_revlogs.first() { } else if let Some(first_user_grade) = output.filtered_revlogs.first() {
// the revlog has been truncated, but not fully // the revlog has been truncated, but not fully
@ -356,6 +358,7 @@ pub(crate) fn fsrs_item_for_memory_state(
Ok(Some(FsrsItemForMemoryState { Ok(Some(FsrsItemForMemoryState {
item, item,
starting_state: Some(starting_state), starting_state: Some(starting_state),
filtered_revlogs: output.filtered_revlogs,
})) }))
} else { } else {
// only manual and rescheduled revlogs; treat like empty // only manual and rescheduled revlogs; treat like empty

View file

@ -14,6 +14,7 @@ use anki_proto::stats::DeckEntry;
use chrono::NaiveDate; use chrono::NaiveDate;
use chrono::NaiveTime; use chrono::NaiveTime;
use fsrs::CombinedProgressState; use fsrs::CombinedProgressState;
use fsrs::ComputeParametersInput;
use fsrs::FSRSItem; use fsrs::FSRSItem;
use fsrs::FSRSReview; use fsrs::FSRSReview;
use fsrs::MemoryState; use fsrs::MemoryState;
@ -107,34 +108,40 @@ impl Collection {
let (progress, progress_thread) = create_progress_thread()?; let (progress, progress_thread) = create_progress_thread()?;
let fsrs = FSRS::new(None)?; let fsrs = FSRS::new(None)?;
let mut params = fsrs.compute_parameters(items.clone(), Some(progress.clone()), true)?; let mut params = fsrs.compute_parameters(ComputeParametersInput {
train_set: items.clone(),
progress: Some(progress.clone()),
enable_short_term: true,
num_relearning_steps: Some(num_of_relearning_steps),
})?;
progress_thread.join().ok(); progress_thread.join().ok();
if let Ok(fsrs) = FSRS::new(Some(current_params)) { if let Ok(fsrs) = FSRS::new(Some(current_params)) {
let current_rmse = fsrs.evaluate(items.clone(), |_| true)?.rmse_bins; let current_rmse = fsrs.evaluate(items.clone(), |_| true)?.rmse_bins;
let optimized_fsrs = FSRS::new(Some(&params))?; let optimized_fsrs = FSRS::new(Some(&params))?;
let optimized_rmse = optimized_fsrs.evaluate(items.clone(), |_| true)?.rmse_bins; let optimized_rmse = optimized_fsrs.evaluate(items.clone(), |_| true)?.rmse_bins;
if current_rmse <= optimized_rmse { if current_rmse <= optimized_rmse {
params = current_params.to_vec(); if num_of_relearning_steps <= 1 {
} params = current_params.to_vec();
if num_of_relearning_steps > 1 { } else {
let memory_state = MemoryState { let current_fsrs = FSRS::new(Some(current_params))?;
stability: 1.0, let memory_state = MemoryState {
difficulty: 1.0, stability: 1.0,
}; difficulty: 1.0,
let s_fail = optimized_fsrs };
.next_states(Some(memory_state), 0.9, 2)?
.again; let s_fail = current_fsrs.next_states(Some(memory_state), 0.9, 2)?.again;
let mut s_short_term = s_fail.memory; let mut s_short_term = s_fail.memory;
for _ in 0..num_of_relearning_steps {
s_short_term = optimized_fsrs for _ in 0..num_of_relearning_steps {
.next_states(Some(s_short_term), 0.9, 0)? s_short_term = current_fsrs
.good .next_states(Some(s_short_term), 0.9, 0)?
.memory; .good
} .memory;
if s_short_term.stability > memory_state.stability { }
let (progress, progress_thread) = create_progress_thread()?;
params = fsrs.compute_parameters(items.clone(), Some(progress), false)?; if s_short_term.stability < memory_state.stability {
progress_thread.join().ok(); params = current_params.to_vec();
}
} }
} }
} }

View file

@ -17,6 +17,7 @@ use anki_proto::scheduler::FuzzDeltaResponse;
use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse;
use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse; use anki_proto::scheduler::SimulateFsrsReviewResponse;
use fsrs::ComputeParametersInput;
use fsrs::FSRSItem; use fsrs::FSRSItem;
use fsrs::FSRSReview; use fsrs::FSRSReview;
use fsrs::FSRS; use fsrs::FSRS;
@ -352,11 +353,12 @@ impl crate::services::BackendSchedulerService for Backend {
) -> Result<scheduler::ComputeFsrsParamsResponse> { ) -> Result<scheduler::ComputeFsrsParamsResponse> {
let fsrs = FSRS::new(None)?; let fsrs = FSRS::new(None)?;
let fsrs_items = req.items.len() as u32; let fsrs_items = req.items.len() as u32;
let params = fsrs.compute_parameters( let params = fsrs.compute_parameters(ComputeParametersInput {
req.items.into_iter().map(fsrs_item_proto_to_fsrs).collect(), train_set: req.items.into_iter().map(fsrs_item_proto_to_fsrs).collect(),
None, progress: None,
true, enable_short_term: true,
)?; num_relearning_steps: None,
})?;
Ok(ComputeFsrsParamsResponse { params, fsrs_items }) Ok(ComputeFsrsParamsResponse { params, fsrs_items })
} }
@ -370,7 +372,12 @@ impl crate::services::BackendSchedulerService for Backend {
.into_iter() .into_iter()
.map(fsrs_item_proto_to_fsrs) .map(fsrs_item_proto_to_fsrs)
.collect(); .collect();
let params = fsrs.benchmark(train_set, true); let params = fsrs.benchmark(ComputeParametersInput {
train_set,
progress: None,
enable_short_term: true,
num_relearning_steps: None,
});
Ok(FsrsBenchmarkResponse { params }) Ok(FsrsBenchmarkResponse { params })
} }

View file

@ -4,6 +4,7 @@
use fsrs::FSRS; use fsrs::FSRS;
use crate::card::CardType; use crate::card::CardType;
use crate::card::FsrsMemoryState;
use crate::prelude::*; use crate::prelude::*;
use crate::revlog::RevlogEntry; use crate::revlog::RevlogEntry;
use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state; use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state;
@ -140,26 +141,31 @@ impl Collection {
let ignore_before = ignore_revlogs_before_ms_from_config(&config)?; let ignore_before = ignore_revlogs_before_ms_from_config(&config)?;
let mut result = Vec::new(); let mut result = Vec::new();
let mut accumulated_revlog = Vec::new(); if let Some(item) = fsrs_item_for_memory_state(
&fsrs,
for entry in revlog { revlog.clone(),
accumulated_revlog.push(entry.clone()); next_day_at,
let item = fsrs_item_for_memory_state( historical_retention,
&fsrs, ignore_before,
accumulated_revlog.clone(), )? {
next_day_at, let memory_states = fsrs.historical_memory_states(item.item, item.starting_state)?;
historical_retention, let mut revlog_index = 0;
ignore_before, for entry in revlog {
)?; let mut stats_entry = stats_revlog_entry(&entry);
let mut card_clone = card.clone(); let memory_state: FsrsMemoryState =
card_clone.set_memory_state(&fsrs, item, historical_retention)?; if entry.id == item.filtered_revlogs[revlog_index].id {
revlog_index += 1;
let mut stats_entry = stats_revlog_entry(&entry); memory_states[revlog_index - 1].into()
stats_entry.memory_state = card_clone.memory_state.map(Into::into); } else {
result.push(stats_entry); memory_states[revlog_index].into()
};
stats_entry.memory_state = Some(memory_state.into());
result.push(stats_entry);
}
Ok(result.into_iter().rev().collect())
} else {
Ok(revlog.iter().map(stats_revlog_entry).collect())
} }
Ok(result.into_iter().rev().collect())
} }
} }