diff --git a/.cargo/config.toml b/.cargo/config.toml index 3fbb3be1b..49aaa3a6c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,3 +10,6 @@ PYTHONDONTWRITEBYTECODE = "1" # prevent junk files on Windows [term] color = "always" + +[target.'cfg(all(target_env = "msvc", target_os = "windows"))'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitignore b/.gitignore index 768716ca4..ccac21aa2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ yarn-error.log ts/.svelte-kit .yarn .claude/settings.local.json -CLAUDE.local.md +.claude/user.md diff --git a/.isort.cfg b/.isort.cfg deleted file mode 100644 index a26991a95..000000000 --- a/.isort.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[settings] -py_version=39 -known_first_party=anki,aqt,tests -profile=black diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 2413cc6c4..000000000 --- a/.pylintrc +++ /dev/null @@ -1,48 +0,0 @@ -[MASTER] -ignore-patterns=.*_pb2.* -persistent = no -extension-pkg-whitelist=orjson,PyQt6 -init-hook="import sys; sys.path.extend(['pylib/anki/_vendor', 'out/qt'])" - -[REPORTS] -output-format=colorized - -[MESSAGES CONTROL] -disable= - R, - line-too-long, - too-many-lines, - missing-function-docstring, - missing-module-docstring, - missing-class-docstring, - import-outside-toplevel, - wrong-import-position, - wrong-import-order, - fixme, - unused-wildcard-import, - attribute-defined-outside-init, - redefined-builtin, - wildcard-import, - broad-except, - bare-except, - unused-argument, - unused-variable, - redefined-outer-name, - global-statement, - protected-access, - arguments-differ, - arguments-renamed, - consider-using-f-string, - invalid-name, - broad-exception-raised - -[BASIC] -good-names = - id, - tr, - db, - ok, - ip, - -[IMPORTS] -ignored-modules = anki.*_pb2, anki.sync_pb2, win32file,pywintypes,socket,win32pipe,pyaudio,anki.scheduler_pb2,anki.notetypes_pb2 diff --git a/.ruff.toml b/.ruff.toml index fb6ffa2d8..4fa1ffea6 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,2 +1,91 @@ -target-version = "py39" -extend-exclude = [] +lint.select = [ + "E", # pycodestyle errors + "F", # Pyflakes errors + "PL", # Pylint rules + "I", # Isort rules + "ARG", + # "UP", # pyupgrade + # "B", # flake8-bugbear + # "SIM", # flake8-simplify +] + +extend-exclude = ["*_pb2.py", "*_pb2.pyi"] + +lint.ignore = [ + # Docstring rules (missing-*-docstring in pylint) + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D103", # Missing docstring in public function + + # Import rules (wrong-import-* in pylint) + "E402", # Module level import not at top of file + "E501", # Line too long + + # pycodestyle rules + "E741", # ambiguous-variable-name + + # Comment rules (fixme in pylint) + "FIX002", # Line contains TODO + + # Pyflakes rules + "F402", # import-shadowed-by-loop-var + "F403", # undefined-local-with-import-star + "F405", # undefined-local-with-import-star-usage + + # Naming rules (invalid-name in pylint) + "N801", # Class name should use CapWords convention + "N802", # Function name should be lowercase + "N803", # Argument name should be lowercase + "N806", # Variable in function should be lowercase + "N811", # Constant imported as non-constant + "N812", # Lowercase imported as non-lowercase + "N813", # Camelcase imported as lowercase + "N814", # Camelcase imported as constant + "N815", # Variable in class scope should not be mixedCase + "N816", # Variable in global scope should not be mixedCase + "N817", # CamelCase imported as acronym + "N818", # Error suffix in exception names + + # Pylint rules + "PLW0603", # global-statement + "PLW2901", # redefined-loop-name + "PLC0415", # import-outside-top-level + "PLR2004", # magic-value-comparison + + # Exception handling (broad-except, bare-except in pylint) + "BLE001", # Do not catch blind exception + + # Argument rules (unused-argument in pylint) + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG005", # Unused lambda argument + + # Access rules (protected-access in pylint) + "SLF001", # Private member accessed + + # String formatting (consider-using-f-string in pylint) + "UP032", # Use f-string instead of format call + + # Exception rules (broad-exception-raised in pylint) + "TRY301", # Abstract raise to an inner function + + # Builtin shadowing (redefined-builtin in pylint) + "A001", # Variable shadows a Python builtin + "A002", # Argument shadows a Python builtin + "A003", # Class attribute shadows a Python builtin +] + +[lint.per-file-ignores] +"**/anki/*_pb2.py" = ["ALL"] + +[lint.pep8-naming] +ignore-names = ["id", "tr", "db", "ok", "ip"] + +[lint.pylint] +max-args = 12 +max-returns = 10 +max-branches = 35 +max-statements = 125 + +[lint.isort] +known-first-party = ["anki", "aqt", "tests"] diff --git a/.version b/.version index 9bab2a4b4..6b856e54b 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.06 +25.08b5 diff --git a/.vscode.dist/extensions.json b/.vscode.dist/extensions.json index bb449cc57..eb13662d6 100644 --- a/.vscode.dist/extensions.json +++ b/.vscode.dist/extensions.json @@ -2,7 +2,7 @@ "recommendations": [ "dprint.dprint", "ms-python.python", - "ms-python.black-formatter", + "charliermarsh.ruff", "rust-lang.rust-analyzer", "svelte.svelte-vscode", "zxh404.vscode-proto3", diff --git a/.vscode.dist/settings.json b/.vscode.dist/settings.json index ab91e06ab..0da294c38 100644 --- a/.vscode.dist/settings.json +++ b/.vscode.dist/settings.json @@ -18,7 +18,7 @@ "out/qt", "qt" ], - "python.formatting.provider": "black", + "python.formatting.provider": "charliermarsh.ruff", "python.linting.mypyEnabled": false, "python.analysis.diagnosticSeverityOverrides": { "reportMissingModuleSource": "none" diff --git a/CLAUDE.md b/CLAUDE.md index 6ec6db642..fa58b805b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Please do this as a final step before marking a task as completed. During development, you can build/check subsections of our code: - Rust: 'cargo check' -- Python: './tools/dmypy' +- Python: './tools/dmypy', and if wheel-related, './ninja wheels' - TypeScript/Svelte: './ninja check:svelte' Be mindful that some changes (such as modifications to .proto files) may @@ -80,3 +80,7 @@ when possible. in rslib, use error/mod.rs's AnkiError/Result and snafu. In our other Rust modules, prefer anyhow + additional context where appropriate. Unwrapping in build scripts/tests is fine. + +## Individual preferences + +See @.claude/user.md diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 1070ffe22..183103e99 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -63,6 +63,7 @@ Jakub Kaczmarzyk Akshara Balachandra lukkea David Allison +David Allison <62114487+david-allison@users.noreply.github.com> Tsung-Han Yu Piotr Kubowicz RumovZ @@ -236,6 +237,10 @@ David Brenn Felix Kühne Matthis Ehrhardt Billy Julian Lesmana +Kevin Nakamura +Bradley Szoke +jcznk +Thomas Rixen ******************** diff --git a/Cargo.lock b/Cargo.lock index 66173027b..2d50d5388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ dependencies = [ "axum", "axum-client-ip", "axum-extra", + "bitflags 2.9.1", "blake3", "bytes", "chrono", @@ -117,7 +118,7 @@ dependencies = [ "id_tree", "inflections", "itertools 0.14.0", - "nom", + "nom 8.0.0", "num_cpus", "num_enum", "once_cell", @@ -129,7 +130,8 @@ dependencies = [ "prost", "prost-reflect", "pulldown-cmark 0.13.0", - "rand 0.9.1", + "rand 0.9.2", + "rayon", "regex", "reqwest 0.12.20", "rusqlite", @@ -142,7 +144,7 @@ dependencies = [ "serde_tuple", "sha1", "snafu", - "strum 0.27.1", + "strum 0.27.2", "syn 2.0.103", "tempfile", "tokio", @@ -218,7 +220,7 @@ dependencies = [ "prost-types", "serde", "snafu", - "strum 0.27.1", + "strum 0.27.2", ] [[package]] @@ -704,7 +706,7 @@ dependencies = [ "log", "num-traits", "portable-atomic-util", - "rand 0.9.1", + "rand 0.9.2", "rmp-serde", "serde", "serde_json", @@ -730,7 +732,7 @@ dependencies = [ "hashbrown 0.15.4", "log", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "serde", "spin 0.10.0", "text_placeholder", @@ -760,12 +762,12 @@ dependencies = [ "csv", "derive-new 0.7.0", "dirs 6.0.0", - "rand 0.9.1", + "rand 0.9.2", "rmp-serde", "sanitize-filename 0.6.0", "serde", "serde_json", - "strum 0.27.1", + "strum 0.27.2", "tempfile", "thiserror 2.0.12", ] @@ -815,7 +817,7 @@ dependencies = [ "num-traits", "paste", "portable-atomic-util", - "rand 0.9.1", + "rand 0.9.2", "seq-macro", "spin 0.10.0", ] @@ -863,7 +865,7 @@ dependencies = [ "half", "hashbrown 0.15.4", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", "serde", "serde_bytes", @@ -957,7 +959,7 @@ dependencies = [ "memmap2", "num-traits", "num_cpus", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", "rayon", "safetensors", @@ -1401,7 +1403,7 @@ dependencies = [ "log", "num-traits", "portable-atomic", - "rand 0.9.1", + "rand 0.9.2", "sanitize-filename 0.5.0", "serde", "serde_json", @@ -2212,20 +2214,20 @@ dependencies = [ [[package]] name = "fsrs" -version = "4.1.1" +version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f3a8c3df2c324ebab71461178fe8c1fe2d7373cf603f312b652befd026f06d" +checksum = "04954cc67c3c11ee342a2ee1f5222bf76d73f7772df08d37dc9a6cdd73c467eb" dependencies = [ "burn", "itertools 0.14.0", "log", "ndarray", "priority-queue", - "rand 0.9.1", + "rand 0.9.2", "rayon", "serde", "snafu", - "strum 0.27.1", + "strum 0.27.2", ] [[package]] @@ -2802,7 +2804,7 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", - "rand 0.9.1", + "rand 0.9.2", "rand_distr", "serde", ] @@ -3543,11 +3545,14 @@ dependencies = [ "anki_io", "anki_process", "anyhow", + "camino", "dirs 6.0.0", "embed-resource", "libc", "libc-stdhandle", - "winapi", + "serde_json", + "widestring", + "windows 0.61.3", ] [[package]] @@ -3653,7 +3658,7 @@ dependencies = [ "linkcheck", "regex", "reqwest 0.12.20", - "strum 0.27.1", + "strum 0.27.2", "tokio", ] @@ -4117,6 +4122,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "normpath" version = "1.3.0" @@ -5082,7 +5096,7 @@ dependencies = [ "bytes", "getrandom 0.3.3", "lru-slab", - "rand 0.9.1", + "rand 0.9.2", "ring", "rustc-hash 2.1.1", "rustls", @@ -5136,9 +5150,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -5189,7 +5203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.1", + "rand 0.9.2", ] [[package]] @@ -6099,9 +6113,9 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros 0.27.1", ] @@ -6258,7 +6272,7 @@ dependencies = [ "bytesize", "lazy_static", "libc", - "nom", + "nom 7.1.3", "time", "winapi", ] @@ -7365,6 +7379,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 980956b05..27d14ce8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,9 +33,8 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "4.1.1" +version = "5.1.0" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" -# rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f" # path = "../open-spaced-repetition/fsrs-rs" [workspace.dependencies] @@ -60,6 +59,7 @@ async-trait = "0.1.88" axum = { version = "0.8.4", features = ["multipart", "macros"] } axum-client-ip = "1.1.3" axum-extra = { version = "0.10.1", features = ["typed-header"] } +bitflags = "2.9.1" blake3 = "1.8.2" bytes = "1.10.1" camino = "1.1.10" @@ -93,7 +93,7 @@ junction = "1.2.0" libc = "0.2" libc-stdhandle = "0.1" maplit = "1.0.2" -nom = "7.1.3" +nom = "8.0.0" num-format = "0.4.4" num_cpus = "1.17.0" num_enum = "0.7.3" @@ -109,6 +109,7 @@ prost-types = "0.13" pulldown-cmark = "0.13.0" pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] } rand = "0.9.1" +rayon = "1.10.0" regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] } rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] } @@ -138,8 +139,9 @@ unic-ucd-category = "0.9.0" unicode-normalization = "0.1.24" walkdir = "2.5.0" which = "8.0.0" -winapi = { version = "0.3", features = ["wincon"] } -windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams"] } +widestring = "1.1.0" +winapi = { version = "0.3", features = ["wincon", "winreg"] } +windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams", "Win32_System_Console", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_Foundation", "Win32_UI_Shell", "Wdk_System_SystemServices"] } wiremock = "0.6.3" xz2 = "0.1.7" zip = { version = "4.1.0", default-features = false, features = ["deflate", "time"] } diff --git a/LICENSE b/LICENSE index 033dc2a0a..456a7cfd6 100644 --- a/LICENSE +++ b/LICENSE @@ -6,8 +6,6 @@ The following included source code items use a license other than AGPL3: In the pylib folder: - * The SuperMemo importer: GPL3 and 0BSD. - * The Pauker importer: BSD-3. * statsbg.py: CC BY 4.0. In the qt folder: diff --git a/README.md b/README.md index 3bdcc2db3..04d5603a7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Anki +# Anki® [![Build status](https://badge.buildkite.com/c9edf020a4aec976f9835e54751cc5409d843adbb66d043bd3.svg?branch=main)](https://buildkite.com/ankitects/anki-ci) diff --git a/build/configure/src/aqt.rs b/build/configure/src/aqt.rs index 60316fcd2..83be77e91 100644 --- a/build/configure/src/aqt.rs +++ b/build/configure/src/aqt.rs @@ -27,7 +27,6 @@ pub fn build_and_check_aqt(build: &mut Build) -> Result<()> { build_forms(build)?; build_generated_sources(build)?; build_data_folder(build)?; - build_macos_helper(build)?; build_wheel(build)?; check_python(build)?; Ok(()) @@ -39,7 +38,6 @@ fn build_forms(build: &mut Build) -> Result<()> { let mut py_files = vec![]; for path in ui_files.resolve() { let outpath = outdir.join(path.file_name().unwrap()).into_string(); - py_files.push(outpath.replace(".ui", "_qt5.py")); py_files.push(outpath.replace(".ui", "_qt6.py")); } build.add_action( @@ -337,27 +335,6 @@ impl BuildAction for BuildThemedIcon<'_> { } } -fn build_macos_helper(build: &mut Build) -> Result<()> { - if cfg!(target_os = "macos") { - build.add_action( - "qt:aqt:data:lib:libankihelper", - RunCommand { - command: ":pyenv:bin", - args: "$script $out $in", - inputs: hashmap! { - "script" => inputs!["qt/mac/helper_build.py"], - "in" => inputs![glob!["qt/mac/*.swift"]], - "" => inputs!["out/env"], - }, - outputs: hashmap! { - "out" => vec!["qt/_aqt/data/lib/libankihelper.dylib"], - }, - }, - )?; - } - Ok(()) -} - fn build_wheel(build: &mut Build) -> Result<()> { build.add_action( "wheels:aqt", @@ -365,7 +342,12 @@ fn build_wheel(build: &mut Build) -> Result<()> { name: "aqt", version: anki_version(), platform: None, - deps: inputs![":qt:aqt", glob!("qt/aqt/**"), "qt/pyproject.toml"], + deps: inputs![ + ":qt:aqt", + glob!("qt/aqt/**"), + "qt/pyproject.toml", + "qt/hatch_build.py" + ], }, ) } diff --git a/build/configure/src/pylib.rs b/build/configure/src/pylib.rs index bcef1ecc4..21820ae8b 100644 --- a/build/configure/src/pylib.rs +++ b/build/configure/src/pylib.rs @@ -68,7 +68,8 @@ pub fn build_pylib(build: &mut Build) -> Result<()> { deps: inputs![ ":pylib:anki", glob!("pylib/anki/**"), - "pylib/pyproject.toml" + "pylib/pyproject.toml", + "pylib/hatch_build.py" ], }, )?; diff --git a/build/configure/src/python.rs b/build/configure/src/python.rs index 7cd66b76e..e43bceeb3 100644 --- a/build/configure/src/python.rs +++ b/build/configure/src/python.rs @@ -1,34 +1,61 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::env; - use anyhow::Result; use ninja_gen::action::BuildAction; use ninja_gen::archives::Platform; use ninja_gen::build::FilesHandle; -use ninja_gen::command::RunCommand; use ninja_gen::copy::CopyFiles; use ninja_gen::glob; -use ninja_gen::hashmap; use ninja_gen::input::BuildInput; use ninja_gen::inputs; use ninja_gen::python::python_format; use ninja_gen::python::PythonEnvironment; -use ninja_gen::python::PythonLint; use ninja_gen::python::PythonTypecheck; -use ninja_gen::rsync::RsyncFiles; +use ninja_gen::python::RuffCheck; use ninja_gen::Build; +/// Normalize version string by removing leading zeros from numeric parts +/// while preserving pre-release markers (b1, rc2, a3, etc.) +fn normalize_version(version: &str) -> String { + version + .split('.') + .map(|part| { + // Check if the part contains only digits + if part.chars().all(|c| c.is_ascii_digit()) { + // Numeric part: remove leading zeros + part.parse::().unwrap_or(0).to_string() + } else { + // Mixed part (contains both numbers and pre-release markers) + // Split on first non-digit character and normalize the numeric prefix + let chars = part.chars(); + let mut numeric_prefix = String::new(); + let mut rest = String::new(); + let mut found_non_digit = false; + + for ch in chars { + if ch.is_ascii_digit() && !found_non_digit { + numeric_prefix.push(ch); + } else { + found_non_digit = true; + rest.push(ch); + } + } + + if numeric_prefix.is_empty() { + part.to_string() + } else { + let normalized_prefix = numeric_prefix.parse::().unwrap_or(0).to_string(); + format!("{normalized_prefix}{rest}") + } + } + }) + .collect::>() + .join(".") +} + pub fn setup_venv(build: &mut Build) -> Result<()> { - let extra_binary_exports = &[ - "mypy", - "black", - "isort", - "pylint", - "pytest", - "protoc-gen-mypy", - ]; + let extra_binary_exports = &["mypy", "ruff", "pytest", "protoc-gen-mypy"]; build.add_action( "pyenv", PythonEnvironment { @@ -96,7 +123,14 @@ impl BuildAction for BuildWheel { } fn files(&mut self, build: &mut impl FilesHandle) { - build.add_inputs("uv", inputs![":uv_binary"]); + if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { + let uv_path = + std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode"); + build.add_inputs("uv", inputs![uv_path]); + } else { + build.add_inputs("uv", inputs![":uv_binary"]); + } + build.add_inputs("", &self.deps); // Set the project directory based on which package we're building @@ -131,14 +165,7 @@ impl BuildAction for BuildWheel { let name = self.name; - // Normalize version like hatchling does: remove leading zeros from version - // parts - let normalized_version = self - .version - .split('.') - .map(|part| part.parse::().unwrap_or(0).to_string()) - .collect::>() - .join("."); + let normalized_version = normalize_version(&self.version); let wheel_path = format!("wheels/{name}-{normalized_version}-{tag}.whl"); build.add_outputs("out", vec![wheel_path]); @@ -168,60 +195,26 @@ pub fn check_python(build: &mut Build) -> Result<()> { }, )?; - add_pylint(build)?; - - Ok(()) -} - -fn add_pylint(build: &mut Build) -> Result<()> { - // pylint does not support PEP420 implicit namespaces split across import paths, - // so we need to merge our pylib sources and generated files before invoking it, - // and add a top-level __init__.py + let ruff_folders = &["qt/aqt", "ftl", "pylib/tools", "tools", "python"]; + let ruff_deps = inputs![ + glob!["{pylib,ftl,qt,python,tools}/**/*.py"], + ":pylib:anki", + ":qt:aqt" + ]; build.add_action( - "check:pylint:copy_pylib", - RsyncFiles { - inputs: inputs![":pylib:anki"], - target_folder: "pylint/anki", - strip_prefix: "$builddir/pylib/anki", - // avoid copying our large rsbridge binary - extra_args: "--links", + "check:ruff", + RuffCheck { + folders: ruff_folders, + deps: ruff_deps.clone(), + check_only: true, }, )?; build.add_action( - "check:pylint:copy_pylib", - RsyncFiles { - inputs: inputs![glob!["pylib/anki/**"]], - target_folder: "pylint/anki", - strip_prefix: "pylib/anki", - extra_args: "", - }, - )?; - build.add_action( - "check:pylint:copy_pylib", - RunCommand { - command: ":pyenv:bin", - args: "$script $out", - inputs: hashmap! { "script" => inputs!["python/mkempty.py"] }, - outputs: hashmap! { "out" => vec!["pylint/anki/__init__.py"] }, - }, - )?; - build.add_action( - "check:pylint", - PythonLint { - folders: &[ - "$builddir/pylint/anki", - "qt/aqt", - "ftl", - "pylib/tools", - "tools", - "python", - ], - pylint_ini: inputs![".pylintrc"], - deps: inputs![ - ":check:pylint:copy_pylib", - ":qt:aqt", - glob!("{pylib/tools,ftl,qt,python,tools}/**/*.py") - ], + "fix:ruff", + RuffCheck { + folders: ruff_folders, + deps: ruff_deps, + check_only: false, }, )?; @@ -234,15 +227,19 @@ struct Sphinx { impl BuildAction for Sphinx { fn command(&self) -> &str { - if env::var("OFFLINE_BUILD").is_err() { - "$uv sync --extra sphinx && $python python/sphinx/build.py" - } else { + if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { "$python python/sphinx/build.py" + } else { + "$uv sync --extra sphinx && $python python/sphinx/build.py" } } fn files(&mut self, build: &mut impl FilesHandle) { - if env::var("OFFLINE_BUILD").is_err() { + if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") { + let uv_path = + std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode"); + build.add_inputs("uv", inputs![uv_path]); + } else { build.add_inputs("uv", inputs![":uv_binary"]); // Set environment variable to use the existing pyenv build.add_variable("pyenv_path", "$builddir/pyenv"); @@ -279,3 +276,25 @@ pub(crate) fn setup_sphinx(build: &mut Build) -> Result<()> { )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_version_basic() { + assert_eq!(normalize_version("1.2.3"), "1.2.3"); + assert_eq!(normalize_version("01.02.03"), "1.2.3"); + assert_eq!(normalize_version("1.0.0"), "1.0.0"); + } + + #[test] + fn test_normalize_version_with_prerelease() { + assert_eq!(normalize_version("1.2.3b1"), "1.2.3b1"); + assert_eq!(normalize_version("01.02.03b1"), "1.2.3b1"); + assert_eq!(normalize_version("1.0.0rc2"), "1.0.0rc2"); + assert_eq!(normalize_version("2.1.0a3"), "2.1.0a3"); + assert_eq!(normalize_version("1.2.3beta1"), "1.2.3beta1"); + assert_eq!(normalize_version("1.2.3alpha1"), "1.2.3alpha1"); + } +} diff --git a/build/configure/src/rust.rs b/build/configure/src/rust.rs index 1ff0fc97c..758752fa6 100644 --- a/build/configure/src/rust.rs +++ b/build/configure/src/rust.rs @@ -169,7 +169,7 @@ fn build_rsbridge(build: &mut Build) -> Result<()> { pub fn check_rust(build: &mut Build) -> Result<()> { let inputs = inputs![ - glob!("{rslib/**,pylib/rsbridge/**,ftl/**,build/**,tools/workspace-hack/**}"), + glob!("{rslib/**,pylib/rsbridge/**,ftl/**,build/**,qt/launcher/**}"), "Cargo.lock", "Cargo.toml", "rust-toolchain.toml", diff --git a/build/ninja_gen/Cargo.toml b/build/ninja_gen/Cargo.toml index cacab6a7b..5e5a4f736 100644 --- a/build/ninja_gen/Cargo.toml +++ b/build/ninja_gen/Cargo.toml @@ -35,3 +35,7 @@ path = "src/bin/update_uv.rs" [[bin]] name = "update_protoc" path = "src/bin/update_protoc.rs" + +[[bin]] +name = "update_node" +path = "src/bin/update_node.rs" diff --git a/build/ninja_gen/src/bin/update_node.rs b/build/ninja_gen/src/bin/update_node.rs new file mode 100644 index 000000000..32dbf6d4a --- /dev/null +++ b/build/ninja_gen/src/bin/update_node.rs @@ -0,0 +1,268 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::error::Error; +use std::fs; +use std::path::Path; + +use regex::Regex; +use reqwest::blocking::Client; +use serde_json::Value; + +#[derive(Debug)] +struct NodeRelease { + version: String, + files: Vec, +} + +#[derive(Debug)] +struct NodeFile { + filename: String, + url: String, +} + +fn main() -> Result<(), Box> { + let release_info = fetch_node_release_info()?; + let new_text = generate_node_archive_function(&release_info)?; + update_node_text(&new_text)?; + println!("Node.js archive function updated successfully!"); + Ok(()) +} + +fn fetch_node_release_info() -> Result> { + let client = Client::new(); + + // Get the Node.js release info + let response = client + .get("https://nodejs.org/dist/index.json") + .header("User-Agent", "anki-build-updater") + .send()?; + + let releases: Vec = response.json()?; + + // Find the latest LTS release + let latest = releases + .iter() + .find(|release| { + // LTS releases have a non-false "lts" field + release["lts"].as_str().is_some() && release["lts"] != false + }) + .ok_or("No LTS releases found")?; + + let version = latest["version"] + .as_str() + .ok_or("Version not found")? + .to_string(); + + let files = latest["files"] + .as_array() + .ok_or("Files array not found")? + .iter() + .map(|f| f.as_str().unwrap_or("")) + .collect::>(); + + let lts_name = latest["lts"].as_str().unwrap_or("unknown"); + println!("Found Node.js LTS version: {version} ({lts_name})"); + + // Map platforms to their expected file keys and full filenames + let platform_mapping = vec![ + ( + "linux-x64", + "linux-x64", + format!("node-{version}-linux-x64.tar.xz"), + ), + ( + "linux-arm64", + "linux-arm64", + format!("node-{version}-linux-arm64.tar.xz"), + ), + ( + "darwin-x64", + "osx-x64-tar", + format!("node-{version}-darwin-x64.tar.xz"), + ), + ( + "darwin-arm64", + "osx-arm64-tar", + format!("node-{version}-darwin-arm64.tar.xz"), + ), + ( + "win-x64", + "win-x64-zip", + format!("node-{version}-win-x64.zip"), + ), + ( + "win-arm64", + "win-arm64-zip", + format!("node-{version}-win-arm64.zip"), + ), + ]; + + let mut node_files = Vec::new(); + + for (platform, file_key, filename) in platform_mapping { + // Check if this file exists in the release + if files.contains(&file_key) { + let url = format!("https://nodejs.org/dist/{version}/{filename}"); + node_files.push(NodeFile { + filename: filename.clone(), + url, + }); + println!("Found file for {platform}: {filename} (key: {file_key})"); + } else { + return Err( + format!("File not found for {platform} (key: {file_key}): {filename}").into(), + ); + } + } + + Ok(NodeRelease { + version, + files: node_files, + }) +} + +fn generate_node_archive_function(release: &NodeRelease) -> Result> { + let client = Client::new(); + + // Fetch the SHASUMS256.txt file once + println!("Fetching SHA256 checksums..."); + let shasums_url = format!("https://nodejs.org/dist/{}/SHASUMS256.txt", release.version); + let shasums_response = client + .get(&shasums_url) + .header("User-Agent", "anki-build-updater") + .send()?; + let shasums_text = shasums_response.text()?; + + // Create a mapping from filename patterns to platform names - using the exact + // patterns we stored in files + let platform_mapping = vec![ + ("linux-x64.tar.xz", "LinuxX64"), + ("linux-arm64.tar.xz", "LinuxArm"), + ("darwin-x64.tar.xz", "MacX64"), + ("darwin-arm64.tar.xz", "MacArm"), + ("win-x64.zip", "WindowsX64"), + ("win-arm64.zip", "WindowsArm"), + ]; + + let mut platform_blocks = Vec::new(); + + for (file_pattern, platform_name) in platform_mapping { + // Find the file that ends with this pattern + if let Some(file) = release + .files + .iter() + .find(|f| f.filename.ends_with(file_pattern)) + { + // Find the SHA256 for this file + let sha256 = shasums_text + .lines() + .find(|line| line.contains(&file.filename)) + .and_then(|line| line.split_whitespace().next()) + .ok_or_else(|| format!("SHA256 not found for {}", file.filename))?; + + println!( + "Found SHA256 for {}: {} => {}", + platform_name, file.filename, sha256 + ); + + let block = format!( + " Platform::{} => OnlineArchive {{\n url: \"{}\",\n sha256: \"{}\",\n }},", + platform_name, file.url, sha256 + ); + platform_blocks.push(block); + } else { + return Err(format!( + "File not found for platform {platform_name}: no file ending with {file_pattern}" + ) + .into()); + } + } + + let function = format!( + "pub fn node_archive(platform: Platform) -> OnlineArchive {{\n match platform {{\n{}\n }}\n}}", + platform_blocks.join("\n") + ); + + Ok(function) +} + +fn update_node_text(new_function: &str) -> Result<(), Box> { + let node_rs_content = read_node_rs()?; + + // Regex to match the entire node_archive function with proper multiline + // matching + let re = Regex::new( + r"(?s)pub fn node_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\s*\}\s*\n\s*\}", + )?; + + let updated_content = re.replace(&node_rs_content, new_function); + + write_node_rs(&updated_content)?; + Ok(()) +} + +fn read_node_rs() -> Result> { + // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set")?; + let path = Path::new(&manifest_dir).join("src").join("node.rs"); + Ok(fs::read_to_string(path)?) +} + +fn write_node_rs(content: &str) -> Result<(), Box> { + // Use CARGO_MANIFEST_DIR to get the crate root, then find src/node.rs + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set")?; + let path = Path::new(&manifest_dir).join("src").join("node.rs"); + fs::write(path, content)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_regex_replacement() { + let sample_content = r#"Some other code +pub fn node_archive(platform: Platform) -> OnlineArchive { + match platform { + Platform::LinuxX64 => OnlineArchive { + url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.xz", + sha256: "old_hash", + }, + Platform::MacX64 => OnlineArchive { + url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-x64.tar.xz", + sha256: "old_hash", + }, + } +} + +More code here"#; + + let new_function = r#"pub fn node_archive(platform: Platform) -> OnlineArchive { + match platform { + Platform::LinuxX64 => OnlineArchive { + url: "https://nodejs.org/dist/v21.0.0/node-v21.0.0-linux-x64.tar.xz", + sha256: "new_hash", + }, + Platform::MacX64 => OnlineArchive { + url: "https://nodejs.org/dist/v21.0.0/node-v21.0.0-darwin-x64.tar.xz", + sha256: "new_hash", + }, + } +}"#; + + let re = Regex::new( + r"(?s)pub fn node_archive\(platform: Platform\) -> OnlineArchive \{.*?\n\s*\}\s*\n\s*\}" + ).unwrap(); + + let result = re.replace(sample_content, new_function); + assert!(result.contains("v21.0.0")); + assert!(result.contains("new_hash")); + assert!(!result.contains("old_hash")); + assert!(result.contains("Some other code")); + assert!(result.contains("More code here")); + } +} diff --git a/build/ninja_gen/src/bin/update_protoc.rs b/build/ninja_gen/src/bin/update_protoc.rs index 224dbaa50..3a8f06b8b 100644 --- a/build/ninja_gen/src/bin/update_protoc.rs +++ b/build/ninja_gen/src/bin/update_protoc.rs @@ -72,12 +72,11 @@ fn fetch_protoc_release_info() -> Result> { "MacArm" => continue, // Skip MacArm since it's handled with MacX64 "WindowsX64" => "Platform::WindowsX64 | Platform::WindowsArm", "WindowsArm" => continue, // Skip WindowsArm since it's handled with WindowsX64 - _ => &format!("Platform::{}", platform), + _ => &format!("Platform::{platform}"), }; match_blocks.push(format!( - " {} => {{\n OnlineArchive {{\n url: \"{}\",\n sha256: \"{}\",\n }}\n }}", - match_pattern, download_url, sha256 + " {match_pattern} => {{\n OnlineArchive {{\n url: \"{download_url}\",\n sha256: \"{sha256}\",\n }}\n }}" )); } diff --git a/build/ninja_gen/src/bin/update_uv.rs b/build/ninja_gen/src/bin/update_uv.rs index 39cf87668..5a5d2d253 100644 --- a/build/ninja_gen/src/bin/update_uv.rs +++ b/build/ninja_gen/src/bin/update_uv.rs @@ -53,7 +53,7 @@ fn fetch_uv_release_info() -> Result> { // Find the corresponding .sha256 or .sha256sum asset let sha_asset = assets.iter().find(|a| { let name = a["name"].as_str().unwrap_or(""); - name == format!("{}.sha256", asset_name) || name == format!("{}.sha256sum", asset_name) + name == format!("{asset_name}.sha256") || name == format!("{asset_name}.sha256sum") }); if sha_asset.is_none() { eprintln!("No sha256 asset found for {asset_name}"); @@ -71,8 +71,7 @@ fn fetch_uv_release_info() -> Result> { let sha256 = sha_text.split_whitespace().next().unwrap_or(""); match_blocks.push(format!( - " Platform::{} => {{\n OnlineArchive {{\n url: \"{}\",\n sha256: \"{}\",\n }}\n }}", - platform, download_url, sha256 + " Platform::{platform} => {{\n OnlineArchive {{\n url: \"{download_url}\",\n sha256: \"{sha256}\",\n }}\n }}" )); } @@ -135,10 +134,7 @@ mod tests { assert_eq!( updated_lines, original_lines - EXPECTED_LINES_REMOVED, - "Expected line count to decrease by exactly {} lines (original: {}, updated: {})", - EXPECTED_LINES_REMOVED, - original_lines, - updated_lines + "Expected line count to decrease by exactly {EXPECTED_LINES_REMOVED} lines (original: {original_lines}, updated: {updated_lines})" ); } } diff --git a/build/ninja_gen/src/build.rs b/build/ninja_gen/src/build.rs index df8ec82fb..ed416b000 100644 --- a/build/ninja_gen/src/build.rs +++ b/build/ninja_gen/src/build.rs @@ -300,7 +300,7 @@ impl BuildStatement<'_> { writeln!(buf, "build {outputs_str}: {action_name} {inputs_str}").unwrap(); for (key, value) in self.variables.iter().sorted() { - writeln!(buf, " {key} = {}", value).unwrap(); + writeln!(buf, " {key} = {value}").unwrap(); } writeln!(buf).unwrap(); @@ -476,7 +476,7 @@ impl FilesHandle for BuildStatement<'_> { let outputs = outputs.into_iter().map(|v| { let v = v.as_ref(); let v = if !v.starts_with("$builddir/") && !v.starts_with("$builddir\\") { - format!("$builddir/{}", v) + format!("$builddir/{v}") } else { v.to_owned() }; diff --git a/build/ninja_gen/src/cargo.rs b/build/ninja_gen/src/cargo.rs index 645203170..2a3397704 100644 --- a/build/ninja_gen/src/cargo.rs +++ b/build/ninja_gen/src/cargo.rs @@ -162,7 +162,7 @@ impl BuildAction for CargoTest { "cargo-nextest", CargoInstall { binary_name: "cargo-nextest", - args: "cargo-nextest --version 0.9.57 --locked", + args: "cargo-nextest --version 0.9.99 --locked --no-default-features --features default-no-update", }, )?; setup_flags(build) diff --git a/build/ninja_gen/src/node.rs b/build/ninja_gen/src/node.rs index 10b3e6184..b7b66225b 100644 --- a/build/ninja_gen/src/node.rs +++ b/build/ninja_gen/src/node.rs @@ -19,28 +19,28 @@ use crate::input::BuildInput; pub fn node_archive(platform: Platform) -> OnlineArchive { match platform { Platform::LinuxX64 => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.xz", - sha256: "822780369d0ea309e7d218e41debbd1a03f8cdf354ebf8a4420e89f39cc2e612", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz", + sha256: "325c0f1261e0c61bcae369a1274028e9cfb7ab7949c05512c5b1e630f7e80e12", }, Platform::LinuxArm => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-arm64.tar.xz", - sha256: "f6df68c6793244071f69023a9b43a0cf0b13d65cbe86d55925c28e4134d9aafb", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-arm64.tar.xz", + sha256: "140aee84be6774f5fb3f404be72adbe8420b523f824de82daeb5ab218dab7b18", }, Platform::MacX64 => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-x64.tar.xz", - sha256: "d4b4ab81ebf1f7aab09714f834992f27270ad0079600da00c8110f8950ca6c5a", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-x64.tar.xz", + sha256: "f79de1f64df4ac68493a344bb5ab7d289d0275271e87b543d1278392c9de778a", }, Platform::MacArm => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-darwin-arm64.tar.xz", - sha256: "f18a7438723d48417f5e9be211a2f3c0520ffbf8e02703469e5153137ca0f328", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-darwin-arm64.tar.xz", + sha256: "cc9cc294eaf782dd93c8c51f460da610cc35753c6a9947411731524d16e97914", }, Platform::WindowsX64 => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-win-x64.zip", - sha256: "893115cd92ad27bf178802f15247115e93c0ef0c753b93dca96439240d64feb5", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-x64.zip", + sha256: "721ab118a3aac8584348b132767eadf51379e0616f0db802cc1e66d7f0d98f85", }, Platform::WindowsArm => OnlineArchive { - url: "https://nodejs.org/dist/v20.11.0/node-v20.11.0-win-arm64.zip", - sha256: "89c1f7034dcd6ff5c17f2af61232a96162a1902f862078347dcf274a938b6142", + url: "https://nodejs.org/dist/v22.17.0/node-v22.17.0-win-arm64.zip", + sha256: "78355dc9ca117bb71d3f081e4b1b281855e2b134f3939bb0ca314f7567b0e621", }, } } diff --git a/build/ninja_gen/src/python.rs b/build/ninja_gen/src/python.rs index 7ac65e85f..541d6c96e 100644 --- a/build/ninja_gen/src/python.rs +++ b/build/ninja_gen/src/python.rs @@ -148,7 +148,7 @@ impl BuildAction for PythonEnvironment { // Add --python flag to extra_args if PYTHON_BINARY is set let mut args = self.extra_args.to_string(); if let Ok(python_binary) = env::var("PYTHON_BINARY") { - args = format!("--python {} {}", python_binary, args); + args = format!("--python {python_binary} {args}"); } build.add_variable("extra_args", args); } @@ -159,6 +159,10 @@ impl BuildAction for PythonEnvironment { } build.add_output_stamp(format!("{}/.stamp", self.venv_folder)); } + + fn check_output_timestamps(&self) -> bool { + true + } } pub struct PythonTypecheck { @@ -189,31 +193,19 @@ impl BuildAction for PythonTypecheck { struct PythonFormat<'a> { pub inputs: &'a BuildInput, pub check_only: bool, - pub isort_ini: &'a BuildInput, } impl BuildAction for PythonFormat<'_> { fn command(&self) -> &str { - "$black -t py39 -q $check --color $in && $ - $isort --color --settings-path $isort_ini $check $in" + "$ruff format $mode $in && $ruff check --select I --fix $in" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("in", self.inputs); - build.add_inputs("black", inputs![":pyenv:black"]); - build.add_inputs("isort", inputs![":pyenv:isort"]); + build.add_inputs("ruff", inputs![":pyenv:ruff"]); let hash = simple_hash(self.inputs); - build.add_env_var("BLACK_CACHE_DIR", "out/python/black.cache.{hash}"); - build.add_inputs("isort_ini", self.isort_ini); - build.add_variable( - "check", - if self.check_only { - "--diff --check" - } else { - "" - }, - ); + build.add_variable("mode", if self.check_only { "--check" } else { "" }); build.add_output_stamp(format!( "tests/python_format.{}.{hash}", @@ -223,13 +215,11 @@ impl BuildAction for PythonFormat<'_> { } pub fn python_format(build: &mut Build, group: &str, inputs: BuildInput) -> Result<()> { - let isort_ini = &inputs![".isort.cfg"]; build.add_action( format!("check:format:python:{group}"), PythonFormat { inputs: &inputs, check_only: true, - isort_ini, }, )?; @@ -238,34 +228,39 @@ pub fn python_format(build: &mut Build, group: &str, inputs: BuildInput) -> Resu PythonFormat { inputs: &inputs, check_only: false, - isort_ini, }, )?; Ok(()) } -pub struct PythonLint { +pub struct RuffCheck { pub folders: &'static [&'static str], - pub pylint_ini: BuildInput, pub deps: BuildInput, + pub check_only: bool, } -impl BuildAction for PythonLint { +impl BuildAction for RuffCheck { fn command(&self) -> &str { - "$pylint --rcfile $pylint_ini -sn -j $cpus $folders" + "$ruff check $folders $mode" } fn files(&mut self, build: &mut impl crate::build::FilesHandle) { build.add_inputs("", &self.deps); - build.add_inputs("pylint", inputs![":pyenv:pylint"]); - build.add_inputs("pylint_ini", &self.pylint_ini); + build.add_inputs("", inputs![".ruff.toml"]); + build.add_inputs("ruff", inputs![":pyenv:ruff"]); build.add_variable("folders", self.folders.join(" ")); - // On a 16 core system, values above 10 do not improve wall clock time, - // but waste extra cores that could be working on other tests. - build.add_variable("cpus", num_cpus::get().min(10).to_string()); + build.add_variable( + "mode", + if self.check_only { + "" + } else { + "--fix --unsafe-fixes" + }, + ); let hash = simple_hash(&self.deps); - build.add_output_stamp(format!("tests/python_lint.{hash}")); + let kind = if self.check_only { "check" } else { "fix" }; + build.add_output_stamp(format!("tests/python_ruff.{kind}.{hash}")); } } diff --git a/build/ninja_gen/src/render.rs b/build/ninja_gen/src/render.rs index a9a32cf84..dde307e73 100644 --- a/build/ninja_gen/src/render.rs +++ b/build/ninja_gen/src/render.rs @@ -30,12 +30,12 @@ impl Build { ) .unwrap(); for (key, value) in &self.variables { - writeln!(&mut buf, "{} = {}", key, value).unwrap(); + writeln!(&mut buf, "{key} = {value}").unwrap(); } buf.push('\n'); for (key, value) in &self.pools { - writeln!(&mut buf, "pool {}\n depth = {}", key, value).unwrap(); + writeln!(&mut buf, "pool {key}\n depth = {value}").unwrap(); } buf.push('\n'); diff --git a/build/runner/src/archive.rs b/build/runner/src/archive.rs index 8a78dd515..932b924e1 100644 --- a/build/runner/src/archive.rs +++ b/build/runner/src/archive.rs @@ -65,7 +65,7 @@ fn sha2_data(data: &[u8]) -> String { let mut digest = sha2::Sha256::new(); digest.update(data); let result = digest.finalize(); - format!("{:x}", result) + format!("{result:x}") } enum CompressionKind { diff --git a/build/runner/src/build.rs b/build/runner/src/build.rs index 5e3042aba..107be9783 100644 --- a/build/runner/src/build.rs +++ b/build/runner/src/build.rs @@ -67,7 +67,10 @@ pub fn run_build(args: BuildArgs) { "MYPY_CACHE_DIR", build_root.join("tests").join("mypy").into_string(), ) - .env("PYTHONPYCACHEPREFIX", build_root.join("pycache")) + .env( + "PYTHONPYCACHEPREFIX", + std::path::absolute(build_root.join("pycache")).unwrap(), + ) // commands will not show colors by default, as we do not provide a tty .env("FORCE_COLOR", "1") .env("MYPY_FORCE_COLOR", "1") @@ -135,7 +138,7 @@ fn setup_build_root() -> Utf8PathBuf { true }; if create { - println!("Switching build root to {}", new_target); + println!("Switching build root to {new_target}"); std::os::unix::fs::symlink(new_target, build_root).unwrap(); } } diff --git a/build/runner/src/pyenv.rs b/build/runner/src/pyenv.rs index 0bd5ec662..efd58fd91 100644 --- a/build/runner/src/pyenv.rs +++ b/build/runner/src/pyenv.rs @@ -32,10 +32,19 @@ pub fn setup_pyenv(args: PyenvArgs) { } } + let mut command = Command::new(args.uv_bin); + + // remove UV_* environment variables to avoid interference + for (key, _) in std::env::vars() { + if key.starts_with("UV_") || key == "VIRTUAL_ENV" { + command.env_remove(key); + } + } + run_command( - Command::new(args.uv_bin) + command .env("UV_PROJECT_ENVIRONMENT", args.pyenv_folder.clone()) - .args(["sync", "--frozen"]) + .args(["sync", "--locked", "--no-config"]) .args(args.extra_args), ); diff --git a/build/runner/src/run.rs b/build/runner/src/run.rs index bff88eb97..fd8877cd9 100644 --- a/build/runner/src/run.rs +++ b/build/runner/src/run.rs @@ -83,7 +83,7 @@ fn split_args(args: Vec) -> Vec> { pub fn run_command(command: &mut Command) { if let Err(err) = command.ensure_success() { - println!("{}", err); + println!("{err}"); std::process::exit(1); } } diff --git a/cargo/licenses.json b/cargo/licenses.json index c16b20aa7..92713c098 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1450,7 +1450,7 @@ }, { "name": "fsrs", - "version": "4.1.1", + "version": "5.1.0", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", @@ -2645,6 +2645,15 @@ "license_file": null, "description": "A byte-oriented, zero-copy, parser combinators library" }, + { + "name": "nom", + "version": "8.0.0", + "authors": "contact@geoffroycouprie.com", + "repository": "https://github.com/rust-bakery/nom", + "license": "MIT", + "license_file": null, + "description": "A byte-oriented, zero-copy, parser combinators library" + }, { "name": "ntapi", "version": "0.4.1", @@ -3313,7 +3322,7 @@ }, { "name": "rand", - "version": "0.9.1", + "version": "0.9.2", "authors": "The Rand Project Developers|The Rust Project Developers", "repository": "https://github.com/rust-random/rand", "license": "Apache-2.0 OR MIT", @@ -4123,7 +4132,7 @@ }, { "name": "strum", - "version": "0.27.1", + "version": "0.27.2", "authors": "Peter Glotfelty ", "repository": "https://github.com/Peternator7/strum", "license": "MIT", diff --git a/docs/development.md b/docs/development.md index c963aec02..defe9ef1e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,7 @@ When formatting issues are reported, they can be fixed with ./ninja format ``` -## Fixing eslint/copyright header issues +## Fixing ruff/eslint/copyright header issues ``` ./ninja fix diff --git a/docs/linux.md b/docs/linux.md index 27e3ceeda..55794e074 100644 --- a/docs/linux.md +++ b/docs/linux.md @@ -51,13 +51,8 @@ Anki requires a recent glibc. If you are using a distro that uses musl, Anki will not work. -If your glibc version is 2.35+ on AMD64 or 2.39+ on ARM64, you can skip the rest of this section. - -If your system has an older glibc, you won't be able to use the PyQt wheels that are -available in pip/PyPy, and will need to use your system-installed PyQt instead. -Your distro will also need to have Python 3.9 or later. - -After installing the system libraries (eg: +You can use your system's Qt libraries if they are Qt 6.2 or later, if +you wish. After installing the system libraries (eg: 'sudo apt install python3-pyqt6.qt{quick,webengine} python3-venv pyqt6-dev-tools'), find the place they are installed (eg '/usr/lib/python3/dist-packages'). On modern Ubuntu, you'll also need 'sudo apt remove python3-protobuf'. Then before running any commands like './run', tell Anki where @@ -68,12 +63,6 @@ export PYTHONPATH=/usr/lib/python3/dist-packages export PYTHON_BINARY=/usr/bin/python3 ``` -There are a few things to be aware of: - -- You should use ./run and not tools/run-qt5\*, even if your system libraries are Qt5. -- If your system libraries are Qt5, when creating an aqt wheel, the wheel will not work - on Qt6 environments. - ## Packaging considerations Python, node and protoc are downloaded as part of the build. You can optionally define diff --git a/docs/protobuf.md b/docs/protobuf.md index 29094fc65..75796b473 100644 --- a/docs/protobuf.md +++ b/docs/protobuf.md @@ -98,12 +98,6 @@ should preferably be assigned a number between 1 and 15. If a message contains Protobuf has an official Python implementation with an extensive [reference](https://developers.google.com/protocol-buffers/docs/reference/python-generated). -- Every message used in aqt or pylib must be added to the respective `.pylintrc` - to avoid failing type checks. The unqualified protobuf message's name must be - used, not an alias from `collection.py` for example. This should be taken into - account when choosing a message name in order to prevent skipping typechecking - a Python class of the same name. - ### Typescript Anki uses [protobuf-es](https://github.com/bufbuild/protobuf-es), which offers diff --git a/ftl/core-repo b/ftl/core-repo index 2f8c9d956..a599715d3 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit 2f8c9d9566aef8b86e3326fe9ff007d594b7ec83 +Subproject commit a599715d3c27ff2eb895c749f3534ab73d83dad1 diff --git a/ftl/core/card-templates.ftl b/ftl/core/card-templates.ftl index 7ecda1968..edb2433f9 100644 --- a/ftl/core/card-templates.ftl +++ b/ftl/core/card-templates.ftl @@ -60,7 +60,6 @@ card-templates-this-will-create-card-proceed = } card-templates-type-boxes-warning = Only one typing box per card template is supported. card-templates-restore-to-default = Restore to Default -card-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default - values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed? +card-templates-restore-to-default-confirmation = This will reset all fields and templates in this note type to their default values, removing any extra fields/templates and their content, and any custom styling. Do you wish to proceed? card-templates-restored-to-default = Note type has been restored to its original state. 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/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 286e6bae8..5154f44c1 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -425,6 +425,8 @@ deck-config-desired-retention-tooltip = less frequently, and you will forget more of them. Be conservative when adjusting this - higher values will greatly increase your workload, and lower values can be demoralizing when you forget a lot of material. +deck-config-desired-retention-tooltip2 = + The workload values provided by the info box are a rough approximation. For a greater level of accuracy, use the simulator. deck-config-historical-retention-tooltip = When some of your review history is missing, FSRS needs to fill in the gaps. By default, it will assume that when you did those old reviews, you remembered 90% of the material. If your old retention @@ -503,7 +505,9 @@ deck-config-desired-retention-below-optimal = Your desired retention is below op # Description of the y axis in the FSRS simulation # diagram (Deck options -> FSRS) showing the total number of # cards that can be recalled or retrieved on a specific date. -deck-config-fsrs-simulator-experimental = FSRS simulator (experimental) +deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental) +deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental) +deck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental) deck-config-additional-new-cards-to-simulate = Additional new cards to simulate deck-config-simulate = Simulate deck-config-clear-last-simulate = Clear Last Simulation @@ -512,10 +516,14 @@ deck-config-advanced-settings = Advanced Settings 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? # 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. deck-config-fsrs-simulator-radio-memorized = Memorized +deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio +# $time here is pre-formatted e.g. "10 Seconds" +deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card ## Messages related to the FSRS scheduler’s health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function. @@ -536,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/ftl/core/importing.ftl b/ftl/core/importing.ftl index 70bc5f4d1..b23d12654 100644 --- a/ftl/core/importing.ftl +++ b/ftl/core/importing.ftl @@ -48,6 +48,7 @@ importing-merge-notetypes-help = Warning: This will require a one-way sync, and may mark existing notes as modified. importing-mnemosyne-20-deck-db = Mnemosyne 2.0 Deck (*.db) importing-multicharacter-separators-are-not-supported-please = Multi-character separators are not supported. Please enter one character only. +importing-new-deck-will-be-created = A new deck will be created: { $name } importing-notes-added-from-file = Notes added from file: { $val } importing-notes-found-in-file = Notes found in file: { $val } importing-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copies are already in your collection: { $val } @@ -65,7 +66,6 @@ importing-with-deck-configs-help = If enabled, any deck options that the deck sharer included will also be imported. Otherwise, all decks will be assigned the default preset. importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip) -importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz) # the '|' character importing-pipe = Pipe # Warning displayed when the csv import preview table is clipped (some columns were hidden) @@ -78,7 +78,6 @@ importing-rows-had-num1d-fields-expected-num2d = '{ $row }' had { $found } field importing-selected-file-was-not-in-utf8 = Selected file was not in UTF-8 format. Please see the importing section of the manual. importing-semicolon = Semicolon importing-skipped = Skipped -importing-supermemo-xml-export-xml = Supermemo XML export (*.xml) importing-tab = Tab importing-tag-modified-notes = Tag modified notes: importing-text-separated-by-tabs-or-semicolons = Text separated by tabs or semicolons (*) @@ -252,3 +251,5 @@ importing-importing-collection = Importing collection... importing-unable-to-import-filename = Unable to import { $filename }: file type not supported importing-notes-that-could-not-be-imported = Notes that could not be imported as note type has changed: { $val } importing-added = Added +importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz) +importing-supermemo-xml-export-xml = Supermemo XML export (*.xml) diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 75420fa63..e7a20b36c 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -34,7 +34,7 @@ preferences-when-adding-default-to-current-deck = When adding, default to curren preferences-you-can-restore-backups-via-fileswitch = You can restore backups via File > Switch Profile. preferences-legacy-timezone-handling = Legacy timezone handling (buggy, but required for AnkiDroid <= 2.14) preferences-default-search-text = Default search text -preferences-default-search-text-example = eg. 'deck:current ' +preferences-default-search-text-example = e.g. "deck:current" preferences-theme = Theme preferences-theme-follow-system = Follow System preferences-theme-light = Light diff --git a/ftl/core/statistics.ftl b/ftl/core/statistics.ftl index c3a2bb613..c5551ef67 100644 --- a/ftl/core/statistics.ftl +++ b/ftl/core/statistics.ftl @@ -80,7 +80,7 @@ statistics-reviews = # This fragment of the tooltip in the FSRS simulation # diagram (Deck options -> FSRS) shows the total number of # cards that can be recalled or retrieved on a specific date. -statistics-memorized = {$memorized} memorized +statistics-memorized = {$memorized} cards memorized statistics-today-title = Today statistics-today-again-count = Again count: statistics-today-type-counts = Learn: { $learnCount }, Review: { $reviewCount }, Relearn: { $relearnCount }, Filtered: { $filteredCount } @@ -99,9 +99,9 @@ statistics-counts-relearning-cards = Relearning statistics-counts-title = Card Counts statistics-counts-separate-suspended-buried-cards = Separate suspended/buried cards -## True Retention represents your actual retention rate from past reviews, in -## comparison to the "desired retention" parameter of FSRS, which forecasts -## future retention. True Retention is the percentage of all reviewed cards +## Retention represents your actual retention from past reviews, in +## comparison to the "desired retention" setting of FSRS, which forecasts +## future retention. Retention is the percentage of all reviewed cards ## that were marked as "Hard," "Good," or "Easy" within a specific time period. ## ## Most of these strings are used as column / row headings in a table. @@ -112,9 +112,9 @@ statistics-counts-separate-suspended-buried-cards = Separate suspended/buried ca ## N.B. Stats cards may be very small on mobile devices and when the Stats ## window is certain sizes. -statistics-true-retention-title = True Retention +statistics-true-retention-title = Retention statistics-true-retention-subtitle = Pass rate of cards with an interval ≥ 1 day. -statistics-true-retention-tooltip = If you are using FSRS, your true retention is expected to be close to your desired retention. Please keep in mind that data for a single day is noisy, so it's better to look at monthly data. +statistics-true-retention-tooltip = If you are using FSRS, your retention is expected to be close to your desired retention. Please keep in mind that data for a single day is noisy, so it's better to look at monthly data. statistics-true-retention-range = Range statistics-true-retention-pass = Pass statistics-true-retention-fail = Fail diff --git a/ftl/qt-repo b/ftl/qt-repo index 69f2dbaeb..bb4207f3b 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit 69f2dbaeba6f72ac62da0b35881f320603da5124 +Subproject commit bb4207f3b8e9a7c428db282d12c75b850be532f3 diff --git a/ftl/qt/qt-accel.ftl b/ftl/qt/qt-accel.ftl index 327cd6c46..3ab54eb24 100644 --- a/ftl/qt/qt-accel.ftl +++ b/ftl/qt/qt-accel.ftl @@ -1,4 +1,5 @@ qt-accel-about = &About +qt-accel-about-mac = About Anki... qt-accel-cards = &Cards qt-accel-check-database = &Check Database qt-accel-check-media = Check &Media @@ -45,3 +46,4 @@ qt-accel-zoom-editor-in = Zoom Editor &In qt-accel-zoom-editor-out = Zoom Editor &Out qt-accel-create-backup = Create &Backup qt-accel-load-backup = &Revert to Backup +qt-accel-upgrade-downgrade = Upgrade/Downgrade diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl index 294cd8a83..d7bbef990 100644 --- a/ftl/qt/qt-misc.ftl +++ b/ftl/qt/qt-misc.ftl @@ -73,6 +73,7 @@ qt-misc-second = qt-misc-layout-auto-enabled = Responsive layout enabled qt-misc-layout-vertical-enabled = Vertical layout enabled qt-misc-layout-horizontal-enabled = Horizontal layout enabled +qt-misc-open-anki-launcher = Change to a different Anki version? ## deprecated- these strings will be removed in the future, and do not need ## to be translated diff --git a/ftl/src/serialize.rs b/ftl/src/serialize.rs index c6eda559c..73513df69 100644 --- a/ftl/src/serialize.rs +++ b/ftl/src/serialize.rs @@ -435,7 +435,7 @@ impl TextWriter { item = item.trim_start_matches(' '); } - write!(self.buffer, "{}", item) + write!(self.buffer, "{item}") } fn write_char_into_indent(&mut self, ch: char) { diff --git a/ftl/src/string/mod.rs b/ftl/src/string/mod.rs index 1b64dd91c..e7bf2c5bd 100644 --- a/ftl/src/string/mod.rs +++ b/ftl/src/string/mod.rs @@ -67,7 +67,7 @@ fn additional_template_folder(dst_folder: &Utf8Path) -> Option { fn all_langs(lang_folder: &Utf8Path) -> Result> { std::fs::read_dir(lang_folder) - .with_context(|| format!("reading {:?}", lang_folder))? + .with_context(|| format!("reading {lang_folder:?}"))? .filter_map(Result::ok) .map(|e| Ok(e.path().utf8()?)) .collect() diff --git a/package.json b/package.json index d08655bad..9f12133db 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "@poppanator/sveltekit-svg": "^5.0.0", "@sqltools/formatter": "^1.2.2", "@sveltejs/adapter-static": "^3.0.0", - "@sveltejs/kit": "^2.20.7", - "@sveltejs/vite-plugin-svelte": "4.0.0", + "@sveltejs/kit": "^2.22.2", + "@sveltejs/vite-plugin-svelte": "5.1", "@types/bootstrap": "^5.0.12", "@types/codemirror": "^5.60.0", "@types/d3": "^7.0.0", @@ -30,7 +30,7 @@ "@types/jqueryui": "^1.12.13", "@types/lodash-es": "^4.17.4", "@types/marked": "^5.0.0", - "@types/node": "^20", + "@types/node": "^22", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", "caniuse-lite": "^1.0.30001431", @@ -48,16 +48,16 @@ "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.2", "sass": "<1.77", - "svelte": "^5.17.3", - "svelte-check": "^3.4.4", - "svelte-preprocess": "^5.0.4", + "svelte": "^5.34.9", + "svelte-check": "^4.2.2", + "svelte-preprocess": "^6.0.3", "svelte-preprocess-esbuild": "^3.0.1", "svgo": "^3.2.0", "tslib": "^2.0.3", - "tsx": "^3.12.0", + "tsx": "^4.8.1", "typescript": "^5.0.4", - "vite": "5.4.19", - "vitest": "^2" + "vite": "6", + "vitest": "^3" }, "dependencies": { "@bufbuild/protobuf": "^1.2.1", @@ -81,7 +81,8 @@ }, "resolutions": { "canvas": "npm:empty-npm-package@1.0.0", - "cookie": "0.7.0" + "cookie": "0.7.0", + "vite": "6" }, "browserslist": [ "defaults", 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/proto/anki/config.proto b/proto/anki/config.proto index d61f139d6..ea115f0fc 100644 --- a/proto/anki/config.proto +++ b/proto/anki/config.proto @@ -56,6 +56,7 @@ message ConfigKey { RENDER_LATEX = 25; LOAD_BALANCER_ENABLED = 26; FSRS_SHORT_TERM_WITH_STEPS_ENABLED = 27; + FSRS_LEGACY_EVALUATE = 28; } enum String { SET_DUE_BROWSER = 0; diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 831283931..5ed02423e 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -40,12 +40,10 @@ message DeckConfigId { message GetRetentionWorkloadRequest { repeated float w = 1; string search = 2; - float before = 3; - float after = 4; } message GetRetentionWorkloadResponse { - float factor = 1; + map costs = 1; } message GetIgnoredBeforeCountRequest { @@ -219,6 +217,8 @@ message DeckConfigsForUpdate { bool review_today_active = 5; // Whether new_today applies to today or a past day. bool new_today_active = 6; + // Deck-specific desired retention override + optional float desired_retention = 7; } string name = 1; int64 config_id = 2; @@ -236,6 +236,7 @@ message DeckConfigsForUpdate { bool new_cards_ignore_review_limit = 7; bool fsrs = 8; bool fsrs_health_check = 11; + bool fsrs_legacy_evaluate = 12; bool apply_all_parent_limits = 9; uint32 days_since_last_fsrs_optimize = 10; } diff --git a/proto/anki/decks.proto b/proto/anki/decks.proto index bcd206b06..b244eb4a1 100644 --- a/proto/anki/decks.proto +++ b/proto/anki/decks.proto @@ -83,6 +83,8 @@ message Deck { optional uint32 new_limit = 7; DayLimit review_limit_today = 8; DayLimit new_limit_today = 9; + // Deck-specific desired retention override + optional float desired_retention = 10; reserved 12 to 15; } diff --git a/proto/anki/import_export.proto b/proto/anki/import_export.proto index 88a7ad163..3273a57bb 100644 --- a/proto/anki/import_export.proto +++ b/proto/anki/import_export.proto @@ -176,9 +176,12 @@ message CsvMetadata { // to determine the number of columns. repeated string column_labels = 5; oneof deck { + // id of an existing deck int64 deck_id = 6; // One-based. 0 means n/a. uint32 deck_column = 7; + // name of new deck to be created + string deck_name = 17; } oneof notetype { // One notetype for all rows with given column mapping. diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index ea483d3db..34b350642 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -55,7 +55,11 @@ service SchedulerService { returns (ComputeOptimalRetentionResponse); rpc SimulateFsrsReview(SimulateFsrsReviewRequest) returns (SimulateFsrsReviewResponse); + rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest) + returns (SimulateFsrsWorkloadResponse); rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse); + rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest) + returns (EvaluateParamsResponse); rpc ComputeMemoryState(cards.CardId) returns (ComputeMemoryStateResponse); // The number of days the calculated interval was fuzzed by on the previous // review (if any). Utilized by the FSRS add-on. @@ -402,6 +406,9 @@ message SimulateFsrsReviewRequest { repeated float easy_days_percentages = 10; deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11; optional uint32 suspend_after_lapse_count = 12; + float historical_retention = 13; + uint32 learning_step_count = 14; + uint32 relearning_step_count = 15; } message SimulateFsrsReviewResponse { @@ -411,6 +418,12 @@ message SimulateFsrsReviewResponse { repeated float daily_time_cost = 4; } +message SimulateFsrsWorkloadResponse { + map cost = 1; + map memorized = 2; + map review_count = 3; +} + message ComputeOptimalRetentionResponse { float optimal_retention = 1; } @@ -442,6 +455,12 @@ message EvaluateParamsRequest { uint32 num_of_relearning_steps = 3; } +message EvaluateParamsLegacyRequest { + repeated float params = 1; + string search = 2; + int64 ignore_revlogs_before_ms = 3; +} + message EvaluateParamsResponse { float log_loss = 1; float rmse_bins = 2; @@ -450,6 +469,7 @@ message EvaluateParamsResponse { message ComputeMemoryStateResponse { optional cards.FsrsMemoryState state = 1; float desired_retention = 2; + float decay = 3; } message FuzzDeltaRequest { diff --git a/pylib/anki/_backend.py b/pylib/anki/_backend.py index a0d8f8949..9a68cf644 100644 --- a/pylib/anki/_backend.py +++ b/pylib/anki/_backend.py @@ -46,7 +46,6 @@ from .errors import ( # the following comment is required to suppress a warning that only shows up # when there are other pylint failures -# pylint: disable=c-extension-no-member if _rsbridge.buildhash() != anki.buildinfo.buildhash: raise Exception( f"""rsbridge and anki build hashes do not match: @@ -164,7 +163,7 @@ class RustBackend(RustBackendGenerated): finally: elapsed = time.time() - start if current_thread() is main_thread() and elapsed > 0.2: - print(f"blocked main thread for {int(elapsed*1000)}ms:") + print(f"blocked main thread for {int(elapsed * 1000)}ms:") print("".join(traceback.format_stack())) err = backend_pb2.BackendError() @@ -247,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/pylib/anki/cards.py b/pylib/anki/cards.py index 093776ebb..b8154510e 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -7,7 +7,7 @@ import pprint import time from typing import NewType -import anki # pylint: disable=unused-import +import anki import anki.collection import anki.decks import anki.notes @@ -49,6 +49,7 @@ class Card(DeprecatedNamesMixin): memory_state: FSRSMemoryState | None desired_retention: float | None decay: float | None + last_review_time: int | None def __init__( self, @@ -103,6 +104,11 @@ class Card(DeprecatedNamesMixin): card.desired_retention if card.HasField("desired_retention") else None ) self.decay = card.decay if card.HasField("decay") else None + self.last_review_time = ( + card.last_review_time_secs + if card.HasField("last_review_time_secs") + else None + ) def _to_backend_card(self) -> cards_pb2.Card: # mtime & usn are set by backend @@ -127,6 +133,7 @@ class Card(DeprecatedNamesMixin): memory_state=self.memory_state, desired_retention=self.desired_retention, decay=self.decay, + last_review_time_secs=self.last_review_time, ) @deprecated(info="please use col.update_card()") diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 17ee08e2f..c64ffdb8b 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -122,6 +122,7 @@ class ComputedMemoryState: desired_retention: float stability: float | None = None difficulty: float | None = None + decay: float | None = None @dataclass @@ -157,7 +158,7 @@ class Collection(DeprecatedNamesMixin): self.tags = TagManager(self) self.conf = ConfigManager(self) self._load_scheduler() - self._startReps = 0 # pylint: disable=invalid-name + self._startReps = 0 def name(self) -> Any: return os.path.splitext(os.path.basename(self.path))[0] @@ -510,9 +511,7 @@ class Collection(DeprecatedNamesMixin): # Utils ########################################################################## - def nextID( # pylint: disable=invalid-name - self, type: str, inc: bool = True - ) -> Any: + def nextID(self, type: str, inc: bool = True) -> Any: type = f"next{type.capitalize()}" id = self.conf.get(type, 1) if inc: @@ -848,7 +847,6 @@ class Collection(DeprecatedNamesMixin): ) def _pb_search_separator(self, operator: SearchJoiner) -> SearchNode.Group.Joiner.V: - # pylint: disable=no-member if operator == "AND": return SearchNode.Group.Joiner.AND else: @@ -866,7 +864,9 @@ class Collection(DeprecatedNamesMixin): return column return None - def browser_row_for_id(self, id_: int) -> tuple[ + def browser_row_for_id( + self, id_: int + ) -> tuple[ Generator[tuple[str, bool, BrowserRow.Cell.TextElideMode.V], None, None], BrowserRow.Color.V, str, @@ -1189,9 +1189,13 @@ class Collection(DeprecatedNamesMixin): desired_retention=resp.desired_retention, stability=resp.state.stability, difficulty=resp.state.difficulty, + decay=resp.decay, ) else: - return ComputedMemoryState(desired_retention=resp.desired_retention) + return ComputedMemoryState( + desired_retention=resp.desired_retention, + decay=resp.decay, + ) def fuzz_delta(self, card_id: CardId, interval: int) -> int: "The delta days of fuzz applied if reviewing the card in v3." @@ -1207,8 +1211,6 @@ class Collection(DeprecatedNamesMixin): # the count on things like edits, which we probably could do by checking # the previous state in moveToState. - # pylint: disable=invalid-name - def startTimebox(self) -> None: self._startTime = time.time() self._startReps = self.sched.reps diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py index 43713d8b2..ef6f02c63 100644 --- a/pylib/anki/exporting.py +++ b/pylib/anki/exporting.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations @@ -351,7 +350,7 @@ class AnkiPackageExporter(AnkiExporter): colfile = path.replace(".apkg", ".anki2") AnkiExporter.exportInto(self, colfile) # prevent older clients from accessing - # pylint: disable=unreachable + self._addDummyCollection(z) z.write(colfile, "collection.anki21") diff --git a/pylib/anki/find.py b/pylib/anki/find.py index bcae6e556..106bf2876 100644 --- a/pylib/anki/find.py +++ b/pylib/anki/find.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations diff --git a/pylib/anki/hooks.py b/pylib/anki/hooks.py index fcc3758f4..13148c649 100644 --- a/pylib/anki/hooks.py +++ b/pylib/anki/hooks.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name """ Tools for extending Anki. diff --git a/pylib/anki/importing/__init__.py b/pylib/anki/importing/__init__.py index cfc2cac3f..d4fccc643 100644 --- a/pylib/anki/importing/__init__.py +++ b/pylib/anki/importing/__init__.py @@ -11,8 +11,6 @@ from anki.importing.apkg import AnkiPackageImporter from anki.importing.base import Importer from anki.importing.csvfile import TextImporter from anki.importing.mnemo import MnemosyneImporter -from anki.importing.pauker import PaukerImporter -from anki.importing.supermemo_xml import SupermemoXmlImporter # type: ignore from anki.lang import TR @@ -24,8 +22,6 @@ def importers(col: Collection) -> Sequence[tuple[str, type[Importer]]]: AnkiPackageImporter, ), (col.tr.importing_mnemosyne_20_deck_db(), MnemosyneImporter), - (col.tr.importing_supermemo_xml_export_xml(), SupermemoXmlImporter), - (col.tr.importing_pauker_18_lesson_paugz(), PaukerImporter), ] anki.hooks.importing_importers(importers) return importers diff --git a/pylib/anki/importing/anki2.py b/pylib/anki/importing/anki2.py index 098265c3f..dcfa15c8d 100644 --- a/pylib/anki/importing/anki2.py +++ b/pylib/anki/importing/anki2.py @@ -1,7 +1,7 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name + from __future__ import annotations import os diff --git a/pylib/anki/importing/apkg.py b/pylib/anki/importing/apkg.py index ea2325960..012686ffa 100644 --- a/pylib/anki/importing/apkg.py +++ b/pylib/anki/importing/apkg.py @@ -1,7 +1,7 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name + from __future__ import annotations import json diff --git a/pylib/anki/importing/base.py b/pylib/anki/importing/base.py index 2ddcaaebf..fc27dc909 100644 --- a/pylib/anki/importing/base.py +++ b/pylib/anki/importing/base.py @@ -1,7 +1,7 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name + from __future__ import annotations from typing import Any diff --git a/pylib/anki/importing/csvfile.py b/pylib/anki/importing/csvfile.py index 6a2ed347c..fde7ec8ac 100644 --- a/pylib/anki/importing/csvfile.py +++ b/pylib/anki/importing/csvfile.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations @@ -144,7 +143,6 @@ class TextImporter(NoteImporter): self.close() zuper = super() if hasattr(zuper, "__del__"): - # pylint: disable=no-member zuper.__del__(self) # type: ignore def noteFromFields(self, fields: list[str]) -> ForeignNote: diff --git a/pylib/anki/importing/mnemo.py b/pylib/anki/importing/mnemo.py index 5b7fda65f..a2f68ad4c 100644 --- a/pylib/anki/importing/mnemo.py +++ b/pylib/anki/importing/mnemo.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name import re import time @@ -35,7 +34,6 @@ f._id=d._fact_id""" ): if id != curid: if note: - # pylint: disable=unsubscriptable-object notes[note["_id"]] = note note = {"_id": _id} curid = id @@ -185,7 +183,6 @@ acq_reps+ret_reps, lapses, card_type_id from cards""" state = dict(n=1) def repl(match): - # pylint: disable=cell-var-from-loop # replace [...] with cloze refs res = "{{c%d::%s}}" % (state["n"], match.group(1)) state["n"] += 1 diff --git a/pylib/anki/importing/noteimp.py b/pylib/anki/importing/noteimp.py index f827a525a..cb35a373a 100644 --- a/pylib/anki/importing/noteimp.py +++ b/pylib/anki/importing/noteimp.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations diff --git a/pylib/anki/importing/pauker.py b/pylib/anki/importing/pauker.py deleted file mode 100644 index ea5c45082..000000000 --- a/pylib/anki/importing/pauker.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright: Andreas Klauer -# License: BSD-3 - -# pylint: disable=invalid-name - -import gzip -import html -import math -import random -import time -import xml.etree.ElementTree as ET - -from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter -from anki.stdmodels import _legacy_add_forward_reverse - -ONE_DAY = 60 * 60 * 24 - - -class PaukerImporter(NoteImporter): - """Import Pauker 1.8 Lesson (*.pau.gz)""" - - needMapper = False - allowHTML = True - - def run(self): - model = _legacy_add_forward_reverse(self.col) - model["name"] = "Pauker" - self.col.models.save(model, updateReqs=False) - self.col.models.set_current(model) - self.model = model - self.initMapping() - NoteImporter.run(self) - - def fields(self): - """Pauker is Front/Back""" - return 2 - - def foreignNotes(self): - """Build and return a list of notes.""" - notes = [] - - try: - f = gzip.open(self.file) - tree = ET.parse(f) # type: ignore - lesson = tree.getroot() - assert lesson.tag == "Lesson" - finally: - f.close() - - index = -4 - - for batch in lesson.findall("./Batch"): - index += 1 - - for card in batch.findall("./Card"): - # Create a note for this card. - front = card.findtext("./FrontSide/Text") - back = card.findtext("./ReverseSide/Text") - note = ForeignNote() - assert front and back - note.fields = [ - html.escape(x.strip()) - .replace("\n", "
") - .replace(" ", "  ") - for x in [front, back] - ] - notes.append(note) - - # Determine due date for cards. - frontdue = card.find("./FrontSide[@LearnedTimestamp]") - backdue = card.find("./ReverseSide[@Batch][@LearnedTimestamp]") - - if frontdue is not None: - note.cards[0] = self._learnedCard( - index, int(frontdue.attrib["LearnedTimestamp"]) - ) - - if backdue is not None: - note.cards[1] = self._learnedCard( - int(backdue.attrib["Batch"]), - int(backdue.attrib["LearnedTimestamp"]), - ) - - return notes - - def _learnedCard(self, batch, timestamp): - ivl = math.exp(batch) - now = time.time() - due = ivl - (now - timestamp / 1000.0) / ONE_DAY - fc = ForeignCard() - fc.due = self.col.sched.today + int(due + 0.5) - fc.ivl = random.randint(int(ivl * 0.90), int(ivl + 0.5)) - fc.factor = random.randint(1500, 2500) - return fc diff --git a/pylib/anki/importing/supermemo_xml.py b/pylib/anki/importing/supermemo_xml.py deleted file mode 100644 index 202592c2e..000000000 --- a/pylib/anki/importing/supermemo_xml.py +++ /dev/null @@ -1,484 +0,0 @@ -# Copyright: petr.michalec@gmail.com -# License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pytype: disable=attribute-error -# type: ignore -# pylint: disable=C -from __future__ import annotations - -import re -import sys -import time -import unicodedata -from string import capwords -from xml.dom import minidom -from xml.dom.minidom import Element, Text - -from anki.collection import Collection -from anki.importing.noteimp import ForeignCard, ForeignNote, NoteImporter -from anki.stdmodels import _legacy_add_basic_model - - -class SmartDict(dict): - """ - See http://www.peterbe.com/plog/SmartDict - Copyright 2005, Peter Bengtsson, peter@fry-it.com - 0BSD - - A smart dict can be instantiated either from a pythonic dict - or an instance object (eg. SQL recordsets) but it ensures that you can - do all the convenient lookups such as x.first_name, x['first_name'] or - x.get('first_name'). - """ - - def __init__(self, *a, **kw) -> None: - if a: - if isinstance(type(a[0]), dict): - kw.update(a[0]) - elif isinstance(type(a[0]), object): - kw.update(a[0].__dict__) - elif hasattr(a[0], "__class__") and a[0].__class__.__name__ == "SmartDict": - kw.update(a[0].__dict__) - - dict.__init__(self, **kw) - self.__dict__ = self - - -class SuperMemoElement(SmartDict): - "SmartDict wrapper to store SM Element data" - - def __init__(self, *a, **kw) -> None: - SmartDict.__init__(self, *a, **kw) - # default content - self.__dict__["lTitle"] = None - self.__dict__["Title"] = None - self.__dict__["Question"] = None - self.__dict__["Answer"] = None - self.__dict__["Count"] = None - self.__dict__["Type"] = None - self.__dict__["ID"] = None - self.__dict__["Interval"] = None - self.__dict__["Lapses"] = None - self.__dict__["Repetitions"] = None - self.__dict__["LastRepetiton"] = None - self.__dict__["AFactor"] = None - self.__dict__["UFactor"] = None - - -# This is an AnkiImporter -class SupermemoXmlImporter(NoteImporter): - needMapper = False - allowHTML = True - - """ - Supermemo XML export's to Anki parser. - Goes through a SM collection and fetch all elements. - - My SM collection was a big mess where topics and items were mixed. - I was unable to parse my content in a regular way like for loop on - minidom.getElementsByTagName() etc. My collection had also an - limitation, topics were splited into branches with max 100 items - on each. Learning themes were in deep structure. I wanted to have - full title on each element to be stored in tags. - - Code should be upgrade to support importing of SM2006 exports. - """ - - def __init__(self, col: Collection, file: str) -> None: - """Initialize internal variables. - Pameters to be exposed to GUI are stored in self.META""" - NoteImporter.__init__(self, col, file) - m = _legacy_add_basic_model(self.col) - m["name"] = "Supermemo" - self.col.models.save(m) - self.initMapping() - - self.lines = None - self.numFields = int(2) - - # SmXmlParse VARIABLES - self.xmldoc = None - self.pieces = [] - self.cntBuf = [] # to store last parsed data - self.cntElm = [] # to store SM Elements data - self.cntCol = [] # to store SM Colections data - - # store some meta info related to parse algorithm - # SmartDict works like dict / class wrapper - self.cntMeta = SmartDict() - self.cntMeta.popTitles = False - self.cntMeta.title = [] - - # META stores controls of import script, should be - # exposed to import dialog. These are default values. - self.META = SmartDict() - self.META.resetLearningData = False # implemented - self.META.onlyMemorizedItems = False # implemented - self.META.loggerLevel = 2 # implemented 0no,1info,2error,3debug - self.META.tagAllTopics = True - self.META.pathsToBeTagged = [ - "English for beginners", - "Advanced English 97", - "Phrasal Verbs", - ] # path patterns to be tagged - in gui entered like 'Advanced English 97|My Vocablary' - self.META.tagMemorizedItems = True # implemented - self.META.logToStdOutput = False # implemented - - self.notes = [] - - ## TOOLS - - def _fudgeText(self, text: str) -> str: - "Replace sm syntax to Anki syntax" - text = text.replace("\n\r", "
") - text = text.replace("\n", "
") - return text - - def _unicode2ascii(self, str: str) -> str: - "Remove diacritic punctuation from strings (titles)" - return "".join( - [ - c - for c in unicodedata.normalize("NFKD", str) - if not unicodedata.combining(c) - ] - ) - - def _decode_htmlescapes(self, html: str) -> str: - """Unescape HTML code.""" - # In case of bad formatted html you can import MinimalSoup etc.. see BeautifulSoup source code - from bs4 import BeautifulSoup - - # my sm2004 also ecaped & char in escaped sequences. - html = re.sub("&", "&", html) - - # https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx - if html.find(">") < 0: - return html - - # unescaped solitary chars < or > that were ok for minidom confuse btfl soup - # html = re.sub(u'>',u'>',html) - # html = re.sub(u'<',u'<',html) - - return str(BeautifulSoup(html, "html.parser")) - - def _afactor2efactor(self, af: float) -> float: - # Adapted from - - # Ranges for A-factors and E-factors - af_min = 1.2 - af_max = 6.9 - ef_min = 1.3 - ef_max = 3.3 - - # Sanity checks for the A-factor - if af < af_min: - af = af_min - elif af > af_max: - af = af_max - - # Scale af to the range 0..1 - af_scaled = (af - af_min) / (af_max - af_min) - # Rescale to the interval ef_min..ef_max - ef = ef_min + af_scaled * (ef_max - ef_min) - - return ef - - ## DEFAULT IMPORTER METHODS - - def foreignNotes(self) -> list[ForeignNote]: - # Load file and parse it by minidom - self.loadSource(self.file) - - # Migrating content / time consuming part - # addItemToCards is called for each sm element - self.logger("Parsing started.") - self.parse() - self.logger("Parsing done.") - - # Return imported cards - self.total = len(self.notes) - self.log.append("%d cards imported." % self.total) - return self.notes - - def fields(self) -> int: - return 2 - - ## PARSER METHODS - - def addItemToCards(self, item: SuperMemoElement) -> None: - "This method actually do conversion" - - # new anki card - note = ForeignNote() - - # clean Q and A - note.fields.append(self._fudgeText(self._decode_htmlescapes(item.Question))) - note.fields.append(self._fudgeText(self._decode_htmlescapes(item.Answer))) - note.tags = [] - - # pre-process scheduling data - # convert learning data - if ( - not self.META.resetLearningData - and int(item.Interval) >= 1 - and getattr(item, "LastRepetition", None) - ): - # migration of LearningData algorithm - tLastrep = time.mktime(time.strptime(item.LastRepetition, "%d.%m.%Y")) - tToday = time.time() - card = ForeignCard() - card.ivl = int(item.Interval) - card.lapses = int(item.Lapses) - card.reps = int(item.Repetitions) + int(item.Lapses) - nextDue = tLastrep + (float(item.Interval) * 86400.0) - remDays = int((nextDue - time.time()) / 86400) - card.due = self.col.sched.today + remDays - card.factor = int( - self._afactor2efactor(float(item.AFactor.replace(",", "."))) * 1000 - ) - note.cards[0] = card - - # categories & tags - # it's worth to have every theme (tree structure of sm collection) stored in tags, but sometimes not - # you can deceide if you are going to tag all toppics or just that containing some pattern - tTaggTitle = False - for pattern in self.META.pathsToBeTagged: - if ( - item.lTitle is not None - and pattern.lower() in " ".join(item.lTitle).lower() - ): - tTaggTitle = True - break - if tTaggTitle or self.META.tagAllTopics: - # normalize - remove diacritic punctuation from unicode chars to ascii - item.lTitle = [self._unicode2ascii(topic) for topic in item.lTitle] - - # Transform xyz / aaa / bbb / ccc on Title path to Tag xyzAaaBbbCcc - # clean things like [999] or [111-2222] from title path, example: xyz / [1000-1200] zyx / xyz - # clean whitespaces - # set Capital letters for first char of the word - tmp = list( - {re.sub(r"(\[[0-9]+\])", " ", i).replace("_", " ") for i in item.lTitle} - ) - tmp = list({re.sub(r"(\W)", " ", i) for i in tmp}) - tmp = list({re.sub("^[0-9 ]+$", "", i) for i in tmp}) - tmp = list({capwords(i).replace(" ", "") for i in tmp}) - tags = [j[0].lower() + j[1:] for j in tmp if j.strip() != ""] - - note.tags += tags - - if self.META.tagMemorizedItems and int(item.Interval) > 0: - note.tags.append("Memorized") - - self.logger("Element tags\t- " + repr(note.tags), level=3) - - self.notes.append(note) - - def logger(self, text: str, level: int = 1) -> None: - "Wrapper for Anki logger" - - dLevels = {0: "", 1: "Info", 2: "Verbose", 3: "Debug"} - if level <= self.META.loggerLevel: - # self.deck.updateProgress(_(text)) - - if self.META.logToStdOutput: - print( - self.__class__.__name__ - + " - " - + dLevels[level].ljust(9) - + " -\t" - + text - ) - - # OPEN AND LOAD - def openAnything(self, source): - """Open any source / actually only opening of files is used - @return an open handle which must be closed after use, i.e., handle.close()""" - - if source == "-": - return sys.stdin - - # try to open with urllib (if source is http, ftp, or file URL) - import urllib.error - import urllib.parse - import urllib.request - - try: - return urllib.request.urlopen(source) - except OSError: - pass - - # try to open with native open function (if source is pathname) - try: - return open(source, encoding="utf8") - except OSError: - pass - - # treat source as string - import io - - return io.StringIO(str(source)) - - def loadSource(self, source: str) -> None: - """Load source file and parse with xml.dom.minidom""" - self.source = source - self.logger("Load started...") - sock = open(self.source, encoding="utf8") - self.xmldoc = minidom.parse(sock).documentElement - sock.close() - self.logger("Load done.") - - # PARSE - def parse(self, node: Text | Element | None = None) -> None: - "Parse method - parses document elements" - - if node is None and self.xmldoc is not None: - node = self.xmldoc - - _method = "parse_%s" % node.__class__.__name__ - if hasattr(self, _method): - parseMethod = getattr(self, _method) - parseMethod(node) - else: - self.logger("No handler for method %s" % _method, level=3) - - def parse_Document(self, node): - "Parse XML document" - - self.parse(node.documentElement) - - def parse_Element(self, node: Element) -> None: - "Parse XML element" - - _method = "do_%s" % node.tagName - if hasattr(self, _method): - handlerMethod = getattr(self, _method) - handlerMethod(node) - else: - self.logger("No handler for method %s" % _method, level=3) - # print traceback.print_exc() - - def parse_Text(self, node: Text) -> None: - "Parse text inside elements. Text is stored into local buffer." - - text = node.data - self.cntBuf.append(text) - - # def parse_Comment(self, node): - # """ - # Source can contain XML comments, but we ignore them - # """ - # pass - - # DO - def do_SuperMemoCollection(self, node: Element) -> None: - "Process SM Collection" - - for child in node.childNodes: - self.parse(child) - - def do_SuperMemoElement(self, node: Element) -> None: - "Process SM Element (Type - Title,Topics)" - - self.logger("=" * 45, level=3) - - self.cntElm.append(SuperMemoElement()) - self.cntElm[-1]["lTitle"] = self.cntMeta["title"] - - # parse all child elements - for child in node.childNodes: - self.parse(child) - - # strip all saved strings, just for sure - for key in list(self.cntElm[-1].keys()): - if hasattr(self.cntElm[-1][key], "strip"): - self.cntElm[-1][key] = self.cntElm[-1][key].strip() - - # pop current element - smel = self.cntElm.pop() - - # Process cntElm if is valid Item (and not an Topic etc..) - # if smel.Lapses != None and smel.Interval != None and smel.Question != None and smel.Answer != None: - if smel.Title is None and smel.Question is not None and smel.Answer is not None: - if smel.Answer.strip() != "" and smel.Question.strip() != "": - # migrate only memorized otherway skip/continue - if self.META.onlyMemorizedItems and not (int(smel.Interval) > 0): - self.logger("Element skipped \t- not memorized ...", level=3) - else: - # import sm element data to Anki - self.addItemToCards(smel) - self.logger("Import element \t- " + smel["Question"], level=3) - - # print element - self.logger("-" * 45, level=3) - for key in list(smel.keys()): - self.logger( - "\t{} {}".format((key + ":").ljust(15), smel[key]), level=3 - ) - else: - self.logger("Element skipped \t- no valid Q and A ...", level=3) - - else: - # now we know that item was topic - # parsing of whole node is now finished - - # test if it's really topic - if smel.Title is not None: - # remove topic from title list - t = self.cntMeta["title"].pop() - self.logger("End of topic \t- %s" % (t), level=2) - - def do_Content(self, node: Element) -> None: - "Process SM element Content" - - for child in node.childNodes: - if hasattr(child, "tagName") and child.firstChild is not None: - self.cntElm[-1][child.tagName] = child.firstChild.data - - def do_LearningData(self, node: Element) -> None: - "Process SM element LearningData" - - for child in node.childNodes: - if hasattr(child, "tagName") and child.firstChild is not None: - self.cntElm[-1][child.tagName] = child.firstChild.data - - # It's being processed in do_Content now - # def do_Question(self, node): - # for child in node.childNodes: self.parse(child) - # self.cntElm[-1][node.tagName]=self.cntBuf.pop() - - # It's being processed in do_Content now - # def do_Answer(self, node): - # for child in node.childNodes: self.parse(child) - # self.cntElm[-1][node.tagName]=self.cntBuf.pop() - - def do_Title(self, node: Element) -> None: - "Process SM element Title" - - t = self._decode_htmlescapes(node.firstChild.data) - self.cntElm[-1][node.tagName] = t - self.cntMeta["title"].append(t) - self.cntElm[-1]["lTitle"] = self.cntMeta["title"] - self.logger("Start of topic \t- " + " / ".join(self.cntMeta["title"]), level=2) - - def do_Type(self, node: Element) -> None: - "Process SM element Type" - - if len(self.cntBuf) >= 1: - self.cntElm[-1][node.tagName] = self.cntBuf.pop() - - -# if __name__ == '__main__': - -# for testing you can start it standalone - -# file = u'/home/epcim/hg2g/dev/python/sm2anki/ADVENG2EXP.xxe.esc.zaloha_FINAL.xml' -# file = u'/home/epcim/hg2g/dev/python/anki/libanki/tests/importing/supermemo/original_ENGLISHFORBEGGINERS_noOEM.xml' -# file = u'/home/epcim/hg2g/dev/python/anki/libanki/tests/importing/supermemo/original_ENGLISHFORBEGGINERS_oem_1250.xml' -# file = str(sys.argv[1]) -# impo = SupermemoXmlImporter(Deck(),file) -# impo.foreignCards() - -# sys.exit(1) - -# vim: ts=4 sts=2 ft=python diff --git a/pylib/anki/lang.py b/pylib/anki/lang.py index a0a6bf757..b639b0416 100644 --- a/pylib/anki/lang.py +++ b/pylib/anki/lang.py @@ -73,6 +73,7 @@ langs = sorted( ("ଓଡ଼ିଆ", "or_OR"), ("Filipino", "tl"), ("ئۇيغۇر", "ug"), + ("Oʻzbekcha", "uz_UZ"), ] ) @@ -123,6 +124,7 @@ compatMap = { "th": "th_TH", "tr": "tr_TR", "uk": "uk_UA", + "uz": "uz_UZ", "vi": "vi_VN", } @@ -157,13 +159,13 @@ def lang_to_disk_lang(lang: str) -> str: # the currently set interface language -current_lang = "en" # pylint: disable=invalid-name +current_lang = "en" # the current Fluent translation instance. Code in pylib/ should # not reference this, and should use col.tr instead. The global # instance exists for legacy reasons, and as a convenience for the # Qt code. -current_i18n: anki._backend.RustBackend | None = None # pylint: disable=invalid-name +current_i18n: anki._backend.RustBackend | None = None tr_legacyglobal = anki._backend.Translations(None) @@ -178,7 +180,7 @@ def ngettext(single: str, plural: str, num: int) -> str: def set_lang(lang: str) -> None: - global current_lang, current_i18n # pylint: disable=invalid-name + global current_lang, current_i18n current_lang = lang current_i18n = anki._backend.RustBackend(langs=[lang]) tr_legacyglobal.backend = weakref.ref(current_i18n) @@ -198,9 +200,7 @@ def get_def_lang(user_lang: str | None = None) -> tuple[int, str]: # getdefaultlocale() is deprecated since Python 3.11, but we need to keep using it as getlocale() behaves differently: https://bugs.python.org/issue38805 with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - (sys_lang, enc) = ( - locale.getdefaultlocale() # pylint: disable=deprecated-method - ) + (sys_lang, enc) = locale.getdefaultlocale() except AttributeError: # this will return a different format on Windows (e.g. Italian_Italy), resulting in us falling back to en_US # further below diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 230084359..a2267663a 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -10,7 +10,7 @@ import time from collections.abc import Sequence from typing import Any, NewType, Union -import anki # pylint: disable=unused-import +import anki import anki.collection import anki.notes from anki import notetypes_pb2 @@ -419,7 +419,7 @@ and notes.mid = ? and cards.ord = ?""", # legacy API - used by unit tests and add-ons - def change( # pylint: disable=invalid-name + def change( self, notetype: NotetypeDict, nids: list[anki.notes.NoteId], @@ -478,8 +478,6 @@ and notes.mid = ? and cards.ord = ?""", # Legacy ########################################################################## - # pylint: disable=invalid-name - @deprecated(info="use note.cloze_numbers_in_fields()") def _availClozeOrds( self, notetype: NotetypeDict, flds: str, allow_empty: bool = True diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index 5de95bfb6..3d09d5632 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -7,7 +7,7 @@ import copy from collections.abc import Sequence from typing import NewType -import anki # pylint: disable=unused-import +import anki import anki.cards import anki.collection import anki.decks diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 297c1b6d2..093712fca 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -4,10 +4,8 @@ # The backend code has moved into _backend; this file exists only to avoid breaking # some add-ons. They should be updated to point to the correct location in the # future. -# -# pylint: disable=unused-import -# pylint: enable=invalid-name +# ruff: noqa: F401 from anki.decks import DeckTreeNode from anki.errors import InvalidInput, NotFoundError from anki.lang import TR diff --git a/pylib/anki/scheduler/dummy.py b/pylib/anki/scheduler/dummy.py index 5732ad346..08896b1e5 100644 --- a/pylib/anki/scheduler/dummy.py +++ b/pylib/anki/scheduler/dummy.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations diff --git a/pylib/anki/scheduler/legacy.py b/pylib/anki/scheduler/legacy.py index 58bed7933..35092588d 100644 --- a/pylib/anki/scheduler/legacy.py +++ b/pylib/anki/scheduler/legacy.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name from __future__ import annotations diff --git a/pylib/anki/scheduler/v3.py b/pylib/anki/scheduler/v3.py index 2a18ee021..3c1123d0b 100644 --- a/pylib/anki/scheduler/v3.py +++ b/pylib/anki/scheduler/v3.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=invalid-name """ The V3/2021 scheduler. @@ -184,7 +183,7 @@ class Scheduler(SchedulerBaseWithLegacy): return self._interval_for_filtered_state(state.filtered) else: assert_exhaustive(kind) - return 0 # pylint: disable=unreachable + return 0 def _interval_for_normal_state( self, normal: scheduler_pb2.SchedulingState.Normal @@ -200,7 +199,7 @@ class Scheduler(SchedulerBaseWithLegacy): return normal.relearning.learning.scheduled_secs else: assert_exhaustive(kind) - return 0 # pylint: disable=unreachable + return 0 def _interval_for_filtered_state( self, filtered: scheduler_pb2.SchedulingState.Filtered @@ -212,7 +211,7 @@ class Scheduler(SchedulerBaseWithLegacy): return self._interval_for_normal_state(filtered.rescheduling.original_state) else: assert_exhaustive(kind) - return 0 # pylint: disable=unreachable + return 0 def nextIvl(self, card: Card, ease: int) -> Any: "Don't use this - it is only required by tests, and will be moved in the future." diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py index e6ca1cb97..e8045decb 100644 --- a/pylib/anki/stats.py +++ b/pylib/anki/stats.py @@ -1,7 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -# pylint: disable=C from __future__ import annotations @@ -27,7 +26,7 @@ def _legacy_card_stats( col: anki.collection.Collection, card_id: anki.cards.CardId, include_revlog: bool ) -> str: "A quick hack to preserve compatibility with the old HTML string API." - random_id = f"cardinfo-{base62(random.randint(0, 2 ** 64 - 1))}" + random_id = f"cardinfo-{base62(random.randint(0, 2**64 - 1))}" varName = random_id.replace("-", "") return f"""
@@ -324,7 +323,6 @@ group by day order by day""" yaxes=[dict(min=0), dict(position="right", min=0)], ) if days is not None: - # pylint: disable=invalid-unary-operand-type conf["xaxis"]["min"] = -days + 0.5 def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str: @@ -359,7 +357,6 @@ group by day order by day""" yaxes=[dict(min=0), dict(position="right", min=0)], ) if days is not None: - # pylint: disable=invalid-unary-operand-type conf["xaxis"]["min"] = -days + 0.5 def plot(id: str, data: Any, ylabel: str, ylabel2: str) -> str: diff --git a/pylib/anki/statsbg.py b/pylib/anki/statsbg.py index 552dfb5a9..b9ebb5aa8 100644 --- a/pylib/anki/statsbg.py +++ b/pylib/anki/statsbg.py @@ -1,5 +1,3 @@ -# pylint: disable=invalid-name - # from subtlepatterns.com; CC BY 4.0. # by Daniel Beaton # https://www.toptal.com/designers/subtlepatterns/fancy-deboss/ diff --git a/pylib/anki/stdmodels.py b/pylib/anki/stdmodels.py index 721b96bc6..4edb83a7a 100644 --- a/pylib/anki/stdmodels.py +++ b/pylib/anki/stdmodels.py @@ -12,7 +12,6 @@ from anki import notetypes_pb2 from anki._legacy import DeprecatedNamesMixinForModule from anki.utils import from_json_bytes -# pylint: disable=no-member StockNotetypeKind = notetypes_pb2.StockNotetype.Kind # add-on authors can add ("note type name", function) diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index a54aa7901..0c0338b82 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -16,7 +16,7 @@ import re from collections.abc import Collection, Sequence from typing import Match -import anki # pylint: disable=unused-import +import anki import anki.collection from anki import tags_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated diff --git a/pylib/anki/utils.py b/pylib/anki/utils.py index 46daa3b97..60ae75507 100644 --- a/pylib/anki/utils.py +++ b/pylib/anki/utils.py @@ -24,7 +24,6 @@ from anki.dbproxy import DBProxy _tmpdir: str | None try: - # pylint: disable=c-extension-no-member import orjson to_json_bytes: Callable[[Any], bytes] = orjson.dumps @@ -156,12 +155,12 @@ def field_checksum(data: str) -> int: # Temp files ############################################################################## -_tmpdir = None # pylint: disable=invalid-name +_tmpdir = None def tmpdir() -> str: "A reusable temp folder which we clean out on each program invocation." - global _tmpdir # pylint: disable=invalid-name + global _tmpdir if not _tmpdir: def cleanup() -> None: @@ -216,7 +215,6 @@ def call(argv: list[str], wait: bool = True, **kwargs: Any) -> int: try: info.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore except Exception: - # pylint: disable=no-member info.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW # type: ignore else: info = None @@ -282,7 +280,7 @@ def plat_desc() -> str: elif is_win: theos = f"win:{platform.win32_ver()[0]}" elif system == "Linux": - import distro # pytype: disable=import-error # pylint: disable=import-error + import distro # pytype: disable=import-error dist_id = distro.id() dist_version = distro.version() @@ -309,12 +307,17 @@ def int_version() -> int: """Anki's version as an integer in the form YYMMPP, e.g. 230900. (year, month, patch). In 2.1.x releases, this was just the last number.""" + import re + from anki.buildinfo import version + # Strip non-numeric characters (handles beta/rc suffixes like '25.02b1' or 'rc3') + numeric_version = re.sub(r"[^0-9.]", "", version) + try: - [year, month, patch] = version.split(".") + [year, month, patch] = numeric_version.split(".") except ValueError: - [year, month] = version.split(".") + [year, month] = numeric_version.split(".") patch = "0" year_num = int(year) diff --git a/pylib/hatch_build.py b/pylib/hatch_build.py index c3539da56..9e8ee9799 100644 --- a/pylib/hatch_build.py +++ b/pylib/hatch_build.py @@ -35,8 +35,16 @@ class CustomBuildHook(BuildHookInterface): assert generated_root.exists(), "you should build with --wheel" for path in generated_root.rglob("*"): - if path.is_file(): + if path.is_file() and not self._should_exclude(path): relative_path = path.relative_to(generated_root) # Place files under anki/ in the distribution dist_path = "anki" / relative_path force_include[str(path)] = str(dist_path) + + def _should_exclude(self, path: Path) -> bool: + """Check if a file should be excluded from the wheel.""" + # Exclude __pycache__ + path_str = str(path) + if "/__pycache__/" in path_str: + return True + return False diff --git a/pylib/pyproject.toml b/pylib/pyproject.toml index a12c6848b..fb7422694 100644 --- a/pylib/pyproject.toml +++ b/pylib/pyproject.toml @@ -1,23 +1,18 @@ [project] name = "anki" -# dynamic = ["version"] -version = "0.1.2" +dynamic = ["version"] requires-python = ">=3.9" license = "AGPL-3.0-or-later" dependencies = [ - "beautifulsoup4", "decorator", "markdown", "orjson", - "protobuf>=4.21", + "protobuf>=6.0,<8.0", "requests[socks]", + # remove after we update to min python 3.11+ "typing_extensions", - "types-protobuf", - "types-requests", - "types-orjson", # platform-specific dependencies "distro; sys_platform != 'darwin' and sys_platform != 'win32'", - "psutil; sys_platform == 'win32'", ] [build-system] diff --git a/pylib/rsbridge/Cargo.toml b/pylib/rsbridge/Cargo.toml index fbe76c8a5..22dca83fa 100644 --- a/pylib/rsbridge/Cargo.toml +++ b/pylib/rsbridge/Cargo.toml @@ -12,6 +12,7 @@ description = "Anki's Rust library code Python bindings" name = "rsbridge" crate-type = ["cdylib"] path = "lib.rs" +test = false [dependencies] anki.workspace = true diff --git a/pylib/rsbridge/build.rs b/pylib/rsbridge/build.rs index 2940563cb..4671bc296 100644 --- a/pylib/rsbridge/build.rs +++ b/pylib/rsbridge/build.rs @@ -28,6 +28,6 @@ fn main() { .to_string(); let libs_path = stdlib_path + "s"; - println!("cargo:rustc-link-search={}", libs_path); + println!("cargo:rustc-link-search={libs_path}"); } } diff --git a/pylib/tests/test_find.py b/pylib/tests/test_find.py index d9c2c1f87..236096572 100644 --- a/pylib/tests/test_find.py +++ b/pylib/tests/test_find.py @@ -169,8 +169,7 @@ def test_find_cards(): # properties id = col.db.scalar("select id from cards limit 1") col.db.execute( - "update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 " - "where id = ?", + "update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 where id = ?", id, ) assert len(col.find_cards("prop:ivl>5")) == 1 diff --git a/pylib/tests/test_importing.py b/pylib/tests/test_importing.py index 191de51f4..b7b63de26 100644 --- a/pylib/tests/test_importing.py +++ b/pylib/tests/test_importing.py @@ -13,7 +13,6 @@ from anki.importing import ( Anki2Importer, AnkiPackageImporter, MnemosyneImporter, - SupermemoXmlImporter, TextImporter, ) from tests.shared import getEmptyCol, getUpgradeDeckPath @@ -306,22 +305,6 @@ def test_csv_tag_only_if_modified(): col.close() -@pytest.mark.filterwarnings("ignore:Using or importing the ABCs") -def test_supermemo_xml_01_unicode(): - col = getEmptyCol() - file = str(os.path.join(testDir, "support", "supermemo1.xml")) - i = SupermemoXmlImporter(col, file) - # i.META.logToStdOutput = True - i.run() - assert i.total == 1 - cid = col.db.scalar("select id from cards") - c = col.get_card(cid) - # Applies A Factor-to-E Factor conversion - assert c.factor == 2879 - assert c.reps == 7 - col.close() - - def test_mnemo(): col = getEmptyCol() file = str(os.path.join(testDir, "support", "mnemo.db")) diff --git a/pylib/tests/test_schedv3.py b/pylib/tests/test_schedv3.py index 0deff7bf9..a71fa7140 100644 --- a/pylib/tests/test_schedv3.py +++ b/pylib/tests/test_schedv3.py @@ -551,12 +551,10 @@ def test_bury(): col.addNote(note) c2 = note.cards()[0] # burying - col.sched.bury_cards([c.id], manual=True) # pylint: disable=unexpected-keyword-arg + col.sched.bury_cards([c.id], manual=True) c.load() assert c.queue == QUEUE_TYPE_MANUALLY_BURIED - col.sched.bury_cards( - [c2.id], manual=False - ) # pylint: disable=unexpected-keyword-arg + col.sched.bury_cards([c2.id], manual=False) c2.load() assert c2.queue == QUEUE_TYPE_SIBLING_BURIED diff --git a/pylib/tools/genbuildinfo.py b/pylib/tools/genbuildinfo.py index b997ca5b3..add188d41 100644 --- a/pylib/tools/genbuildinfo.py +++ b/pylib/tools/genbuildinfo.py @@ -15,6 +15,5 @@ with open(buildhash_file, "r", encoding="utf8") as f: with open(outpath, "w", encoding="utf8") as f: # if we switch to uppercase we'll need to add legacy aliases - f.write("# pylint: disable=invalid-name\n") f.write(f"version = '{version}'\n") f.write(f"buildhash = '{buildhash}'\n") diff --git a/pylib/tools/genhooks.py b/pylib/tools/genhooks.py index e0e4924be..3644e3e95 100644 --- a/pylib/tools/genhooks.py +++ b/pylib/tools/genhooks.py @@ -133,7 +133,7 @@ prefix = """\ # This file is automatically generated; edit tools/genhooks.py instead. # Please import from anki.hooks instead of this file. -# pylint: disable=unused-import +# ruff: noqa: F401 from __future__ import annotations diff --git a/pylib/tools/hookslib.py b/pylib/tools/hookslib.py index 8920cdcfc..99f08fa1e 100644 --- a/pylib/tools/hookslib.py +++ b/pylib/tools/hookslib.py @@ -7,7 +7,6 @@ Code for generating hooks. from __future__ import annotations -import os import subprocess import sys from dataclasses import dataclass @@ -204,9 +203,6 @@ def write_file(path: str, hooks: list[Hook], prefix: str, suffix: str): code += f"\n{suffix}" - # work around issue with latest black - if sys.platform == "win32" and "HOME" in os.environ: - os.environ["USERPROFILE"] = os.environ["HOME"] with open(path, "wb") as file: file.write(code.encode("utf8")) - subprocess.run([sys.executable, "-m", "black", "-q", path], check=True) + subprocess.run([sys.executable, "-m", "ruff", "format", "-q", path], check=True) diff --git a/pyproject.toml b/pyproject.toml index f5443e229..7de32ec73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,16 +7,23 @@ classifiers = ["Private :: Do Not Upload"] [dependency-groups] dev = [ - "black", - "isort", "mypy", "mypy-protobuf", - "pylint", + "ruff", "pytest", "PyChromeDevTools", - "colorama", # for isort --color "wheel", "hatchling", # for type checking hatch_build.py files + "mock", + "types-protobuf", + "types-requests", + "types-orjson", + "types-decorator", + "types-flask", + "types-flask-cors", + "types-markdown", + "types-waitress", + "types-pywin32", ] [project.optional-dependencies] diff --git a/python/sphinx/build.py b/python/sphinx/build.py index 7d979c510..61091e6e1 100644 --- a/python/sphinx/build.py +++ b/python/sphinx/build.py @@ -2,6 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import os import subprocess + os.environ["REPO_ROOT"] = os.path.abspath(".") subprocess.run(["out/pyenv/bin/sphinx-apidoc", "-o", "out/python/sphinx", "pylib", "qt"], check=True) subprocess.run(["out/pyenv/bin/sphinx-build", "out/python/sphinx", "out/python/sphinx/html"], check=True) diff --git a/qt/.isort.cfg b/qt/.isort.cfg deleted file mode 100644 index aa01f87c7..000000000 --- a/qt/.isort.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[settings] -py_version=39 -profile=black -known_first_party=anki,aqt -extend_skip=aqt/forms,hooks_gen.py diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index 6645e3599..53bdc3c92 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations +# ruff: noqa: F401 import atexit import logging import os @@ -28,7 +29,7 @@ if sys.version_info[0] < 3 or sys.version_info[1] < 9: # ensure unicode filenames are supported try: "テスト".encode(sys.getfilesystemencoding()) -except UnicodeEncodeError as exc: +except UnicodeEncodeError: print("Anki requires a UTF-8 locale.") print("Please Google 'how to change locale on [your Linux distro]'") sys.exit(1) @@ -41,6 +42,11 @@ if "--syncserver" in sys.argv: # does not return run_sync_server() +if sys.platform == "win32": + from win32com.shell import shell + + shell.SetCurrentProcessExplicitAppUserModelID("Ankitects.Anki") + import argparse import builtins import cProfile @@ -284,8 +290,7 @@ def setupLangAndBackend( class NativeEventFilter(QAbstractNativeEventFilter): def nativeEventFilter( self, eventType: Any, message: Any - ) -> tuple[bool, sip.voidptr | None]: - + ) -> tuple[bool, Any | None]: if eventType == "windows_generic_MSG": import ctypes.wintypes @@ -376,6 +381,8 @@ class AnkiApp(QApplication): def onRecv(self) -> None: sock = self._srv.nextPendingConnection() + if sock is None: + return if not sock.waitForReadyRead(self.TMOUT): sys.stderr.write(sock.errorString()) return @@ -406,14 +413,12 @@ class AnkiApp(QApplication): QRadioButton, QMenu, QSlider, - # classes with PyQt5 compatibility proxy - without_qt5_compat_wrapper(QToolButton), - without_qt5_compat_wrapper(QTabBar), + QToolButton, + QTabBar, ) if evt.type() in [QEvent.Type.Enter, QEvent.Type.HoverEnter]: if (isinstance(src, pointer_classes) and src.isEnabled()) or ( - isinstance(src, without_qt5_compat_wrapper(QComboBox)) - and not src.isEditable() + isinstance(src, QComboBox) and not src.isEditable() ): self.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor)) else: @@ -525,15 +530,12 @@ def setupGL(pm: aqt.profiles.ProfileManager) -> None: QQuickWindow.setGraphicsApi(QSGRendererInterface.GraphicsApi.OpenGL) elif driver in (VideoDriver.Software, VideoDriver.ANGLE): if is_win: - # on Windows, this appears to be sufficient on Qt5/Qt6. + # on Windows, this appears to be sufficient # On Qt6, ANGLE is excluded by the enum. os.environ["QT_OPENGL"] = driver.value elif is_mac: QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_UseSoftwareOpenGL) elif is_lin: - # Qt5 only - os.environ["QT_XCB_FORCE_SOFTWARE_OPENGL"] = "1" - # Required on Qt6 if "QTWEBENGINE_CHROMIUM_FLAGS" not in os.environ: os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" if qtmajor > 5: @@ -561,7 +563,7 @@ def run() -> None: print(f"Starting Anki {_version}...") try: _run() - except Exception as e: + except Exception: traceback.print_exc() QMessageBox.critical( None, @@ -663,12 +665,6 @@ def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None: if is_win and "QT_QPA_PLATFORM" not in os.environ: os.environ["QT_QPA_PLATFORM"] = "windows:altgr" - # Disable sandbox on Qt5 PyPi/packaged builds, as it causes blank screens on modern - # glibc versions. We check for specific patch versions, because distros may have - # fixed the issue in their own Qt builds. - if is_lin and qtfullversion in ([5, 15, 2], [5, 14, 1]): - os.environ["QTWEBENGINE_DISABLE_SANDBOX"] = "1" - # create the app QCoreApplication.setApplicationName("Anki") QGuiApplication.setDesktopFileName("anki") diff --git a/qt/aqt/_macos_helper.py b/qt/aqt/_macos_helper.py index 9328b1c4a..27b368e80 100644 --- a/qt/aqt/_macos_helper.py +++ b/qt/aqt/_macos_helper.py @@ -3,50 +3,9 @@ from __future__ import annotations -import os import sys -from collections.abc import Callable -from ctypes import CDLL, CFUNCTYPE, c_bool, c_char_p -import aqt -import aqt.utils - - -class _MacOSHelper: - def __init__(self) -> None: - path = os.path.join(aqt.utils.aqt_data_folder(), "lib", "libankihelper.dylib") - - self._dll = CDLL(path) - self._dll.system_is_dark.restype = c_bool - - def system_is_dark(self) -> bool: - return self._dll.system_is_dark() - - def set_darkmode_enabled(self, enabled: bool) -> bool: - return self._dll.set_darkmode_enabled(enabled) - - def start_wav_record(self, path: str, on_error: Callable[[str], None]) -> None: - global _on_audio_error - _on_audio_error = on_error - self._dll.start_wav_record(path.encode("utf8"), _audio_error_callback) - - def end_wav_record(self) -> None: - "On completion, file should be saved if no error has arrived." - self._dll.end_wav_record() - - -# this must not be overwritten or deallocated -@CFUNCTYPE(None, c_char_p) # type: ignore -def _audio_error_callback(msg: str) -> None: - if handler := _on_audio_error: - handler(msg) - - -_on_audio_error: Callable[[str], None] | None = None - -macos_helper: _MacOSHelper | None = None if sys.platform == "darwin": - try: - macos_helper = _MacOSHelper() - except Exception as e: - print("macos_helper:", e) + from anki_mac_helper import macos_helper +else: + macos_helper = None diff --git a/qt/aqt/about.py b/qt/aqt/about.py index fb90a9355..03e989f2c 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -66,13 +66,14 @@ def show(mw: aqt.AnkiQt) -> QDialog: # WebView contents ###################################################################### abouttext = "
" - abouttext += f"

{tr.about_anki_is_a_friendly_intelligent_spaced()}" + lede = tr.about_anki_is_a_friendly_intelligent_spaced().replace("Anki", "Anki®") + abouttext += f"

{lede}" abouttext += f"

{tr.about_anki_is_licensed_under_the_agpl3()}" abouttext += f"

{tr.about_version(val=version_with_build())}
" - abouttext += ("Python %s Qt %s PyQt %s
") % ( + abouttext += ("Python %s Qt %s Chromium %s
") % ( platform.python_version(), qVersion(), - PYQT_VERSION_STR, + (qWebEngineChromiumVersion() or "").split(".")[0], ) abouttext += ( without_unicode_isolation(tr.about_visit_website(val=aqt.appWebsite)) @@ -223,6 +224,8 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Mukunda Madhav Dey", "Adnane Taghi", "Anon_0000", + "Bilolbek Normuminov", + "Sagiv Marzini", ) ) diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index fdce9142a..a940fb208 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -927,7 +927,6 @@ class AddonsDialog(QDialog): or self.mgr.configAction(addon.dir_name) ) ) - return def _onAddonItemSelected(self, row_int: int) -> None: try: @@ -1457,7 +1456,9 @@ class ChooseAddonsToUpdateDialog(QDialog): layout.addWidget(addons_list_widget) self.addons_list_widget = addons_list_widget - button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) # type: ignore + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) # type: ignore qconnect( button_box.button(QDialogButtonBox.StandardButton.Ok).clicked, self.accept ) diff --git a/qt/aqt/ankihub.py b/qt/aqt/ankihub.py index 4d3b00c8a..0ea9b6dac 100644 --- a/qt/aqt/ankihub.py +++ b/qt/aqt/ankihub.py @@ -36,7 +36,6 @@ def ankihub_login( username: str = "", password: str = "", ) -> None: - def on_future_done(fut: Future[str], username: str, password: str) -> None: try: token = fut.result() @@ -73,7 +72,6 @@ def ankihub_logout( on_success: Callable[[], None], token: str, ) -> None: - def logout() -> None: mw.pm.set_ankihub_username(None) mw.pm.set_ankihub_token(None) diff --git a/qt/aqt/browser/__init__.py b/qt/aqt/browser/__init__.py index ffff667e7..130167124 100644 --- a/qt/aqt/browser/__init__.py +++ b/qt/aqt/browser/__init__.py @@ -2,6 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +# ruff: noqa: F401 import sys import aqt 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: diff --git a/qt/aqt/browser/card_info.py b/qt/aqt/browser/card_info.py index fe031e630..c925d43bb 100644 --- a/qt/aqt/browser/card_info.py +++ b/qt/aqt/browser/card_info.py @@ -51,6 +51,7 @@ class CardInfoDialog(QDialog): def _setup_ui(self, card_id: CardId | None) -> None: self.mw.garbage_collect_on_dialog_finish(self) + self.setMinimumSize(400, 300) disable_help_button(self) restoreGeom(self, self.GEOMETRY_KEY, default_size=(800, 800)) add_close_shortcut(self) diff --git a/qt/aqt/browser/sidebar/__init__.py b/qt/aqt/browser/sidebar/__init__.py index 99ca8f7c4..555ed3cdd 100644 --- a/qt/aqt/browser/sidebar/__init__.py +++ b/qt/aqt/browser/sidebar/__init__.py @@ -1,5 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +# ruff: noqa: F401 from anki.utils import is_mac from aqt.theme import theme_manager diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index e28f166e9..22d7fa4a6 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -106,7 +106,7 @@ class SidebarTreeView(QTreeView): def _setup_style(self) -> None: # match window background color and tweak style bgcolor = QPalette().window().color().name() - border = theme_manager.var(colors.BORDER) + theme_manager.var(colors.BORDER) styles = [ "padding: 3px", "padding-right: 0px", @@ -711,7 +711,6 @@ class SidebarTreeView(QTreeView): def _flags_tree(self, root: SidebarItem) -> None: icon_off = "icons:flag-variant-off-outline.svg" - icon = "icons:flag-variant.svg" icon_outline = "icons:flag-variant-outline.svg" root = self._section_root( diff --git a/qt/aqt/browser/table/__init__.py b/qt/aqt/browser/table/__init__.py index bd666cf1a..c942dc30f 100644 --- a/qt/aqt/browser/table/__init__.py +++ b/qt/aqt/browser/table/__init__.py @@ -2,6 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +# ruff: noqa: F401 import copy import time from collections.abc import Generator, Sequence diff --git a/qt/aqt/browser/table/model.py b/qt/aqt/browser/table/model.py index 5b42c0ca3..732e8c99c 100644 --- a/qt/aqt/browser/table/model.py +++ b/qt/aqt/browser/table/model.py @@ -105,11 +105,11 @@ class DataModel(QAbstractTableModel): row = CellRow(*self.col.browser_row_for_id(item)) except BackendError as e: return CellRow.disabled(self.len_columns(), str(e)) - except Exception as e: + except Exception: return CellRow.disabled( self.len_columns(), tr.errors_please_check_database() ) - except BaseException as e: + except BaseException: # fatal error like a panic in the backend - dump it to the # console so it gets picked up by the error handler import traceback @@ -325,15 +325,13 @@ class DataModel(QAbstractTableModel): return 0 return self.len_columns() - _QFont = without_qt5_compat_wrapper(QFont) - def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: if not index.isValid(): return QVariant() if role == Qt.ItemDataRole.FontRole: if not self.column_at(index).uses_cell_font: return QVariant() - qfont = self._QFont() + qfont = QFont() row = self.get_row(index) qfont.setFamily(row.font_name) qfont.setPixelSize(row.font_size) diff --git a/qt/aqt/browser/table/state.py b/qt/aqt/browser/table/state.py index 8054d2597..4faf88611 100644 --- a/qt/aqt/browser/table/state.py +++ b/qt/aqt/browser/table/state.py @@ -59,7 +59,7 @@ class ItemState(ABC): # abstractproperty is deprecated but used due to mypy limitations # (https://github.com/python/mypy/issues/1362) - @abstractproperty # pylint: disable=deprecated-decorator + @abstractproperty def active_columns(self) -> list[str]: """Return the saved or default columns for the state.""" diff --git a/qt/aqt/browser/table/table.py b/qt/aqt/browser/table/table.py index f3d543d93..e28075b3f 100644 --- a/qt/aqt/browser/table/table.py +++ b/qt/aqt/browser/table/table.py @@ -361,8 +361,7 @@ class Table: for m in self.col.models.all(): for t in m["tmpls"]: bsize = t.get("bsize", 0) - if bsize > curmax: - curmax = bsize + curmax = max(curmax, bsize) assert self._view is not None vh = self._view.verticalHeader() @@ -382,10 +381,7 @@ class Table: hh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._restore_header() qconnect(hh.customContextMenuRequested, self._on_header_context) - if qtmajor == 5: - qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed_qt5) - else: - qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) + qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) qconnect(hh.sectionMoved, self._on_column_moved) # Slots @@ -495,12 +491,6 @@ class Table: if checked: self._scroll_to_column(self._model.len_columns() - 1) - def _on_sort_column_changed_qt5(self, section: int, order: int) -> None: - self._on_sort_column_changed( - section, - Qt.SortOrder.AscendingOrder if not order else Qt.SortOrder.DescendingOrder, - ) - def _on_sort_column_changed(self, section: int, order: Qt.SortOrder) -> None: column = self._model.column_at_section(section) sorting = column.sorting_notes if self.is_notes_mode() else column.sorting_cards diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 388ae46c0..aec5326f4 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -221,7 +221,7 @@ class CardLayout(QDialog): ) for i in range(min(len(self.cloze_numbers), 9)): QShortcut( # type: ignore - QKeySequence(f"Alt+{i+1}"), + QKeySequence(f"Alt+{i + 1}"), self, activated=lambda n=i: self.pform.cloze_number_combo.setCurrentIndex(n), ) @@ -790,7 +790,7 @@ class CardLayout(QDialog): assert a is not None qconnect( a.triggered, - lambda: self.on_restore_to_default(), # pylint: disable=unnecessary-lambda + lambda: self.on_restore_to_default(), ) if not self._isCloze(): diff --git a/qt/aqt/debug_console.py b/qt/aqt/debug_console.py index a37d14010..54fa8a17a 100644 --- a/qt/aqt/debug_console.py +++ b/qt/aqt/debug_console.py @@ -294,7 +294,6 @@ class DebugConsole(QDialog): } self._captureOutput(True) try: - # pylint: disable=exec-used exec(text, vars) except Exception: self._output += traceback.format_exc() diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 77bd84220..5dc688155 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -386,9 +386,7 @@ class DeckBrowser: if b[0]: b[0] = tr.actions_shortcut_key(val=shortcut(b[0])) buf += """ -""" % tuple( - b - ) +""" % tuple(b) self.bottom.draw( buf=buf, link_handler=self._linkHandler, diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index 3a980145d..f2f267097 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -36,7 +36,7 @@ from anki.hooks import runFilter from anki.httpclient import HttpClient from anki.models import NotetypeDict, NotetypeId, StockNotetype from anki.notes import Note, NoteFieldsCheckResult, NoteId -from anki.utils import checksum, is_lin, is_mac, is_win, namedtmp +from anki.utils import checksum, is_lin, is_win, namedtmp from aqt import AnkiQt, colors, gui_hooks from aqt.operations import QueryOp from aqt.operations.note import update_note @@ -343,7 +343,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too gui_hooks.editor_did_init_shortcuts(cuts, self) for row in cuts: if len(row) == 2: - keys, fn = row # pylint: disable=unbalanced-tuple-unpacking + keys, fn = row fn = self._addFocusCheck(fn) else: keys, fn, _ = row @@ -796,7 +796,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too def accept(file: str) -> None: self.resolve_media(file) - file = getFile( + getFile( parent=self.widget, title=tr.editing_add_media(), cb=cast(Callable[[Any], None], accept), @@ -999,7 +999,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too if html.find(">") < 0: return html - with warnings.catch_warnings() as w: + with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) doc = BeautifulSoup(html, "html.parser") @@ -1029,15 +1029,14 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too m = re.match(r"http://127.0.0.1:\d+/(.*)$", str(src)) if m: tag["src"] = m.group(1) - else: - # in external pastes, download remote media - if isinstance(src, str) and self.isURL(src): - fname = self._retrieveURL(src) - if fname: - tag["src"] = fname - elif isinstance(src, str) and src.startswith("data:image/"): - # and convert inlined data - tag["src"] = self.inlinedImageToFilename(str(src)) + # in external pastes, download remote media + elif isinstance(src, str) and self.isURL(src): + fname = self._retrieveURL(src) + if fname: + tag["src"] = fname + elif isinstance(src, str) and src.startswith("data:image/"): + # and convert inlined data + tag["src"] = self.inlinedImageToFilename(str(src)) html = str(doc) return html @@ -1102,7 +1101,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too ) filter = f"{tr.editing_media()} ({extension_filter})" - file = getFile( + getFile( parent=self.widget, title=tr.editing_add_media(), cb=cast(Callable[[Any], None], self.setup_mask_editor), @@ -1735,10 +1734,9 @@ class EditorWebView(AnkiWebView): assert a is not None qconnect(a.triggered, lambda: openFolder(path)) - if is_win or is_mac: - a = menu.addAction(tr.editing_show_in_folder()) - assert a is not None - qconnect(a.triggered, lambda: show_in_folder(path)) + a = menu.addAction(tr.editing_show_in_folder()) + assert a is not None + qconnect(a.triggered, lambda: show_in_folder(path)) def _clipboard(self) -> QClipboard: clipboard = self.editor.mw.app.clipboard() diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py index af1036acd..a6d9251e2 100644 --- a/qt/aqt/errors.py +++ b/qt/aqt/errors.py @@ -23,25 +23,36 @@ from aqt.utils import openHelp, showWarning, supportText, tooltip, tr if TYPE_CHECKING: from aqt.main import AnkiQt +# so we can be non-modal/non-blocking, without Python deallocating the message +# box ahead of time +_mbox: QMessageBox | None = None + def show_exception(*, parent: QWidget, exception: Exception) -> None: "Present a caught exception to the user using a pop-up." if isinstance(exception, Interrupted): # nothing to do return + global _mbox + error_lines = [] + help_page = HelpPage.TROUBLESHOOTING if isinstance(exception, BackendError): if exception.context: - print(exception.context) + error_lines.append(exception.context) if exception.backtrace: - print(exception.backtrace) - showWarning(str(exception), parent=parent, help=exception.help_page) + error_lines.append(exception.backtrace) + if exception.help_page is not None: + help_page = exception.help_page else: # if the error is not originating from the backend, dump # a traceback to the console to aid in debugging - traceback.print_exception( - None, exception, exception.__traceback__, file=sys.stdout + error_lines = traceback.format_exception( + None, exception, exception.__traceback__ ) - showWarning(str(exception), parent=parent) + error_text = "\n".join(error_lines) + print(error_lines) + _mbox = _init_message_box(str(exception), error_text, help_page) + _mbox.show() def is_chromium_cert_error(error: str) -> bool: @@ -158,9 +169,39 @@ if not os.environ.get("DEBUG"): sys.excepthook = excepthook -# so we can be non-modal/non-blocking, without Python deallocating the message -# box ahead of time -_mbox: QMessageBox | None = None + +def _init_message_box( + user_text: str, debug_text: str, help_page=HelpPage.TROUBLESHOOTING +): + global _mbox + + _mbox = QMessageBox() + _mbox.setWindowTitle("Anki") + _mbox.setText(user_text) + _mbox.setIcon(QMessageBox.Icon.Warning) + _mbox.setTextFormat(Qt.TextFormat.PlainText) + + def show_help(): + openHelp(help_page) + + def copy_debug_info(): + QApplication.clipboard().setText(debug_text) + tooltip(tr.errors_copied_to_clipboard(), parent=_mbox) + + help = _mbox.addButton(QMessageBox.StandardButton.Help) + if debug_text: + debug_info = _mbox.addButton( + tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole + ) + debug_info.disconnect() + debug_info.clicked.connect(copy_debug_info) + cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel) + cancel.setText(tr.actions_close()) + + help.disconnect() + help.clicked.connect(show_help) + + return _mbox class ErrorHandler(QObject): @@ -252,33 +293,7 @@ class ErrorHandler(QObject): user_text += "\n\n" + self._addonText(error) debug_text += addon_debug_info() - def show_troubleshooting(): - openHelp(HelpPage.TROUBLESHOOTING) - - def copy_debug_info(): - QApplication.clipboard().setText(debug_text) - tooltip(tr.errors_copied_to_clipboard(), parent=_mbox) - - global _mbox - _mbox = QMessageBox() - _mbox.setWindowTitle("Anki") - _mbox.setText(user_text) - _mbox.setIcon(QMessageBox.Icon.Warning) - _mbox.setTextFormat(Qt.TextFormat.PlainText) - - troubleshooting = _mbox.addButton( - tr.errors_troubleshooting_button(), QMessageBox.ButtonRole.ActionRole - ) - debug_info = _mbox.addButton( - tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole - ) - cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel) - cancel.setText(tr.actions_close()) - - troubleshooting.disconnect() - troubleshooting.clicked.connect(show_troubleshooting) - debug_info.disconnect() - debug_info.clicked.connect(copy_debug_info) + _mbox = _init_message_box(user_text, debug_text) if self.fatal_error_encountered: _mbox.exec() diff --git a/qt/aqt/exporting.py b/qt/aqt/exporting.py index 4ff024917..cadbaef0c 100644 --- a/qt/aqt/exporting.py +++ b/qt/aqt/exporting.py @@ -212,11 +212,10 @@ class ExportDialog(QDialog): if self.isVerbatim: msg = tr.exporting_collection_exported() self.mw.reopen() + elif self.isTextNote: + msg = tr.exporting_note_exported(count=self.exporter.count) else: - if self.isTextNote: - msg = tr.exporting_note_exported(count=self.exporter.count) - else: - msg = tr.exporting_card_exported(count=self.exporter.count) + msg = tr.exporting_card_exported(count=self.exporter.count) gui_hooks.legacy_exporter_did_export(self.exporter) tooltip(msg, period=3000) QDialog.reject(self) diff --git a/qt/aqt/forms/__init__.py b/qt/aqt/forms/__init__.py index 9484f91ec..7cbfe3a6f 100644 --- a/qt/aqt/forms/__init__.py +++ b/qt/aqt/forms/__init__.py @@ -1,3 +1,4 @@ +# ruff: noqa: F401 from . import ( about, addcards, diff --git a/qt/aqt/forms/about.py b/qt/aqt/forms/about.py index 4faf97fb0..fe66f7da3 100644 --- a/qt/aqt/forms/about.py +++ b/qt/aqt/forms/about.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.about_qt6 import * -else: - from _aqt.forms.about_qt5 import * # type: ignore +from _aqt.forms.about_qt6 import * diff --git a/qt/aqt/forms/addcards.py b/qt/aqt/forms/addcards.py index ae2debe3e..8c501695e 100644 --- a/qt/aqt/forms/addcards.py +++ b/qt/aqt/forms/addcards.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.addcards_qt6 import * -else: - from _aqt.forms.addcards_qt5 import * # type: ignore +from _aqt.forms.addcards_qt6 import * diff --git a/qt/aqt/forms/addfield.py b/qt/aqt/forms/addfield.py index 57c697b4a..a2f9eed74 100644 --- a/qt/aqt/forms/addfield.py +++ b/qt/aqt/forms/addfield.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.addfield_qt6 import * -else: - from _aqt.forms.addfield_qt5 import * # type: ignore +from _aqt.forms.addfield_qt6 import * diff --git a/qt/aqt/forms/addmodel.py b/qt/aqt/forms/addmodel.py index 9a7d06b7e..0af313a45 100644 --- a/qt/aqt/forms/addmodel.py +++ b/qt/aqt/forms/addmodel.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.addmodel_qt6 import * -else: - from _aqt.forms.addmodel_qt5 import * # type: ignore +from _aqt.forms.addmodel_qt6 import * diff --git a/qt/aqt/forms/addonconf.py b/qt/aqt/forms/addonconf.py index cca92b7b9..d78ebb82a 100644 --- a/qt/aqt/forms/addonconf.py +++ b/qt/aqt/forms/addonconf.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.addonconf_qt6 import * -else: - from _aqt.forms.addonconf_qt5 import * # type: ignore +from _aqt.forms.addonconf_qt6 import * diff --git a/qt/aqt/forms/addons.py b/qt/aqt/forms/addons.py index fa00be08b..46d7532b4 100644 --- a/qt/aqt/forms/addons.py +++ b/qt/aqt/forms/addons.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.addons_qt6 import * -else: - from _aqt.forms.addons_qt5 import * # type: ignore +from _aqt.forms.addons_qt6 import * diff --git a/qt/aqt/forms/browser.py b/qt/aqt/forms/browser.py index 403f780c5..70214ba4c 100644 --- a/qt/aqt/forms/browser.py +++ b/qt/aqt/forms/browser.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.browser_qt6 import * -else: - from _aqt.forms.browser_qt5 import * # type: ignore +from _aqt.forms.browser_qt6 import * diff --git a/qt/aqt/forms/browserdisp.py b/qt/aqt/forms/browserdisp.py index 712e5a400..fc745a703 100644 --- a/qt/aqt/forms/browserdisp.py +++ b/qt/aqt/forms/browserdisp.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.browserdisp_qt6 import * -else: - from _aqt.forms.browserdisp_qt5 import * # type: ignore +from _aqt.forms.browserdisp_qt6 import * diff --git a/qt/aqt/forms/browseropts.py b/qt/aqt/forms/browseropts.py index 68602c85c..1ae696033 100644 --- a/qt/aqt/forms/browseropts.py +++ b/qt/aqt/forms/browseropts.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.browseropts_qt6 import * -else: - from _aqt.forms.browseropts_qt5 import * # type: ignore +from _aqt.forms.browseropts_qt6 import * diff --git a/qt/aqt/forms/changemap.py b/qt/aqt/forms/changemap.py index 6028b0d49..b48b49a83 100644 --- a/qt/aqt/forms/changemap.py +++ b/qt/aqt/forms/changemap.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.changemap_qt6 import * -else: - from _aqt.forms.changemap_qt5 import * # type: ignore +from _aqt.forms.changemap_qt6 import * diff --git a/qt/aqt/forms/changemodel.py b/qt/aqt/forms/changemodel.py index 73f7f6095..cd1931af8 100644 --- a/qt/aqt/forms/changemodel.py +++ b/qt/aqt/forms/changemodel.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.changemodel_qt6 import * -else: - from _aqt.forms.changemodel_qt5 import * # type: ignore +from _aqt.forms.changemodel_qt6 import * diff --git a/qt/aqt/forms/clayout_top.py b/qt/aqt/forms/clayout_top.py index 24f78be11..1a76c882a 100644 --- a/qt/aqt/forms/clayout_top.py +++ b/qt/aqt/forms/clayout_top.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.clayout_top_qt6 import * -else: - from _aqt.forms.clayout_top_qt5 import * # type: ignore +from _aqt.forms.clayout_top_qt6 import * diff --git a/qt/aqt/forms/customstudy.py b/qt/aqt/forms/customstudy.py index 393638b2c..3bfad32ac 100644 --- a/qt/aqt/forms/customstudy.py +++ b/qt/aqt/forms/customstudy.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.customstudy_qt6 import * -else: - from _aqt.forms.customstudy_qt5 import * # type: ignore +from _aqt.forms.customstudy_qt6 import * diff --git a/qt/aqt/forms/dconf.py b/qt/aqt/forms/dconf.py index e28db5c31..f39de7077 100644 --- a/qt/aqt/forms/dconf.py +++ b/qt/aqt/forms/dconf.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.dconf_qt6 import * -else: - from _aqt.forms.dconf_qt5 import * # type: ignore +from _aqt.forms.dconf_qt6 import * diff --git a/qt/aqt/forms/debug.py b/qt/aqt/forms/debug.py index 928ba7795..0880c49fc 100644 --- a/qt/aqt/forms/debug.py +++ b/qt/aqt/forms/debug.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.debug_qt6 import * -else: - from _aqt.forms.debug_qt5 import * # type: ignore +from _aqt.forms.debug_qt6 import * diff --git a/qt/aqt/forms/editcurrent.py b/qt/aqt/forms/editcurrent.py index 1281faafe..cfa9ab1d9 100644 --- a/qt/aqt/forms/editcurrent.py +++ b/qt/aqt/forms/editcurrent.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.editcurrent_qt6 import * -else: - from _aqt.forms.editcurrent_qt5 import * # type: ignore +from _aqt.forms.editcurrent_qt6 import * diff --git a/qt/aqt/forms/edithtml.py b/qt/aqt/forms/edithtml.py index 029977705..61b9e0fd2 100644 --- a/qt/aqt/forms/edithtml.py +++ b/qt/aqt/forms/edithtml.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.edithtml_qt6 import * -else: - from _aqt.forms.edithtml_qt5 import * # type: ignore +from _aqt.forms.edithtml_qt6 import * diff --git a/qt/aqt/forms/emptycards.py b/qt/aqt/forms/emptycards.py index 046c7eb3a..1cae290fd 100644 --- a/qt/aqt/forms/emptycards.py +++ b/qt/aqt/forms/emptycards.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.emptycards_qt6 import * -else: - from _aqt.forms.emptycards_qt5 import * # type: ignore +from _aqt.forms.emptycards_qt6 import * diff --git a/qt/aqt/forms/exporting.py b/qt/aqt/forms/exporting.py index 559e50ecd..d09e9cdd9 100644 --- a/qt/aqt/forms/exporting.py +++ b/qt/aqt/forms/exporting.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.exporting_qt6 import * -else: - from _aqt.forms.exporting_qt5 import * # type: ignore +from _aqt.forms.exporting_qt6 import * diff --git a/qt/aqt/forms/fields.py b/qt/aqt/forms/fields.py index fa379be67..cf7a39f75 100644 --- a/qt/aqt/forms/fields.py +++ b/qt/aqt/forms/fields.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.fields_qt6 import * -else: - from _aqt.forms.fields_qt5 import * # type: ignore +from _aqt.forms.fields_qt6 import * diff --git a/qt/aqt/forms/filtered_deck.py b/qt/aqt/forms/filtered_deck.py index 9b9589046..59870f5a0 100644 --- a/qt/aqt/forms/filtered_deck.py +++ b/qt/aqt/forms/filtered_deck.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.filtered_deck_qt6 import * -else: - from _aqt.forms.filtered_deck_qt5 import * # type: ignore +from _aqt.forms.filtered_deck_qt6 import * diff --git a/qt/aqt/forms/finddupes.py b/qt/aqt/forms/finddupes.py index 7bca9c4cd..43ac30549 100644 --- a/qt/aqt/forms/finddupes.py +++ b/qt/aqt/forms/finddupes.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.finddupes_qt6 import * -else: - from _aqt.forms.finddupes_qt5 import * # type: ignore +from _aqt.forms.finddupes_qt6 import * diff --git a/qt/aqt/forms/findreplace.py b/qt/aqt/forms/findreplace.py index 8f82e58fe..65d1f3555 100644 --- a/qt/aqt/forms/findreplace.py +++ b/qt/aqt/forms/findreplace.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.findreplace_qt6 import * -else: - from _aqt.forms.findreplace_qt5 import * # type: ignore +from _aqt.forms.findreplace_qt6 import * diff --git a/qt/aqt/forms/forget.py b/qt/aqt/forms/forget.py index 97425aed8..0d17803df 100644 --- a/qt/aqt/forms/forget.py +++ b/qt/aqt/forms/forget.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.forget_qt6 import * -else: - from _aqt.forms.forget_qt5 import * # type: ignore +from _aqt.forms.forget_qt6 import * diff --git a/qt/aqt/forms/getaddons.py b/qt/aqt/forms/getaddons.py index c47ed27a8..ecb6c23dd 100644 --- a/qt/aqt/forms/getaddons.py +++ b/qt/aqt/forms/getaddons.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.getaddons_qt6 import * -else: - from _aqt.forms.getaddons_qt5 import * # type: ignore +from _aqt.forms.getaddons_qt6 import * diff --git a/qt/aqt/forms/importing.py b/qt/aqt/forms/importing.py index f60b74a4e..39ade97c2 100644 --- a/qt/aqt/forms/importing.py +++ b/qt/aqt/forms/importing.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.importing_qt6 import * -else: - from _aqt.forms.importing_qt5 import * # type: ignore +from _aqt.forms.importing_qt6 import * diff --git a/qt/aqt/forms/main.py b/qt/aqt/forms/main.py index 068804a2d..7ec7107b3 100644 --- a/qt/aqt/forms/main.py +++ b/qt/aqt/forms/main.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.main_qt6 import * -else: - from _aqt.forms.main_qt5 import * # type: ignore +from _aqt.forms.main_qt6 import * diff --git a/qt/aqt/forms/main.ui b/qt/aqt/forms/main.ui index 596ea985c..bffc67ad0 100644 --- a/qt/aqt/forms/main.ui +++ b/qt/aqt/forms/main.ui @@ -46,7 +46,7 @@ 0 0 667 - 24 + 43 @@ -93,6 +93,7 @@ + @@ -130,7 +131,7 @@ Ctrl+P - QAction::PreferencesRole + QAction::MenuRole::PreferencesRole @@ -138,7 +139,7 @@ qt_accel_about - QAction::AboutRole + QAction::MenuRole::ApplicationSpecificRole @@ -283,6 +284,11 @@ qt_accel_load_backup + + + qt_accel_upgrade_downgrade + + diff --git a/qt/aqt/forms/modelopts.py b/qt/aqt/forms/modelopts.py index 0e4770c92..811b1fb7b 100644 --- a/qt/aqt/forms/modelopts.py +++ b/qt/aqt/forms/modelopts.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.modelopts_qt6 import * -else: - from _aqt.forms.modelopts_qt5 import * # type: ignore +from _aqt.forms.modelopts_qt6 import * diff --git a/qt/aqt/forms/models.py b/qt/aqt/forms/models.py index fb0b64e0a..43c75c62a 100644 --- a/qt/aqt/forms/models.py +++ b/qt/aqt/forms/models.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.models_qt6 import * -else: - from _aqt.forms.models_qt5 import * # type: ignore +from _aqt.forms.models_qt6 import * diff --git a/qt/aqt/forms/preferences.py b/qt/aqt/forms/preferences.py index de9fdc989..6fdb0bfd3 100644 --- a/qt/aqt/forms/preferences.py +++ b/qt/aqt/forms/preferences.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.preferences_qt6 import * -else: - from _aqt.forms.preferences_qt5 import * # type: ignore +from _aqt.forms.preferences_qt6 import * diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index a5d677328..9df416d8e 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -1305,9 +1305,10 @@ daily_backups weekly_backups monthly_backups - tabWidget syncAnkiHubLogout syncAnkiHubLogin + buttonBox + tabWidget diff --git a/qt/aqt/forms/preview.py b/qt/aqt/forms/preview.py index ca938a396..bf735bd39 100644 --- a/qt/aqt/forms/preview.py +++ b/qt/aqt/forms/preview.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.preview_qt6 import * -else: - from _aqt.forms.preview_qt5 import * # type: ignore +from _aqt.forms.preview_qt6 import * diff --git a/qt/aqt/forms/profiles.py b/qt/aqt/forms/profiles.py index c7bcc10e1..7d5b8d6e0 100644 --- a/qt/aqt/forms/profiles.py +++ b/qt/aqt/forms/profiles.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.profiles_qt6 import * -else: - from _aqt.forms.profiles_qt5 import * # type: ignore +from _aqt.forms.profiles_qt6 import * diff --git a/qt/aqt/forms/progress.py b/qt/aqt/forms/progress.py index 47a57ce49..7a2a332d5 100644 --- a/qt/aqt/forms/progress.py +++ b/qt/aqt/forms/progress.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.progress_qt6 import * -else: - from _aqt.forms.progress_qt5 import * # type: ignore +from _aqt.forms.progress_qt6 import * diff --git a/qt/aqt/forms/reposition.py b/qt/aqt/forms/reposition.py index 646abf7c4..cfad6b55a 100644 --- a/qt/aqt/forms/reposition.py +++ b/qt/aqt/forms/reposition.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.reposition_qt6 import * -else: - from _aqt.forms.reposition_qt5 import * # type: ignore +from _aqt.forms.reposition_qt6 import * diff --git a/qt/aqt/forms/setgroup.py b/qt/aqt/forms/setgroup.py index 649e4f75a..617ef3b96 100644 --- a/qt/aqt/forms/setgroup.py +++ b/qt/aqt/forms/setgroup.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.setgroup_qt6 import * -else: - from _aqt.forms.setgroup_qt5 import * # type: ignore +from _aqt.forms.setgroup_qt6 import * diff --git a/qt/aqt/forms/setlang.py b/qt/aqt/forms/setlang.py index bb715ff92..efe14343b 100644 --- a/qt/aqt/forms/setlang.py +++ b/qt/aqt/forms/setlang.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.setlang_qt6 import * -else: - from _aqt.forms.setlang_qt5 import * # type: ignore +from _aqt.forms.setlang_qt6 import * diff --git a/qt/aqt/forms/stats.py b/qt/aqt/forms/stats.py index 212c03345..12b161f4e 100644 --- a/qt/aqt/forms/stats.py +++ b/qt/aqt/forms/stats.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.stats_qt6 import * -else: - from _aqt.forms.stats_qt5 import * # type: ignore +from _aqt.forms.stats_qt6 import * diff --git a/qt/aqt/forms/studydeck.py b/qt/aqt/forms/studydeck.py index b95bc7e87..497ab01cf 100644 --- a/qt/aqt/forms/studydeck.py +++ b/qt/aqt/forms/studydeck.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.studydeck_qt6 import * -else: - from _aqt.forms.studydeck_qt5 import * # type: ignore +from _aqt.forms.studydeck_qt6 import * diff --git a/qt/aqt/forms/synclog.py b/qt/aqt/forms/synclog.py index 97fefe300..ddd08456b 100644 --- a/qt/aqt/forms/synclog.py +++ b/qt/aqt/forms/synclog.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.synclog_qt6 import * -else: - from _aqt.forms.synclog_qt5 import * # type: ignore +from _aqt.forms.synclog_qt6 import * diff --git a/qt/aqt/forms/taglimit.py b/qt/aqt/forms/taglimit.py index 7a4763016..88262c657 100644 --- a/qt/aqt/forms/taglimit.py +++ b/qt/aqt/forms/taglimit.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.taglimit_qt6 import * -else: - from _aqt.forms.taglimit_qt5 import * # type: ignore +from _aqt.forms.taglimit_qt6 import * diff --git a/qt/aqt/forms/template.py b/qt/aqt/forms/template.py index 84f3d2a05..7540d72e0 100644 --- a/qt/aqt/forms/template.py +++ b/qt/aqt/forms/template.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.template_qt6 import * -else: - from _aqt.forms.template_qt5 import * # type: ignore +from _aqt.forms.template_qt6 import * diff --git a/qt/aqt/forms/widgets.py b/qt/aqt/forms/widgets.py index b91f7ae26..07dc11c6c 100644 --- a/qt/aqt/forms/widgets.py +++ b/qt/aqt/forms/widgets.py @@ -1,8 +1 @@ -from typing import TYPE_CHECKING - -from aqt.qt import qtmajor - -if qtmajor > 5 or TYPE_CHECKING: - from _aqt.forms.widgets_qt6 import * -else: - from _aqt.forms.widgets_qt5 import * # type: ignore +from _aqt.forms.widgets_qt6 import * diff --git a/qt/aqt/import_export/importing.py b/qt/aqt/import_export/importing.py index 938824035..cb27c5e4b 100644 --- a/qt/aqt/import_export/importing.py +++ b/qt/aqt/import_export/importing.py @@ -134,9 +134,8 @@ IMPORTERS: list[type[Importer]] = [ def legacy_file_endings(col: Collection) -> list[str]: - from anki.importing import AnkiPackageImporter + from anki.importing import AnkiPackageImporter, TextImporter, importers from anki.importing import MnemosyneImporter as LegacyMnemosyneImporter - from anki.importing import TextImporter, importers return [ ext diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index 8f9741a77..8701f9843 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -11,10 +11,10 @@ from collections.abc import Callable from concurrent.futures import Future from typing import Any -import anki.importing as importing import aqt.deckchooser import aqt.forms import aqt.modelchooser +from anki import importing from anki.importing.anki2 import MediaMapInvalid, V2ImportIntoV1 from anki.importing.apkg import AnkiPackageImporter from aqt.import_export.importing import ColpkgImporter @@ -262,7 +262,7 @@ class ImportDialog(QDialog): self.mapwidget.setLayout(self.grid) self.grid.setContentsMargins(3, 3, 3, 3) self.grid.setSpacing(6) - for num in range(len(self.mapping)): # pylint: disable=consider-using-enumerate + for num in range(len(self.mapping)): text = tr.importing_field_of_file_is(val=num + 1) self.grid.addWidget(QLabel(text), num, 0) if self.mapping[num] == "_tags": @@ -357,7 +357,7 @@ def importFile(mw: AnkiQt, file: str) -> None: try: importer.open() mw.progress.finish() - diag = ImportDialog(mw, importer) + ImportDialog(mw, importer) except UnicodeDecodeError: mw.progress.finish() showUnicodeWarning() @@ -443,3 +443,4 @@ def setupApkgImport(mw: AnkiQt, importer: AnkiPackageImporter) -> bool: return True ColpkgImporter.do_import(mw, importer.file) return False + return False diff --git a/qt/aqt/main.py b/qt/aqt/main.py index bc28e287b..c707d1b2a 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -376,7 +376,6 @@ class AnkiQt(QMainWindow): def openProfile(self) -> None: name = self.pm.profiles()[self.profileForm.profiles.currentRow()] self.pm.load(name) - return def onOpenProfile(self, *, callback: Callable[[], None] | None = None) -> None: def on_done() -> None: @@ -451,7 +450,6 @@ class AnkiQt(QMainWindow): self.loadProfile() def onOpenBackup(self) -> None: - def do_open(path: str) -> None: if not askUser( tr.qt_misc_replace_your_collection_with_an_earlier2( @@ -677,7 +675,7 @@ class AnkiQt(QMainWindow): gui_hooks.collection_did_load(self.col) self.apply_collection_options() self.moveToState("deckBrowser") - except Exception as e: + except Exception: # dump error to stderr so it gets picked up by errors.py traceback.print_exc() @@ -774,7 +772,6 @@ class AnkiQt(QMainWindow): oldState = self.state cleanup = getattr(self, f"_{oldState}Cleanup", None) if cleanup: - # pylint: disable=not-callable cleanup(state) self.clearStateShortcuts() self.state = state @@ -821,7 +818,7 @@ class AnkiQt(QMainWindow): self.bottomWeb.hide_timer.start() def _reviewCleanup(self, newState: MainWindowState) -> None: - if newState != "resetRequired" and newState != "review": + if newState not in {"resetRequired", "review"}: self.reviewer.auto_advance_enabled = False self.reviewer.cleanup() self.toolbarWeb.elevate() @@ -1308,6 +1305,14 @@ title="{}" {}>{}""".format( def onPrefs(self) -> None: aqt.dialogs.open("Preferences", self) + def on_upgrade_downgrade(self) -> None: + if not askUser(tr.qt_misc_open_anki_launcher()): + return + + from aqt.package import update_and_restart + + update_and_restart() + def onNoteTypes(self) -> None: import aqt.models @@ -1389,6 +1394,8 @@ title="{}" {}>{}""".format( ########################################################################## def setupMenus(self) -> None: + from aqt.package import launcher_executable + m = self.form # File @@ -1405,6 +1412,7 @@ title="{}" {}>{}""".format( qconnect(m.actionDocumentation.triggered, self.onDocumentation) qconnect(m.actionDonate.triggered, self.onDonate) qconnect(m.actionAbout.triggered, self.onAbout) + m.actionAbout.setText(tr.qt_accel_about_mac()) # Edit qconnect(m.actionUndo.triggered, self.undo) @@ -1417,6 +1425,9 @@ title="{}" {}>{}""".format( qconnect(m.actionCreateFiltered.triggered, self.onCram) qconnect(m.actionEmptyCards.triggered, self.onEmptyCards) qconnect(m.actionNoteTypes.triggered, self.onNoteTypes) + qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade) + if not launcher_executable(): + m.action_upgrade_downgrade.setVisible(False) qconnect(m.actionPreferences.triggered, self.onPrefs) # View @@ -1708,11 +1719,37 @@ title="{}" {}>{}""".format( self.maybeHideAccelerators() self.hideStatusTips() elif is_win: - # make sure ctypes is bundled - from ctypes import windll, wintypes # type: ignore + self._setupWin32() - _dummy1 = windll - _dummy2 = wintypes + def _setupWin32(self): + """Fix taskbar display/pinning""" + if sys.platform != "win32": + return + + launcher_path = os.environ.get("ANKI_LAUNCHER") + if not launcher_path: + return + + from win32com.propsys import propsys, pscon + from win32com.propsys.propsys import PROPVARIANTType + + hwnd = int(self.winId()) + prop_store = propsys.SHGetPropertyStoreForWindow(hwnd) # type: ignore[call-arg] + prop_store.SetValue( + pscon.PKEY_AppUserModel_ID, PROPVARIANTType("Ankitects.Anki") + ) + prop_store.SetValue( + pscon.PKEY_AppUserModel_RelaunchCommand, + PROPVARIANTType(f'"{launcher_path}"'), + ) + prop_store.SetValue( + pscon.PKEY_AppUserModel_RelaunchDisplayNameResource, PROPVARIANTType("Anki") + ) + prop_store.SetValue( + pscon.PKEY_AppUserModel_RelaunchIconResource, + PROPVARIANTType(f"{launcher_path},0"), + ) + prop_store.Commit() def maybeHideAccelerators(self, tgt: Any | None = None) -> None: if not self.hideMenuAccels: diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 69ef054ec..d1d55e232 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -230,7 +230,11 @@ def _handle_local_file_request(request: LocalFileRequest) -> Response: else: max_age = 60 * 60 return flask.send_file( - fullpath, mimetype=mimetype, conditional=True, max_age=max_age, download_name="foo" # type: ignore[call-arg] + fullpath, + mimetype=mimetype, + conditional=True, + max_age=max_age, + download_name="foo", # type: ignore[call-arg] ) else: print(f"Not found: {path}") @@ -647,9 +651,10 @@ exposed_backend_list = [ "compute_fsrs_params", "compute_optimal_retention", "set_wants_abort", - "evaluate_params", + "evaluate_params_legacy", "get_optimal_retention_parameters", "simulate_fsrs_review", + "simulate_fsrs_workload", # DeckConfigService "get_ignored_before_count", "get_retention_workload", diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index e86675e41..2586d024a 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -24,7 +24,7 @@ # # ------------------------------------------------------------------------------ -# pylint: disable=raise-missing-from + from __future__ import annotations import inspect @@ -66,7 +66,6 @@ class MPVTimeoutError(MPVError): if is_win: - # pylint: disable=import-error import pywintypes import win32file # pytype: disable=import-error import win32job @@ -138,15 +137,15 @@ class MPVBase: extended_info = win32job.QueryInformationJobObject( self._job, win32job.JobObjectExtendedLimitInformation ) - extended_info["BasicLimitInformation"][ - "LimitFlags" - ] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + extended_info["BasicLimitInformation"]["LimitFlags"] = ( + win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) win32job.SetInformationJobObject( self._job, win32job.JobObjectExtendedLimitInformation, extended_info, ) - handle = self._proc._handle # pylint: disable=no-member + handle = self._proc._handle win32job.AssignProcessToJobObject(self._job, handle) def _stop_process(self): @@ -193,7 +192,10 @@ class MPVBase: None, ) win32pipe.SetNamedPipeHandleState( - self._sock, 1, None, None # PIPE_NOWAIT + self._sock, + 1, + None, + None, # PIPE_NOWAIT ) except pywintypes.error as err: if err.args[0] == winerror.ERROR_FILE_NOT_FOUND: @@ -394,7 +396,7 @@ class MPVBase: return self._get_response(timeout) except MPVCommandError as e: raise MPVCommandError(f"{message['command']!r}: {e}") - except Exception as e: + except Exception: if _retry: print("mpv timed out, restarting") self._stop_process() @@ -501,7 +503,6 @@ class MPV(MPVBase): # Simulate an init event when the process and all callbacks have been # completely set up. if hasattr(self, "on_init"): - # pylint: disable=no-member self.on_init() # diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index 184a51cf5..b1fc9a119 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -113,7 +113,7 @@ class Overview: self.mw.moveToState("deckBrowser") elif url == "review": openLink(f"{aqt.appShared}info/{self.sid}?v={self.sidVer}") - elif url == "studymore" or url == "customStudy": + elif url in {"studymore", "customStudy"}: self.onStudyMore() elif url == "unbury": self.on_unbury() @@ -180,7 +180,6 @@ class Overview: ############################################################ def _renderPage(self) -> None: - but = self.mw.button deck = self.mw.col.decks.current() self.sid = deck.get("sharedFrom") if self.sid: @@ -307,9 +306,7 @@ class Overview: if b[0]: b[0] = tr.actions_shortcut_key(val=shortcut(b[0])) buf += """ -""" % tuple( - b - ) +""" % tuple(b) self.bottom.draw( buf=buf, link_handler=link_handler, diff --git a/qt/aqt/package.py b/qt/aqt/package.py index a0642fca0..5d349c473 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -5,13 +5,16 @@ from __future__ import annotations +import contextlib +import os import subprocess +import sys from pathlib import Path -from anki.utils import is_mac +from anki.utils import is_mac, is_win -# pylint: disable=unused-import,import-error +# ruff: noqa: F401 def first_run_setup() -> None: """Code run the first time after install/upgrade. @@ -23,47 +26,148 @@ def first_run_setup() -> None: if not is_mac: return - def _dot(): - print(".", flush=True, end="") - - _dot() - import anki.collection - - _dot() - import PyQt6.sip - - _dot() - import PyQt6.QtCore - - _dot() - import PyQt6.QtGui - - _dot() - import PyQt6.QtNetwork - - _dot() - import PyQt6.QtQuick - - _dot() - import PyQt6.QtWebChannel - - _dot() - import PyQt6.QtWebEngineCore - - _dot() - import PyQt6.QtWebEngineWidgets - - _dot() + # Import anki_audio first and spawn commands import anki_audio - import PyQt6.QtWidgets audio_pkg_path = Path(anki_audio.__file__).parent - # Invoke mpv and lame - cmd = [Path(""), "--version"] + # Start mpv and lame commands concurrently + processes = [] for cmd_name in ["mpv", "lame"]: - _dot() - cmd[0] = audio_pkg_path / cmd_name - subprocess.run([str(cmd[0]), str(cmd[1])], check=True, capture_output=True) + cmd_path = audio_pkg_path / cmd_name + proc = subprocess.Popen( + [str(cmd_path), "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + processes.append(proc) - print() + # Continue with other imports while commands run + import concurrent.futures + + import bs4 + import flask + import flask_cors + import markdown + import PyQt6.QtCore + import PyQt6.QtGui + import PyQt6.QtNetwork + import PyQt6.QtQuick + import PyQt6.QtWebChannel + import PyQt6.QtWebEngineCore + import PyQt6.QtWebEngineWidgets + import PyQt6.QtWidgets + import PyQt6.sip + import requests + import waitress + + import anki.collection + + from . import _macos_helper + + # Wait for both commands to complete + for proc in processes: + proc.wait() + + +def uv_binary() -> str | None: + """Return the path to the uv binary.""" + return os.environ.get("ANKI_LAUNCHER_UV") + + +def launcher_root() -> str | None: + """Return the path to the launcher root directory (AnkiProgramFiles).""" + return os.environ.get("UV_PROJECT") + + +def venv_binary(cmd: str) -> str | None: + """Return the path to a binary in the launcher's venv.""" + root = launcher_root() + if not root: + return None + + root_path = Path(root) + if is_win: + binary_path = root_path / ".venv" / "Scripts" / cmd + else: + binary_path = root_path / ".venv" / "bin" / cmd + + return str(binary_path) + + +def add_python_requirements(reqs: list[str]) -> tuple[bool, str]: + """Add Python requirements to the launcher venv using uv add. + + Returns (success, output)""" + + binary = uv_binary() + if not binary: + return (False, "Not in packaged build.") + + uv_cmd = [binary, "add"] + reqs + result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False) + + if result.returncode == 0: + root = launcher_root() + if root: + sync_marker = Path(root) / ".sync_complete" + sync_marker.touch() + + return (True, result.stdout) + else: + return (False, result.stderr) + + +def launcher_executable() -> str | None: + """Return the path to the Anki launcher executable.""" + return os.getenv("ANKI_LAUNCHER") + + +def trigger_launcher_run() -> None: + """Create a trigger file to request launcher UI on next run.""" + try: + root = launcher_root() + if not root: + return + + trigger_path = Path(root) / ".want-launcher" + trigger_path.touch() + except Exception as e: + print(e) + + +def update_and_restart() -> None: + """Update and restart Anki using the launcher.""" + from aqt import mw + + launcher = launcher_executable() + assert launcher + + trigger_launcher_run() + + with contextlib.suppress(ResourceWarning): + env = os.environ.copy() + env["ANKI_LAUNCHER_WANT_TERMINAL"] = "1" + # fixes a bug where launcher fails to appear if opening it + # straight after updating + if "GNOME_TERMINAL_SCREEN" in env: + del env["GNOME_TERMINAL_SCREEN"] + creationflags = 0 + if sys.platform == "win32": + creationflags = ( + subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + ) + # On Windows 10, changing the handles breaks ANSI display + io = None if sys.platform == "win32" else subprocess.DEVNULL + + subprocess.Popen( + [launcher], + start_new_session=True, + stdin=io, + stdout=io, + stderr=io, + env=env, + creationflags=creationflags, + ) + + mw.app.quit() diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index 60aba18bb..4037b8240 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -82,11 +82,14 @@ class Preferences(QDialog): ) group = self.form.preferences_answer_keys group.setLayout(layout := QFormLayout()) + tab_widget: QWidget = self.form.url_schemes for ease, label in ease_labels: layout.addRow( label, line_edit := QLineEdit(self.mw.pm.get_answer_key(ease) or ""), ) + QWidget.setTabOrder(tab_widget, line_edit) + tab_widget = line_edit qconnect( line_edit.textChanged, functools.partial(self.mw.pm.set_answer_key, ease), diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 7c3a8bb57..9d486d3e7 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -128,7 +128,7 @@ class ProfileManager: default_answer_keys = {ease_num: str(ease_num) for ease_num in range(1, 5)} last_run_version: int = 0 - def __init__(self, base: Path) -> None: # + def __init__(self, base: Path) -> None: "base should be retrieved via ProfileMangager.get_created_base_folder" ## Settings which should be forgotten each Anki restart self.session: dict[str, Any] = {} @@ -153,7 +153,7 @@ class ProfileManager: else: try: self.load(profile) - except Exception as exc: + except Exception: self.invalid_profile_provided_on_commandline = True # Profile load/save @@ -189,11 +189,8 @@ class ProfileManager: # return the bytes directly return args[0] elif name == "_unpickle_enum": - if qtmajor == 5: - return sip._unpickle_enum(module, klass, args) # type: ignore - else: - # old style enums can't be unpickled - return None + # old style enums can't be unpickled + return None else: return sip._unpickle_type(module, klass, args) # type: ignore @@ -486,7 +483,11 @@ create table if not exists profiles code = obj[1] name = obj[0] r = QMessageBox.question( - None, "Anki", tr.profiles_confirm_lang_choice(lang=name), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No # type: ignore + None, + "Anki", + tr.profiles_confirm_lang_choice(lang=name), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, # type: ignore ) if r != QMessageBox.StandardButton.Yes: return self.setDefaultLang(f.lang.currentRow()) diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index fbb0a7470..cc7e750de 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -119,13 +119,12 @@ class ProgressManager: if not self._levels: # no current progress; safe to fire func() + elif repeat: + # skip this time; we'll fire again + pass else: - if repeat: - # skip this time; we'll fire again - pass - else: - # retry in 100ms - self.single_shot(100, func, requires_collection) + # retry in 100ms + self.single_shot(100, func, requires_collection) return handler @@ -300,8 +299,7 @@ class ProgressManager: def _closeWin(self) -> None: # if the parent window has been deleted, the progress dialog may have # already been dropped; delete it if it hasn't been - if not sip.isdeleted(self._win): - assert self._win is not None + if self._win and not sip.isdeleted(self._win): self._win.cancel() self._win = None self._shown = 0 diff --git a/qt/aqt/qt/__init__.py b/qt/aqt/qt/__init__.py index ea1b4bd46..730bc771b 100644 --- a/qt/aqt/qt/__init__.py +++ b/qt/aqt/qt/__init__.py @@ -2,7 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # make sure not to optimize imports on this file -# pylint: disable=unused-import +# ruff: noqa: F401 from __future__ import annotations import os @@ -11,27 +11,19 @@ import traceback from collections.abc import Callable from typing import TypeVar, Union -try: - import PyQt6 -except Exception: - from .qt5 import * # type: ignore -else: - if os.getenv("ENABLE_QT5_COMPAT"): - print("Running with temporary Qt5 compatibility shims.") - from . import qt5_compat # needs to be imported first - from .qt6 import * +from anki._legacy import deprecated +# legacy code depends on these re-exports from anki.utils import is_mac, is_win -# fix buggy ubuntu12.04 display of language selector -os.environ["LIBOVERLAY_SCROLLBAR"] = "0" +from .qt6 import * def debug() -> None: from pdb import set_trace pyqtRemoveInputHook() - set_trace() # pylint: disable=forgotten-debug-statement + set_trace() if os.environ.get("DEBUG"): @@ -52,7 +44,7 @@ qtminor = _version.minorVersion() qtpoint = _version.microVersion() qtfullversion = _version.segments() -if qtmajor < 5 or (qtmajor == 5 and qtminor < 14): +if qtmajor == 6 and qtminor < 2: raise Exception("Anki does not support your Qt version.") @@ -64,11 +56,6 @@ def qconnect(signal: Callable | pyqtSignal | pyqtBoundSignal, func: Callable) -> _T = TypeVar("_T") +@deprecated(info="no longer required, and now a no-op") def without_qt5_compat_wrapper(cls: _T) -> _T: - """Remove Qt5 compat wrapper from Qt class, if active. - - Only needed for a few Qt APIs that deal with QVariants.""" - if fn := getattr(cls, "_without_compat_wrapper", None): - return fn() - else: - return cls + return cls diff --git a/qt/aqt/qt/qt5.py b/qt/aqt/qt/qt5.py deleted file mode 100644 index 0a45dffb9..000000000 --- a/qt/aqt/qt/qt5.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -# make sure not to optimize imports on this file -# pylint: skip-file - -""" -PyQt5 imports -""" - -from PyQt5.QtCore import * # type: ignore -from PyQt5.QtGui import * # type: ignore -from PyQt5.QtNetwork import QLocalServer, QLocalSocket, QNetworkProxy # type: ignore -from PyQt5.QtWebChannel import QWebChannel # type: ignore -from PyQt5.QtWebEngineCore import * # type: ignore -from PyQt5.QtWebEngineWidgets import * # type: ignore -from PyQt5.QtWidgets import * # type: ignore - -try: - from PyQt5 import sip # type: ignore -except ImportError: - import sip # type: ignore diff --git a/qt/aqt/qt/qt5_audio.py b/qt/aqt/qt/qt5_audio.py deleted file mode 100644 index cc8426a6e..000000000 --- a/qt/aqt/qt/qt5_audio.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -# pylint: skip-file - -""" -PyQt5-only audio code -""" - -import wave -from collections.abc import Callable -from concurrent.futures import Future -from typing import cast - -import aqt - -from . import * # isort:skip -from ..sound import Recorder # isort:skip -from ..utils import showWarning # isort:skip - - -class QtAudioInputRecorder(Recorder): - def __init__(self, output_path: str, mw: aqt.AnkiQt, parent: QWidget) -> None: - super().__init__(output_path) - - self.mw = mw - self._parent = parent - - from PyQt5.QtMultimedia import ( # type: ignore - QAudioDeviceInfo, - QAudioFormat, - QAudioInput, - ) - - format = QAudioFormat() - format.setChannelCount(1) - format.setSampleRate(44100) - format.setSampleSize(16) - format.setCodec("audio/pcm") - format.setByteOrder(QAudioFormat.LittleEndian) - format.setSampleType(QAudioFormat.SignedInt) - - device = QAudioDeviceInfo.defaultInputDevice() - if not device.isFormatSupported(format): - format = device.nearestFormat(format) - print("format changed") - print("channels", format.channelCount()) - print("rate", format.sampleRate()) - print("size", format.sampleSize()) - self._format = format - - self._audio_input = QAudioInput(device, format, parent) - - def start(self, on_done: Callable[[], None]) -> None: - self._iodevice = self._audio_input.start() - self._buffer = bytearray() - qconnect(self._iodevice.readyRead, self._on_read_ready) - super().start(on_done) - - def _on_read_ready(self) -> None: - self._buffer.extend(cast(bytes, self._iodevice.readAll())) - - def stop(self, on_done: Callable[[str], None]) -> None: - def on_stop_timer() -> None: - # read anything remaining in buffer & stop - self._on_read_ready() - self._audio_input.stop() - - if err := self._audio_input.error(): - showWarning(f"recording failed: {err}") - return - - def write_file() -> None: - # swallow the first 300ms to allow audio device to quiesce - wait = int(44100 * self.STARTUP_DELAY) - if len(self._buffer) <= wait: - return - self._buffer = self._buffer[wait:] - - # write out the wave file - wf = wave.open(self.output_path, "wb") - wf.setnchannels(self._format.channelCount()) - wf.setsampwidth(self._format.sampleSize() // 8) - wf.setframerate(self._format.sampleRate()) - wf.writeframes(self._buffer) - wf.close() - - def and_then(fut: Future) -> None: - fut.result() - Recorder.stop(self, on_done) - - self.mw.taskman.run_in_background(write_file, and_then) - - # schedule the stop for half a second in the future, - # to avoid truncating the end of the recording - self._stop_timer = t = QTimer(self._parent) - t.timeout.connect(on_stop_timer) # type: ignore - t.setSingleShot(True) - t.start(500) diff --git a/qt/aqt/qt/qt5_compat.py b/qt/aqt/qt/qt5_compat.py deleted file mode 100644 index ef281b87c..000000000 --- a/qt/aqt/qt/qt5_compat.py +++ /dev/null @@ -1,411 +0,0 @@ -# Copyright: Ankitects Pty Ltd and contributors -# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -# type: ignore -# pylint: disable=unused-import - -""" -Patches and aliases that provide a PyQt5 → PyQt6 compatibility shim for add-ons -""" - -import sys -import types -import typing - -import PyQt6.QtCore -import PyQt6.QtDBus -import PyQt6.QtGui -import PyQt6.QtNetwork -import PyQt6.QtPrintSupport -import PyQt6.QtWebChannel -import PyQt6.QtWebEngineCore -import PyQt6.QtWebEngineWidgets -import PyQt6.QtWidgets - -from anki._legacy import print_deprecation_warning - -# Globally alias PyQt5 to PyQt6 -# ######################################################################### - -sys.modules["PyQt5"] = PyQt6 -# Need to alias QtCore explicitly as sip otherwise complains about repeat registration -sys.modules["PyQt5.QtCore"] = PyQt6.QtCore -# Need to alias QtWidgets and QtGui explicitly to facilitate patches -sys.modules["PyQt5.QtGui"] = PyQt6.QtGui -sys.modules["PyQt5.QtWidgets"] = PyQt6.QtWidgets -# Needed to maintain import order between QtWebEngineWidgets and QCoreApplication: -sys.modules["PyQt5.QtWebEngineWidgets"] = PyQt6.QtWebEngineWidgets -# Register other aliased top-level Qt modules just to be safe: -sys.modules["PyQt5.QtWebEngineCore"] = PyQt6.QtWebEngineCore -sys.modules["PyQt5.QtWebChannel"] = PyQt6.QtWebChannel -sys.modules["PyQt5.QtNetwork"] = PyQt6.QtNetwork -# Alias sip -sys.modules["sip"] = PyQt6.sip - -# Restore QWebEnginePage.view() -# ######################################################################## - -from PyQt6.QtWebEngineCore import QWebEnginePage -from PyQt6.QtWebEngineWidgets import QWebEngineView - - -def qwebenginepage_view(page: QWebEnginePage) -> QWebEnginePage: - print_deprecation_warning( - "'QWebEnginePage.view()' is deprecated. " - "Please use 'QWebEngineView.forPage(page)'" - ) - return QWebEngineView.forPage(page) - - -PyQt6.QtWebEngineCore.QWebEnginePage.view = qwebenginepage_view - -# Alias removed exec_ methods to exec -# ######################################################################## - -from PyQt6.QtCore import QCoreApplication, QEventLoop, QThread -from PyQt6.QtGui import QDrag, QGuiApplication -from PyQt6.QtWidgets import QApplication, QDialog, QMenu - - -# This helper function is needed as aliasing exec_ to exec directly will cause -# an unbound method error, even when wrapped with types.MethodType -def qt_exec_(object, *args, **kwargs): - class_name = object.__class__.__name__ - print_deprecation_warning( - f"'{class_name}.exec_()' is deprecated. Please use '{class_name}.exec()'" - ) - return object.exec(*args, **kwargs) - - -QCoreApplication.exec_ = qt_exec_ -QEventLoop.exec_ = qt_exec_ -QThread.exec_ = qt_exec_ -QDrag.exec_ = qt_exec_ -QGuiApplication.exec_ = qt_exec_ -QApplication.exec_ = qt_exec_ -QDialog.exec_ = qt_exec_ -QMenu.exec_ = qt_exec_ - -# Graciously handle removed Qt resource system -# ######################################################################## - -# Given that add-ons mostly use the Qt resource system to equip UI elements with -# icons – which oftentimes are not essential to the core UX –, printing a warning -# instead of preventing the add-on from loading seems appropriate. - - -def qt_resource_system_call(*args, **kwargs): - print_deprecation_warning( - "The Qt resource system no longer works on PyQt6. " - "Use QDir.addSearchPath() or mw.addonManager.setWebExports() instead." - ) - - -PyQt6.QtCore.qRegisterResourceData = qt_resource_system_call -PyQt6.QtCore.qUnregisterResourceData = qt_resource_system_call - -# Patch unscoped enums back in, aliasing them to scoped enums -# ######################################################################## - -PyQt6.QtWidgets.QDockWidget.AllDockWidgetFeatures = ( - PyQt6.QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetClosable - | PyQt6.QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable - | PyQt6.QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetFloatable -) - -# when we subclass QIcon, icons fail to show when returned by getData() -# in a tableview/treeview, so we need to manually alias these -PyQt6.QtGui.QIcon.Active = PyQt6.QtGui.QIcon.Mode.Active -PyQt6.QtGui.QIcon.Disabled = PyQt6.QtGui.QIcon.Mode.Disabled -PyQt6.QtGui.QIcon.Normal = PyQt6.QtGui.QIcon.Mode.Normal -PyQt6.QtGui.QIcon.Selected = PyQt6.QtGui.QIcon.Mode.Selected -PyQt6.QtGui.QIcon.Off = PyQt6.QtGui.QIcon.State.Off -PyQt6.QtGui.QIcon.On = PyQt6.QtGui.QIcon.State.On - -# This is the subset of enums used in all public Anki add-ons as of 2021-10-19. -# Please note that this list is likely to be incomplete as the process used to -# find them probably missed dynamically constructed enums. -# Also, as mostly only public Anki add-ons were taken into consideration, -# some enums in other add-ons might not be included. In those cases please -# consider filing a PR to extend the assignments below. - -# Important: These patches are not meant to provide compatibility for all -# add-ons going forward, but simply to maintain support with already -# existing add-ons. Add-on authors should take heed to use scoped enums -# in any future code changes. - -# (module, [(type_name, enums)]) -_enum_map = ( - ( - PyQt6.QtCore, - [ - ("QEvent", ("Type",)), - ("QEventLoop", ("ProcessEventsFlag",)), - ("QIODevice", ("OpenModeFlag",)), - ("QItemSelectionModel", ("SelectionFlag",)), - ("QLocale", ("Country", "Language")), - ("QMetaType", ("Type",)), - ("QProcess", ("ProcessState", "ProcessChannel")), - ("QStandardPaths", ("StandardLocation",)), - ( - "Qt", - ( - "AlignmentFlag", - "ApplicationAttribute", - "ArrowType", - "AspectRatioMode", - "BrushStyle", - "CaseSensitivity", - "CheckState", - "ConnectionType", - "ContextMenuPolicy", - "CursorShape", - "DateFormat", - "DayOfWeek", - "DockWidgetArea", - "FindChildOption", - "FocusPolicy", - "FocusReason", - "GlobalColor", - "HighDpiScaleFactorRoundingPolicy", - "ImageConversionFlag", - "InputMethodHint", - "ItemDataRole", - "ItemFlag", - "KeyboardModifier", - "LayoutDirection", - "MatchFlag", - "Modifier", - "MouseButton", - "Orientation", - "PenCapStyle", - "PenJoinStyle", - "PenStyle", - "ScrollBarPolicy", - "ShortcutContext", - "SortOrder", - "TextElideMode", - "TextFlag", - "TextFormat", - "TextInteractionFlag", - "ToolBarArea", - "ToolButtonStyle", - "TransformationMode", - "WidgetAttribute", - "WindowModality", - "WindowState", - "WindowType", - "Key", - ), - ), - ("QThread", ("Priority",)), - ], - ), - (PyQt6.QtDBus, [("QDBus", ("CallMode",))]), - ( - PyQt6.QtGui, - [ - ("QAction", ("MenuRole", "ActionEvent")), - ("QClipboard", ("Mode",)), - ("QColor", ("NameFormat",)), - ("QFont", ("Style", "Weight", "StyleHint")), - ("QFontDatabase", ("WritingSystem", "SystemFont")), - ("QImage", ("Format",)), - ("QKeySequence", ("SequenceFormat", "StandardKey")), - ("QMovie", ("CacheMode",)), - ("QPageLayout", ("Orientation",)), - ("QPageSize", ("PageSizeId",)), - ("QPainter", ("RenderHint",)), - ("QPalette", ("ColorRole", "ColorGroup")), - ("QTextCharFormat", ("UnderlineStyle",)), - ("QTextCursor", ("MoveOperation", "MoveMode", "SelectionType")), - ("QTextFormat", ("Property",)), - ("QTextOption", ("WrapMode",)), - ("QValidator", ("State",)), - ], - ), - (PyQt6.QtNetwork, [("QHostAddress", ("SpecialAddress",))]), - (PyQt6.QtPrintSupport, [("QPrinter", ("Unit",))]), - ( - PyQt6.QtWebEngineCore, - [ - ("QWebEnginePage", ("WebWindowType", "FindFlag", "WebAction")), - ("QWebEngineProfile", ("PersistentCookiesPolicy", "HttpCacheType")), - ("QWebEngineScript", ("ScriptWorldId", "InjectionPoint")), - ("QWebEngineSettings", ("FontSize", "WebAttribute")), - ], - ), - ( - PyQt6.QtWidgets, - [ - ( - "QAbstractItemView", - ( - "CursorAction", - "DropIndicatorPosition", - "ScrollMode", - "EditTrigger", - "SelectionMode", - "SelectionBehavior", - "DragDropMode", - "ScrollHint", - ), - ), - ("QAbstractScrollArea", ("SizeAdjustPolicy",)), - ("QAbstractSpinBox", ("ButtonSymbols",)), - ("QBoxLayout", ("Direction",)), - ("QColorDialog", ("ColorDialogOption",)), - ("QComboBox", ("SizeAdjustPolicy", "InsertPolicy")), - ("QCompleter", ("CompletionMode",)), - ("QDateTimeEdit", ("Section",)), - ("QDialog", ("DialogCode",)), - ("QDialogButtonBox", ("StandardButton", "ButtonRole")), - ("QDockWidget", ("DockWidgetFeature",)), - ("QFileDialog", ("Option", "FileMode", "AcceptMode", "DialogLabel")), - ("QFormLayout", ("FieldGrowthPolicy", "ItemRole")), - ("QFrame", ("Shape", "Shadow")), - ("QGraphicsItem", ("GraphicsItemFlag",)), - ("QGraphicsPixmapItem", ("ShapeMode",)), - ("QGraphicsView", ("ViewportAnchor", "DragMode")), - ("QHeaderView", ("ResizeMode",)), - ("QLayout", ("SizeConstraint",)), - ("QLineEdit", ("EchoMode",)), - ( - "QListView", - ("Flow", "BrowserLayout", "ResizeMode", "Movement", "ViewMode"), - ), - ("QListWidgetItem", ("ItemType",)), - ("QMessageBox", ("StandardButton", "Icon", "ButtonRole")), - ("QPlainTextEdit", ("LineWrapMode",)), - ("QProgressBar", ("Direction",)), - ("QRubberBand", ("Shape",)), - ("QSizePolicy", ("ControlType", "Policy")), - ("QSlider", ("TickPosition",)), - ( - "QStyle", - ( - "SubElement", - "ComplexControl", - "StandardPixmap", - "ControlElement", - "PixelMetric", - "StateFlag", - "SubControl", - ), - ), - ("QSystemTrayIcon", ("MessageIcon", "ActivationReason")), - ("QTabBar", ("ButtonPosition",)), - ("QTabWidget", ("TabShape", "TabPosition")), - ("QTextEdit", ("LineWrapMode",)), - ("QToolButton", ("ToolButtonPopupMode",)), - ("QWizard", ("WizardStyle", "WizardOption")), - ], - ), -) - -_renamed_enum_cases = { - "QComboBox": { - "AdjustToMinimumContentsLength": "AdjustToMinimumContentsLengthWithIcon" - }, - "QDialogButtonBox": {"No": "NoButton"}, - "QPainter": {"HighQualityAntialiasing": "Antialiasing"}, - "QPalette": {"Background": "Window", "Foreground": "WindowText"}, - "Qt": {"MatchRegExp": "MatchRegularExpression", "MidButton": "MiddleButton"}, -} - - -# This works by wrapping each enum-containing Qt class (eg QAction) in a proxy. -# When an attribute is missing from the underlying Qt class, __getattr__ is -# called, and we try fetching the attribute from each of the declared enums -# for that module. If a match is found, a deprecation warning is printed. -# -# Looping through enumerations is not particularly efficient on a large type like -# Qt, but we only pay the cost when an attribute is not found. In the worst case, -# it's about 50ms per 1000 failed lookups on the Qt module. - - -def _instrument_type( - module: types.ModuleType, type_name: str, enums: list[str] -) -> None: - type = getattr(module, type_name) - renamed_attrs = _renamed_enum_cases.get(type_name, {}) - - class QtClassProxyType(type.__class__): - def __getattr__(cls, provided_name): # pylint: disable=no-self-argument - # we know this is not an enum - if provided_name == "__pyqtSignature__": - raise AttributeError - - name = renamed_attrs.get(provided_name) or provided_name - - for enum_name in enums: - enum = getattr(type, enum_name) - try: - val = getattr(enum, name) - except AttributeError: - continue - - print_deprecation_warning( - f"'{type_name}.{provided_name}' will stop working. Please use '{type_name}.{enum_name}.{name}' instead." - ) - return val - - return getattr(type, name) - - class QtClassProxy( - type, metaclass=QtClassProxyType - ): # pylint: disable=invalid-metaclass - @staticmethod - def _without_compat_wrapper(): - return type - - setattr(module, type_name, QtClassProxy) - - -for module, type_to_enum_list in _enum_map: - for type_name, enums in type_to_enum_list: - _instrument_type(module, type_name, enums) - -# Alias classes shifted between QtWidgets and QtGui -########################################################################## - -PyQt6.QtWidgets.QAction = PyQt6.QtGui.QAction -PyQt6.QtWidgets.QActionGroup = PyQt6.QtGui.QActionGroup -PyQt6.QtWidgets.QShortcut = PyQt6.QtGui.QShortcut - -# Alias classes shifted between QtWebEngineWidgets and QtWebEngineCore -########################################################################## - -PyQt6.QtWebEngineWidgets.QWebEnginePage = PyQt6.QtWebEngineCore.QWebEnginePage -PyQt6.QtWebEngineWidgets.QWebEngineHistory = PyQt6.QtWebEngineCore.QWebEngineHistory -PyQt6.QtWebEngineWidgets.QWebEngineProfile = PyQt6.QtWebEngineCore.QWebEngineProfile -PyQt6.QtWebEngineWidgets.QWebEngineScript = PyQt6.QtWebEngineCore.QWebEngineScript -PyQt6.QtWebEngineWidgets.QWebEngineScriptCollection = ( - PyQt6.QtWebEngineCore.QWebEngineScriptCollection -) -PyQt6.QtWebEngineWidgets.QWebEngineClientCertificateSelection = ( - PyQt6.QtWebEngineCore.QWebEngineClientCertificateSelection -) -PyQt6.QtWebEngineWidgets.QWebEngineSettings = PyQt6.QtWebEngineCore.QWebEngineSettings -PyQt6.QtWebEngineWidgets.QWebEngineFullScreenRequest = ( - PyQt6.QtWebEngineCore.QWebEngineFullScreenRequest -) -PyQt6.QtWebEngineWidgets.QWebEngineContextMenuData = ( - PyQt6.QtWebEngineCore.QWebEngineContextMenuRequest -) -PyQt6.QtWebEngineWidgets.QWebEngineDownloadItem = ( - PyQt6.QtWebEngineCore.QWebEngineDownloadRequest -) - -# Aliases for other miscellaneous class changes -########################################################################## - -PyQt6.QtCore.QRegExp = PyQt6.QtCore.QRegularExpression - - -# Mock the removed PyQt5.Qt module -########################################################################## - -sys.modules["PyQt5.Qt"] = sys.modules["aqt.qt"] -# support 'from PyQt5 import Qt', as it's an alias to PyQt6 -PyQt6.Qt = sys.modules["aqt.qt"] diff --git a/qt/aqt/qt/qt6.py b/qt/aqt/qt/qt6.py index 2d387aabf..dabed757b 100644 --- a/qt/aqt/qt/qt6.py +++ b/qt/aqt/qt/qt6.py @@ -2,8 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # make sure not to optimize imports on this file -# pylint: disable=unused-import - +# ruff: noqa: F401 """ PyQt6 imports """ diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 6e34a7931..a8839c598 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -21,13 +21,11 @@ from anki.scheduler.base import ScheduleCardsAsNew from anki.scheduler.v3 import ( CardAnswer, QueuedCards, -) -from anki.scheduler.v3 import Scheduler as V3Scheduler -from anki.scheduler.v3 import ( SchedulingContext, SchedulingStates, SetSchedulingStatesRequest, ) +from anki.scheduler.v3 import Scheduler as V3Scheduler from anki.tags import MARKED_TAG from anki.types import assert_exhaustive from anki.utils import is_mac @@ -597,10 +595,9 @@ class Reviewer: def _shortcutKeys( self, ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: - - def generate_default_answer_keys() -> ( - Generator[tuple[str, partial], None, None] - ): + def generate_default_answer_keys() -> Generator[ + tuple[str, partial], None, None + ]: for ease in aqt.mw.pm.default_answer_keys: key = aqt.mw.pm.get_answer_key(ease) if not key: diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 5753ab234..25fe07e53 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -101,7 +101,7 @@ def is_audio_file(fname: str) -> bool: return ext in AUDIO_EXTENSIONS -class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method +class SoundOrVideoPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: @@ -111,7 +111,7 @@ class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method return None -class SoundPlayer(Player): # pylint: disable=abstract-method +class SoundPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: @@ -121,7 +121,7 @@ class SoundPlayer(Player): # pylint: disable=abstract-method return None -class VideoPlayer(Player): # pylint: disable=abstract-method +class VideoPlayer(Player): default_rank = 0 def rank_for_tag(self, tag: AVTag) -> int | None: @@ -324,7 +324,7 @@ def retryWait(proc: subprocess.Popen) -> int: ########################################################################## -class SimpleProcessPlayer(Player): # pylint: disable=abstract-method +class SimpleProcessPlayer(Player): "A player that invokes a new process for each tag to play." args: list[str] = [] @@ -633,7 +633,7 @@ class QtAudioInputRecorder(Recorder): from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore format = QAudioFormat() - format.setChannelCount(1) + format.setChannelCount(2) format.setSampleRate(44100) format.setSampleFormat(QAudioFormat.SampleFormat.Int16) @@ -772,19 +772,14 @@ class RecordDialog(QDialog): saveGeom(self, "audioRecorder2") def _start_recording(self) -> None: - if qtmajor > 5: - if macos_helper and platform.machine() == "arm64": - self._recorder = NativeMacRecorder( - namedtmp("rec.wav"), - ) - else: - self._recorder = QtAudioInputRecorder( - namedtmp("rec.wav"), self.mw, self._parent - ) + if macos_helper and platform.machine() == "arm64": + self._recorder = NativeMacRecorder( + namedtmp("rec.wav"), + ) else: - from aqt.qt.qt5_audio import QtAudioInputRecorder as Qt5Recorder - - self._recorder = Qt5Recorder(namedtmp("rec.wav"), self.mw, self._parent) + self._recorder = QtAudioInputRecorder( + namedtmp("rec.wav"), self.mw, self._parent + ) self._recorder.start(self._start_timer) def _start_timer(self) -> None: diff --git a/qt/aqt/stylesheets.py b/qt/aqt/stylesheets.py index 0721e76d2..6b4eff1f5 100644 --- a/qt/aqt/stylesheets.py +++ b/qt/aqt/stylesheets.py @@ -177,9 +177,13 @@ class CustomStyles: QPushButton:default {{ border: 1px solid {tm.var(colors.BORDER_FOCUS)}; }} + QPushButton {{ + margin: 1px; + }} QPushButton:focus {{ border: 2px solid {tm.var(colors.BORDER_FOCUS)}; outline: none; + margin: 0px; }} QPushButton:hover, QTabBar::tab:hover, @@ -208,7 +212,7 @@ class CustomStyles: button_pressed_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), - tm.var(colors.SHADOW) + tm.var(colors.SHADOW), ) }; }} @@ -340,7 +344,7 @@ class CustomStyles: }} QTabBar::tab:selected:hover {{ background: { - button_gradient( + button_gradient( tm.var(colors.BUTTON_PRIMARY_GRADIENT_START), tm.var(colors.BUTTON_PRIMARY_GRADIENT_END), ) @@ -391,7 +395,7 @@ class CustomStyles: button_pressed_gradient( tm.var(colors.BUTTON_GRADIENT_START), tm.var(colors.BUTTON_GRADIENT_END), - tm.var(colors.SHADOW) + tm.var(colors.SHADOW), ) } }} @@ -647,10 +651,12 @@ class CustomStyles: margin: -7px 0; }} QSlider::handle:hover {{ - background: {button_gradient( - tm.var(colors.BUTTON_GRADIENT_START), - tm.var(colors.BUTTON_GRADIENT_END), - )} + background: { + button_gradient( + tm.var(colors.BUTTON_GRADIENT_START), + tm.var(colors.BUTTON_GRADIENT_END), + ) + } }} """ diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py index 5a4d5fd4c..94ce0c8c1 100644 --- a/qt/aqt/sync.py +++ b/qt/aqt/sync.py @@ -44,7 +44,7 @@ def get_sync_status( ) -> None: auth = mw.pm.sync_auth() if not auth: - callback(SyncStatus(required=SyncStatus.NO_CHANGES)) # pylint:disable=no-member + callback(SyncStatus(required=SyncStatus.NO_CHANGES)) return def on_future_done(fut: Future[SyncStatus]) -> None: @@ -118,7 +118,7 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: if out.new_endpoint: mw.pm.set_current_sync_url(out.new_endpoint) if out.server_message: - showText(out.server_message) + showText(out.server_message, parent=mw) if out.required == out.NO_CHANGES: tooltip(parent=mw, msg=tr.sync_collection_complete()) # all done; track media progress @@ -302,7 +302,6 @@ def sync_login( username: str = "", password: str = "", ) -> None: - def on_future_done(fut: Future[SyncAuth], username: str, password: str) -> None: try: auth = fut.result() @@ -374,7 +373,9 @@ def get_id_and_pass_from_user( g.addWidget(passwd, 1, 1) l2.setBuddy(passwd) vbox.addLayout(g) - bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) # type: ignore + bb = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) # type: ignore ok_button = bb.button(QDialogButtonBox.StandardButton.Ok) assert ok_button is not None ok_button.setAutoDefault(True) diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py index e06cf71c2..675eb9345 100644 --- a/qt/aqt/theme.py +++ b/qt/aqt/theme.py @@ -187,7 +187,7 @@ class ThemeManager: self, card_ord: int, night_mode: bool | None = None ) -> str: "Returns body classes used when showing a card." - return f"card card{card_ord+1} {self.body_class(night_mode, reviewer=True)}" + return f"card card{card_ord + 1} {self.body_class(night_mode, reviewer=True)}" def var(self, vars: dict[str, str]) -> str: """Given day/night colors/props, return the correct one for the current theme.""" @@ -213,13 +213,12 @@ class ThemeManager: return False elif theme == Theme.DARK: return True + elif is_win: + return get_windows_dark_mode() + elif is_mac: + return get_macos_dark_mode() else: - if is_win: - return get_windows_dark_mode() - elif is_mac: - return get_macos_dark_mode() - else: - return get_linux_dark_mode() + return get_linux_dark_mode() def apply_style(self) -> None: "Apply currently configured style." @@ -340,7 +339,7 @@ def get_windows_dark_mode() -> bool: if not is_win: return False - from winreg import ( # type: ignore[attr-defined] # pylint: disable=import-error + from winreg import ( # type: ignore[attr-defined] HKEY_CURRENT_USER, OpenKey, QueryValueEx, @@ -352,7 +351,7 @@ def get_windows_dark_mode() -> bool: r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", ) return not QueryValueEx(key, "AppsUseLightTheme")[0] - except Exception as err: + except Exception: # key reportedly missing or set to wrong type on some systems return False @@ -416,12 +415,12 @@ def get_linux_dark_mode() -> bool: capture_output=True, encoding="utf8", ) - except FileNotFoundError as e: + except FileNotFoundError: # detection strategy failed, missing program # print(e) continue - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError: # detection strategy failed, command returned error # print(e) continue diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index 44f2ee66c..be547b5ba 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -87,6 +87,7 @@ class TopWebView(ToolbarWebView): else: self.flatten() + self.adjustHeightToFit() self.show() def _onHeight(self, qvar: int | None) -> None: diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index 079a5e3de..d559fb41f 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -166,7 +166,6 @@ class MacVoice(TTSVoice): original_name: str -# pylint: disable=no-member class MacTTSPlayer(TTSProcessPlayer): "Invokes a process to play the audio in the background." @@ -487,7 +486,7 @@ if is_win: class WindowsTTSPlayer(TTSProcessPlayer): default_rank = -1 try: - import win32com.client # pylint: disable=import-error + import win32com.client speaker = win32com.client.Dispatch("SAPI.SpVoice") except Exception as exc: diff --git a/qt/aqt/update.py b/qt/aqt/update.py index fd0e4eafd..e5794eead 100644 --- a/qt/aqt/update.py +++ b/qt/aqt/update.py @@ -1,11 +1,19 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations + import aqt from anki.buildinfo import buildhash from anki.collection import CheckForUpdateResponse, Collection from anki.utils import dev_mode, int_time, int_version, plat_desc from aqt.operations import QueryOp +from aqt.package import ( + launcher_executable as _launcher_executable, +) +from aqt.package import ( + update_and_restart as _update_and_restart, +) from aqt.qt import * from aqt.utils import openLink, show_warning, showText, tr @@ -77,4 +85,7 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None: # ignore this update mw.pm.meta["suppressUpdate"] = ver elif ret == QMessageBox.StandardButton.Yes: - openLink(aqt.appWebsiteDownloadSection) + if _launcher_executable(): + _update_and_restart() + else: + openLink(aqt.appWebsiteDownloadSection) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index e17550fc0..64d057082 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -19,7 +19,7 @@ from send2trash import send2trash import aqt from anki._legacy import DeprecatedNamesMixinForModule from anki.collection import Collection, HelpPage -from anki.lang import TR, tr_legacyglobal # pylint: disable=unused-import +from anki.lang import TR, tr_legacyglobal # noqa: F401 from anki.utils import ( call, invalid_filename, @@ -31,7 +31,7 @@ from anki.utils import ( from aqt.qt import * from aqt.qt import ( PYQT_VERSION_STR, - QT_VERSION_STR, + QT_VERSION_STR, # noqa: F401 QAction, QApplication, QCheckBox, @@ -294,7 +294,7 @@ def showInfo( icon = QMessageBox.Icon.Critical else: icon = QMessageBox.Icon.Information - mb = QMessageBox(parent_widget) # + mb = QMessageBox(parent_widget) if textFormat == "plain": mb.setTextFormat(Qt.TextFormat.PlainText) elif textFormat == "rich": @@ -936,14 +936,39 @@ def show_in_folder(path: str) -> None: """ call(osascript_to_args(script)) else: - # Just open the file in any other platform - with no_bundled_libs(): - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + # For linux, there are multiple file managers. Let's test if one of the + # most common file managers is found and use it in case it is installed. + # If none of this list are installed, use a fallback. The fallback + # might open the image in a web browser, image viewer or others, + # depending on the users defaults. + file_managers = [ + "nautilus", # GNOME + "dolphin", # KDE + "pcmanfm", # LXDE + "thunar", # XFCE + "nemo", # Cinnamon + "caja", # MATE + ] + + available_file_manager = None + + # Test if a file manager is installed and use it, fallback otherwise + for file_manager in file_managers: + if shutil.which(file_manager): + available_file_manager = file_manager + break + + if available_file_manager: + subprocess.run([available_file_manager, path], check=False) + else: + # Just open the file in any other platform + with no_bundled_libs(): + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) def _show_in_folder_win32(path: str) -> None: - import win32con # pylint: disable=import-error - import win32gui # pylint: disable=import-error + import win32con + import win32gui from aqt import mw @@ -1238,12 +1263,12 @@ def opengl_vendor() -> str | None: # Can't use versionFunctions there return None - vp = QOpenGLVersionProfile() # type: ignore # pylint: disable=undefined-variable + vp = QOpenGLVersionProfile() # type: ignore vp.setVersion(2, 0) try: vf = ctx.versionFunctions(vp) # type: ignore - except ImportError as e: + except ImportError: return None if vf is None: diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 966d3de5a..95d84c00e 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -980,7 +980,6 @@ def _create_ankiwebview_subclass( /, **fixed_kwargs: Unpack[_AnkiWebViewKwargs], ) -> Type[AnkiWebView]: - def __init__(self, *args: Any, **kwargs: _AnkiWebViewKwargs) -> None: # user‑supplied kwargs override fixed kwargs merged = cast(_AnkiWebViewKwargs, {**fixed_kwargs, **kwargs}) diff --git a/qt/aqt/winpaths.py b/qt/aqt/winpaths.py index e53a47c06..8b2698739 100644 --- a/qt/aqt/winpaths.py +++ b/qt/aqt/winpaths.py @@ -100,7 +100,7 @@ _SHGetFolderPath.restype = _err_unless_zero def _get_path_buf(csidl): path_buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH) - result = _SHGetFolderPath(0, csidl, 0, 0, path_buf) + _SHGetFolderPath(0, csidl, 0, 0, path_buf) return path_buf.value diff --git a/qt/hatch_build.py b/qt/hatch_build.py index e475b5d84..1bc9eccd7 100644 --- a/qt/hatch_build.py +++ b/qt/hatch_build.py @@ -18,6 +18,9 @@ class CustomBuildHook(BuildHookInterface): """Initialize the build hook.""" force_include = build_data.setdefault("force_include", {}) + # Pin anki== + self._set_anki_dependency(version, build_data) + # Look for generated files in out/qt/_aqt project_root = Path(self.root).parent generated_root = project_root / "out" / "qt" / "_aqt" @@ -30,6 +33,29 @@ class CustomBuildHook(BuildHookInterface): assert generated_root.exists(), "you should build with --wheel" self._add_aqt_files(force_include, generated_root) + def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None: + # Get current dependencies and replace 'anki' with exact version + dependencies = build_data.setdefault("dependencies", []) + + # Remove any existing anki dependency + dependencies[:] = [dep for dep in dependencies if not dep.startswith("anki")] + + # Handle version detection + actual_version = version + if version == "standard": + # Read actual version from .version file + project_root = Path(self.root).parent + version_file = project_root / ".version" + if version_file.exists(): + actual_version = version_file.read_text().strip() + + # Only add exact version for real releases, not editable installs + if actual_version != "editable": + dependencies.append(f"anki=={actual_version}") + else: + # For editable installs, just add anki without version constraint + dependencies.append("anki") + def _add_aqt_files(self, force_include: Dict[str, str], aqt_root: Path) -> None: """Add _aqt files to the build.""" for path in aqt_root.rglob("*"): @@ -41,11 +67,12 @@ class CustomBuildHook(BuildHookInterface): def _should_exclude(self, path: Path) -> bool: """Check if a file should be excluded from the wheel.""" - # Match the exclusions from write_wheel.py exclude_aqt function + # Exclude __pycache__ + if "/__pycache__/" in str(path): + return True + if path.suffix in [".ui", ".scss", ".map", ".ts"]: return True if path.name.startswith("tsconfig"): return True - if "/aqt/data" in str(path): - return True return False diff --git a/qt/launcher/Cargo.toml b/qt/launcher/Cargo.toml index 45ca11e9b..7de321a29 100644 --- a/qt/launcher/Cargo.toml +++ b/qt/launcher/Cargo.toml @@ -11,10 +11,16 @@ rust-version.workspace = true anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true +camino.workspace = true dirs.workspace = true +serde_json.workspace = true + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +libc.workspace = true [target.'cfg(windows)'.dependencies] -winapi.workspace = true +windows.workspace = true +widestring.workspace = true libc.workspace = true libc-stdhandle.workspace = true @@ -22,5 +28,9 @@ libc-stdhandle.workspace = true name = "build_win" path = "src/bin/build_win.rs" +[[bin]] +name = "anki-console" +path = "src/bin/anki_console.rs" + [target.'cfg(windows)'.build-dependencies] embed-resource.workspace = true diff --git a/qt/launcher/addon/__init__.py b/qt/launcher/addon/__init__.py new file mode 100644 index 000000000..4ef348b9f --- /dev/null +++ b/qt/launcher/addon/__init__.py @@ -0,0 +1,193 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import contextlib +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from anki.utils import pointVersion +from aqt import mw +from aqt.qt import QAction +from aqt.utils import askUser, is_mac, is_win, showInfo + + +def launcher_executable() -> str | None: + """Return the path to the Anki launcher executable.""" + return os.getenv("ANKI_LAUNCHER") + + +def uv_binary() -> str | None: + """Return the path to the uv binary.""" + return os.environ.get("ANKI_LAUNCHER_UV") + + +def launcher_root() -> str | None: + """Return the path to the launcher root directory (AnkiProgramFiles).""" + return os.environ.get("UV_PROJECT") + + +def venv_binary(cmd: str) -> str | None: + """Return the path to a binary in the launcher's venv.""" + root = launcher_root() + if not root: + return None + + root_path = Path(root) + if is_win: + binary_path = root_path / ".venv" / "Scripts" / cmd + else: + binary_path = root_path / ".venv" / "bin" / cmd + + return str(binary_path) + + +def add_python_requirements(reqs: list[str]) -> tuple[bool, str]: + """Add Python requirements to the launcher venv using uv add. + + Returns (success, output)""" + + binary = uv_binary() + if not binary: + return (False, "Not in packaged build.") + + uv_cmd = [binary, "add"] + reqs + result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False) + + if result.returncode == 0: + root = launcher_root() + if root: + sync_marker = Path(root) / ".sync_complete" + sync_marker.touch() + return (True, result.stdout) + else: + return (False, result.stderr) + + +def trigger_launcher_run() -> None: + """Create a trigger file to request launcher UI on next run.""" + try: + root = launcher_root() + if not root: + return + + trigger_path = Path(root) / ".want-launcher" + trigger_path.touch() + except Exception as e: + print(e) + + +def update_and_restart() -> None: + """Update and restart Anki using the launcher.""" + launcher = launcher_executable() + assert launcher + + trigger_launcher_run() + + with contextlib.suppress(ResourceWarning): + env = os.environ.copy() + env["ANKI_LAUNCHER_WANT_TERMINAL"] = "1" + creationflags = 0 + if sys.platform == "win32": + creationflags = ( + subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + ) + # On Windows, changing the handles breaks ANSI display + io = None if sys.platform == "win32" else subprocess.DEVNULL + + subprocess.Popen( + [launcher], + start_new_session=True, + stdin=io, + stdout=io, + stderr=io, + env=env, + creationflags=creationflags, + ) + + mw.app.quit() + + +def confirm_then_upgrade(): + if not askUser("Change to a different Anki version?"): + return + update_and_restart() + + +# return modified command array that points to bundled command, and return +# required environment +def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]: + cmd = cmd[:] + env = os.environ.copy() + # keep LD_LIBRARY_PATH when in snap environment + if "LD_LIBRARY_PATH" in env and "SNAP" not in env: + del env["LD_LIBRARY_PATH"] + + # Try to find binary in anki-audio package for Windows/Mac + if is_win or is_mac: + try: + import anki_audio + + audio_pkg_path = Path(anki_audio.__file__).parent + if is_win: + packaged_path = audio_pkg_path / (cmd[0] + ".exe") + else: # is_mac + packaged_path = audio_pkg_path / cmd[0] + + if packaged_path.exists(): + cmd[0] = str(packaged_path) + return cmd, env + except ImportError: + # anki-audio not available, fall back to old behavior + pass + + packaged_path = Path(sys.prefix) / cmd[0] + if packaged_path.exists(): + cmd[0] = str(packaged_path) + + return cmd, env + + +def on_addon_config(): + showInfo( + "This add-on is automatically added when installing older Anki versions, so that they work with the launcher. You can remove it if you wish." + ) + + +def setup(): + mw.addonManager.setConfigAction(__name__, on_addon_config) + + if pointVersion() >= 250600: + return + if not launcher_executable(): + return + + # Add action to tools menu + action = QAction("Upgrade/Downgrade", mw) + action.triggered.connect(confirm_then_upgrade) + mw.form.menuTools.addAction(action) + + # Monkey-patch audio tools to use anki-audio + if is_win or is_mac: + import aqt + import aqt.sound + + aqt.sound._packagedCmd = _packagedCmd + + # Inject launcher functions into launcher module + import aqt.package + + aqt.package.launcher_executable = launcher_executable + aqt.package.update_and_restart = update_and_restart + aqt.package.trigger_launcher_run = trigger_launcher_run + aqt.package.uv_binary = uv_binary + aqt.package.launcher_root = launcher_root + aqt.package.venv_binary = venv_binary + aqt.package.add_python_requirements = add_python_requirements + + +setup() diff --git a/qt/launcher/addon/manifest.json b/qt/launcher/addon/manifest.json new file mode 100644 index 000000000..b4f08e70d --- /dev/null +++ b/qt/launcher/addon/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Anki Launcher", + "package": "anki-launcher", + "min_point_version": 50, + "max_point_version": 250600 +} diff --git a/qt/launcher/lin/build.sh b/qt/launcher/lin/build.sh index e4ddce243..f38f6defe 100755 --- a/qt/launcher/lin/build.sh +++ b/qt/launcher/lin/build.sh @@ -1,22 +1,36 @@ #!/bin/bash +# +# This script currently only supports universal builds on x86_64. +# set -e # Add Linux cross-compilation target rustup target add aarch64-unknown-linux-gnu +# Detect host architecture +HOST_ARCH=$(uname -m) + # Define output paths OUTPUT_DIR="../../../out/launcher" -LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher" +ANKI_VERSION=$(cat ../../../.version | tr -d '\n') +LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux" # Clean existing output directory rm -rf "$LAUNCHER_DIR" -# Build binaries for both Linux architectures -cargo build -p launcher --release --target x86_64-unknown-linux-gnu -CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ +# Build binaries based on host architecture +if [ "$HOST_ARCH" = "aarch64" ]; then + # On aarch64 host, only build for aarch64 cargo build -p launcher --release --target aarch64-unknown-linux-gnu -(cd ../../.. && ./ninja extract:uv_lin_arm) +else + # On other hosts, build for both architectures + cargo build -p launcher --release --target x86_64-unknown-linux-gnu + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + cargo build -p launcher --release --target aarch64-unknown-linux-gnu + # Extract uv_lin_arm for cross-compilation + (cd ../../.. && ./ninja extract:uv_lin_arm) +fi # Create output directory mkdir -p "$LAUNCHER_DIR" @@ -24,13 +38,21 @@ mkdir -p "$LAUNCHER_DIR" # Copy binaries and support files TARGET_DIR=${CARGO_TARGET_DIR:-../../../target} -# Copy launcher binaries with architecture suffixes -cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" -cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" - -# Copy uv binaries with architecture suffixes -cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" -cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64" +# Copy binaries with architecture suffixes +if [ "$HOST_ARCH" = "aarch64" ]; then + # On aarch64 host, copy arm64 binary to both locations + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" + # Copy uv binary to both locations + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.arm64" +else + # On other hosts, copy architecture-specific binaries + cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" + cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64" +fi # Copy support files from lin directory for file in README.md anki.1 anki.desktop anki.png anki.xml anki.xpm install.sh uninstall.sh anki; do @@ -40,6 +62,7 @@ done # Copy additional files from parent directory cp ../pyproject.toml "$LAUNCHER_DIR/" cp ../../../.python-version "$LAUNCHER_DIR/" +cp ../versions.py "$LAUNCHER_DIR/" # Set executable permissions chmod +x \ @@ -54,10 +77,9 @@ chmod +x \ # Set proper permissions and create tarball chmod -R a+r "$LAUNCHER_DIR" -# Create tarball using the same options as the Rust template ZSTD="zstd -c --long -T0 -18" -TRANSFORM="s%^.%anki-launcher%S" -TARBALL="$OUTPUT_DIR/anki-launcher.tar.zst" +TRANSFORM="s%^.%anki-launcher-$ANKI_VERSION-linux%S" +TARBALL="$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux.tar.zst" tar -I "$ZSTD" --transform "$TRANSFORM" -cf "$TARBALL" -C "$LAUNCHER_DIR" . diff --git a/qt/launcher/mac/Info.plist b/qt/launcher/mac/Info.plist index 59b67605f..a48960208 100644 --- a/qt/launcher/mac/Info.plist +++ b/qt/launcher/mac/Info.plist @@ -5,9 +5,11 @@ CFBundleDisplayName Anki CFBundleShortVersionString - 1.0 + ANKI_VERSION LSMinimumSystemVersion - 11 + 12 + LSApplicationCategoryType + public.app-category.education CFBundleDocumentTypes diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index 8a60d488d..6143451b4 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -8,6 +8,7 @@ APP_LAUNCHER="$OUTPUT_DIR/Anki.app" rm -rf "$APP_LAUNCHER" # Build binaries for both architectures +rustup target add aarch64-apple-darwin x86_64-apple-darwin cargo build -p launcher --release --target aarch64-apple-darwin cargo build -p launcher --release --target x86_64-apple-darwin (cd ../../.. && ./ninja launcher:uv_universal) @@ -30,24 +31,26 @@ lipo -create \ cp "$OUTPUT_DIR/uv" "$APP_LAUNCHER/Contents/MacOS/" # Copy support files -cp Info.plist "$APP_LAUNCHER/Contents/" +ANKI_VERSION=$(cat ../../../.version | tr -d '\n') +sed "s/ANKI_VERSION/$ANKI_VERSION/g" Info.plist > "$APP_LAUNCHER/Contents/Info.plist" cp icon/Assets.car "$APP_LAUNCHER/Contents/Resources/" cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/" cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/" +cp ../versions.py "$APP_LAUNCHER/Contents/Resources/" -# Codesign -for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do - codesign --force -vvvv -o runtime -s "Developer ID Application:" \ - --entitlements entitlements.python.xml \ - "$i" -done +# Codesign/bundle +if [ -z "$NODMG" ]; then + for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do + codesign --force -vvvv -o runtime -s "Developer ID Application:" \ + --entitlements entitlements.python.xml \ + "$i" + done -# Check -codesign -vvv "$APP_LAUNCHER" -spctl -a "$APP_LAUNCHER" + # Check + codesign -vvv "$APP_LAUNCHER" + spctl -a "$APP_LAUNCHER" -# Notarize -./notarize.sh "$OUTPUT_DIR" - -# Bundle -./dmg/build.sh "$OUTPUT_DIR" \ No newline at end of file + # Notarize and build dmg + ./notarize.sh "$OUTPUT_DIR" + ./dmg/build.sh "$OUTPUT_DIR" +fi \ No newline at end of file diff --git a/qt/launcher/mac/dmg/build.sh b/qt/launcher/mac/dmg/build.sh index 16b48c06a..7eeba9948 100755 --- a/qt/launcher/mac/dmg/build.sh +++ b/qt/launcher/mac/dmg/build.sh @@ -6,7 +6,8 @@ set -e # base folder with Anki.app in it output="$1" dist="$1/tmp" -dmg_path="$output/Anki.dmg" +ANKI_VERSION=$(cat ../../../.version | tr -d '\n') +dmg_path="$output/anki-launcher-$ANKI_VERSION-mac.dmg" if [ -d "/Volumes/Anki" ] then diff --git a/qt/launcher/pyproject.toml b/qt/launcher/pyproject.toml index 6ba027844..cc521b432 100644 --- a/qt/launcher/pyproject.toml +++ b/qt/launcher/pyproject.toml @@ -1,22 +1,8 @@ [project] name = "anki-launcher" -version = "0.1.0" +version = "1.0.0" description = "UV-based launcher for Anki." requires-python = ">=3.9" dependencies = [ "anki-release", - # so we can use testpypi - "anki", - "aqt", ] - -[tool.uv.sources] -anki-release = { index = "testpypi" } -anki = { index = "testpypi" } -aqt = { index = "testpypi" } - -[[tool.uv.index]] -name = "testpypi" -url = "https://test.pypi.org/simple/" -publish-url = "https://test.pypi.org/legacy/" -explicit = true diff --git a/qt/launcher/src/bin/anki_console.rs b/qt/launcher/src/bin/anki_console.rs new file mode 100644 index 000000000..181db920f --- /dev/null +++ b/qt/launcher/src/bin/anki_console.rs @@ -0,0 +1,58 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +#![windows_subsystem = "console"] + +use std::env; +use std::io::stdin; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; + +fn main() { + if let Err(e) = run() { + eprintln!("Error: {e:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let current_exe = env::current_exe().context("Failed to get current executable path")?; + let exe_dir = current_exe + .parent() + .context("Failed to get executable directory")?; + + let anki_exe = exe_dir.join("anki.exe"); + + if !anki_exe.exists() { + anyhow::bail!("anki.exe not found in the same directory"); + } + + // Forward all command line arguments to anki.exe + let args: Vec = env::args().skip(1).collect(); + + let mut cmd = Command::new(&anki_exe); + cmd.args(&args); + + if std::env::var("ANKI_IMPLICIT_CONSOLE").is_err() { + // if directly invoked by the user, signal the launcher that the + // user wants a Python console + std::env::set_var("ANKI_CONSOLE", "1"); + } + + // Wait for the process to complete and forward its exit code + let status = cmd.status().context("Failed to execute anki.exe")?; + if !status.success() { + println!("\nPress enter to close."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + } + + if let Some(code) = status.code() { + std::process::exit(code); + } else { + // Process was terminated by a signal + std::process::exit(1); + } +} diff --git a/qt/launcher/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs index 959034438..4c2ca4413 100644 --- a/qt/launcher/src/bin/build_win.rs +++ b/qt/launcher/src/bin/build_win.rs @@ -22,6 +22,11 @@ const NSIS_PATH: &str = "C:\\Program Files (x86)\\NSIS\\makensis.exe"; fn main() -> Result<()> { println!("Building Windows launcher..."); + // Read version early so it can be used throughout the build process + let version = std::fs::read_to_string("../../../.version")? + .trim() + .to_string(); + let output_dir = PathBuf::from(OUTPUT_DIR); let launcher_exe_dir = PathBuf::from(LAUNCHER_EXE_DIR); let nsis_dir = PathBuf::from(NSIS_DIR); @@ -31,16 +36,20 @@ fn main() -> Result<()> { extract_nsis_plugins()?; copy_files(&output_dir)?; sign_binaries(&output_dir)?; - copy_nsis_files(&nsis_dir)?; + copy_nsis_files(&nsis_dir, &version)?; build_uninstaller(&output_dir, &nsis_dir)?; sign_file(&output_dir.join("uninstall.exe"))?; generate_install_manifest(&output_dir)?; build_installer(&output_dir, &nsis_dir)?; - sign_file(&PathBuf::from("../../../out/launcher_exe/anki-install.exe"))?; + + let installer_filename = format!("anki-launcher-{version}-windows.exe"); + let installer_path = PathBuf::from("../../../out/launcher_exe").join(&installer_filename); + + sign_file(&installer_path)?; println!("Build completed successfully!"); println!("Output directory: {}", output_dir.display()); - println!("Installer: ../../../out/launcher_exe/anki-install.exe"); + println!("Installer: ../../../out/launcher_exe/{installer_filename}"); Ok(()) } @@ -114,10 +123,19 @@ fn copy_files(output_dir: &Path) -> Result<()> { let launcher_dst = output_dir.join("anki.exe"); copy_file(&launcher_src, &launcher_dst)?; - // Copy uv.exe + // Copy anki-console binary + let console_src = + PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/anki-console.exe"); + let console_dst = output_dir.join("anki-console.exe"); + copy_file(&console_src, &console_dst)?; + + // Copy uv.exe and uvw.exe let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_dst = output_dir.join("uv.exe"); copy_file(&uv_src, &uv_dst)?; + let uv_src = PathBuf::from("../../../out/extracted/uv/uvw.exe"); + let uv_dst = output_dir.join("uvw.exe"); + copy_file(&uv_src, &uv_dst)?; println!("Copying support files..."); @@ -130,14 +148,15 @@ fn copy_files(output_dir: &Path) -> Result<()> { output_dir.join(".python-version"), )?; - // Copy anki-console.bat - copy_file("anki-console.bat", output_dir.join("anki-console.bat"))?; + // Copy versions.py + copy_file("../versions.py", output_dir.join("versions.py"))?; Ok(()) } fn sign_binaries(output_dir: &Path) -> Result<()> { sign_file(&output_dir.join("anki.exe"))?; + sign_file(&output_dir.join("anki-console.exe"))?; sign_file(&output_dir.join("uv.exe"))?; Ok(()) } @@ -214,7 +233,7 @@ fn generate_install_manifest(output_dir: &Path) -> Result<()> { // Convert to Windows-style backslashes for NSIS let windows_path = relative_path.display().to_string().replace('/', "\\"); // Use Windows line endings (\r\n) as expected by NSIS - manifest_content.push_str(&format!("{}\r\n", windows_path)); + manifest_content.push_str(&format!("{windows_path}\r\n")); } } } @@ -225,11 +244,13 @@ fn generate_install_manifest(output_dir: &Path) -> Result<()> { Ok(()) } -fn copy_nsis_files(nsis_dir: &Path) -> Result<()> { +fn copy_nsis_files(nsis_dir: &Path, version: &str) -> Result<()> { println!("Copying NSIS support files..."); - // Copy anki.template.nsi as anki.nsi - copy_file("anki.template.nsi", nsis_dir.join("anki.nsi"))?; + // Copy anki.template.nsi as anki.nsi and substitute version placeholders + let template_content = std::fs::read_to_string("anki.template.nsi")?; + let substituted_content = template_content.replace("ANKI_VERSION", version); + write_file(nsis_dir.join("anki.nsi"), substituted_content)?; // Copy fileassoc.nsh copy_file("fileassoc.nsh", nsis_dir.join("fileassoc.nsh"))?; diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 77268ce3a..297df5b8b 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -4,36 +4,81 @@ #![windows_subsystem = "windows"] use std::io::stdin; +use std::io::stdout; +use std::io::Write; use std::process::Command; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use anki_io::copy_file; use anki_io::create_dir_all; -use anki_io::metadata; +use anki_io::modified_time; +use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; -use anki_process::CommandExt; +use anki_io::ToUtf8Path; +use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -use crate::platform::exec_anki; -use crate::platform::get_anki_binary_path; +use crate::platform::ensure_os_supported; +use crate::platform::ensure_terminal_shown; use crate::platform::get_exe_and_resources_dirs; use crate::platform::get_uv_binary_name; -use crate::platform::handle_first_launch; -use crate::platform::handle_terminal_launch; -use crate::platform::initial_terminal_setup; -use crate::platform::launch_anki_detached; +use crate::platform::launch_anki_normally; +use crate::platform::respawn_launcher; mod platform; -#[derive(Debug, Clone, Default)] -pub struct Config { - pub show_console: bool, +struct State { + current_version: Option, + prerelease_marker: std::path::PathBuf, + uv_install_root: std::path::PathBuf, + uv_cache_dir: std::path::PathBuf, + no_cache_marker: std::path::PathBuf, + anki_base_folder: std::path::PathBuf, + uv_path: std::path::PathBuf, + uv_python_install_dir: std::path::PathBuf, + user_pyproject_path: std::path::PathBuf, + user_python_version_path: std::path::PathBuf, + dist_pyproject_path: std::path::PathBuf, + dist_python_version_path: std::path::PathBuf, + 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, + venv_folder: std::path::PathBuf, +} + +#[derive(Debug, Clone)] +pub enum VersionKind { + PyOxidizer(String), + Uv(String), +} + +#[derive(Debug)] +pub struct Releases { + pub latest: Vec, + pub all: Vec, +} + +#[derive(Debug, Clone)] +pub enum MainMenuChoice { + Latest, + KeepExisting, + Version(VersionKind), + ToggleBetas, + ToggleCache, + DownloadMirror, + Uninstall, } fn main() { if let Err(e) = run() { - eprintln!("Error: {:#}", e); + eprintln!("Error: {e:#}"); eprintln!("Press enter to close..."); let mut input = String::new(); let _ = stdin().read_line(&mut input); @@ -43,75 +88,963 @@ fn main() { } fn run() -> Result<()> { - let mut config = Config::default(); - initial_terminal_setup(&mut config); - let uv_install_root = dirs::data_local_dir() .context("Unable to determine data_dir")? .join("AnkiProgramFiles"); - let sync_complete_marker = uv_install_root.join(".sync_complete"); let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; - let dist_pyproject_path = resources_dir.join("pyproject.toml"); - let user_pyproject_path = uv_install_root.join("pyproject.toml"); - let dist_python_version_path = resources_dir.join(".python-version"); - let user_python_version_path = uv_install_root.join(".python-version"); - let uv_lock_path = uv_install_root.join("uv.lock"); - let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name()); - let pyproject_has_changed = - !user_pyproject_path.exists() || !sync_complete_marker.exists() || { - let pyproject_toml_time = metadata(&user_pyproject_path)? - .modified() - .context("Failed to get pyproject.toml modified time")?; - let sync_complete_time = metadata(&sync_complete_marker)? - .modified() - .context("Failed to get sync marker modified time")?; - Ok::(pyproject_toml_time > sync_complete_time) - } - .unwrap_or(true); + let mut state = State { + current_version: None, + prerelease_marker: uv_install_root.join("prerelease"), + uv_install_root: uv_install_root.clone(), + uv_cache_dir: uv_install_root.join("cache"), + no_cache_marker: uv_install_root.join("nocache"), + anki_base_folder: get_anki_base_path()?, + uv_path: exe_dir.join(get_uv_binary_name()), + uv_python_install_dir: uv_install_root.join("python"), + user_pyproject_path: uv_install_root.join("pyproject.toml"), + user_python_version_path: uv_install_root.join(".python-version"), + dist_pyproject_path: resources_dir.join("pyproject.toml"), + dist_python_version_path: resources_dir.join(".python-version"), + 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, + venv_folder: uv_install_root.join(".venv"), + }; - if !pyproject_has_changed { - // If venv is already up to date, exec as normal - let anki_bin = get_anki_binary_path(&uv_install_root); - exec_anki(&anki_bin, &config)?; + // Check for uninstall request from Windows uninstaller + if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { + ensure_terminal_shown()?; + handle_uninstall(&state)?; return Ok(()); } - // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see - handle_terminal_launch()?; + // Create install directory + create_dir_all(&state.uv_install_root)?; - // Create install directory and copy project files in - create_dir_all(&uv_install_root)?; - if !user_pyproject_path.exists() { - copy_file(&dist_pyproject_path, &user_pyproject_path)?; - copy_file(&dist_python_version_path, &user_python_version_path)?; + let launcher_requested = + state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists(); + + // Calculate whether user has custom edits that need syncing + let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); + let sync_time = file_timestamp_secs(&state.sync_complete_marker); + state.pyproject_modified_by_user = pyproject_time > sync_time; + let pyproject_has_changed = state.pyproject_modified_by_user; + if !launcher_requested && !pyproject_has_changed { + // If no launcher request and venv is already up to date, launch Anki normally + let args: Vec = std::env::args().skip(1).collect(); + let cmd = build_python_command(&state, &args)?; + launch_anki_normally(cmd)?; + return Ok(()); } - // Remove sync marker before attempting sync - let _ = remove_file(&sync_complete_marker); + // If we weren't in a terminal, respawn ourselves in one + ensure_terminal_shown()?; - // Sync the venv - if let Err(e) = Command::new(&uv_path) - .current_dir(&uv_install_root) - .args(["sync", "--refresh"]) - .ensure_success() + if launcher_requested { + // Remove the trigger file to make request ephemeral + let _ = remove_file(&state.launcher_trigger_file); + } + + print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top + println!("\x1B[1mAnki Launcher\x1B[0m\n"); + + ensure_os_supported()?; + + check_versions(&mut state); + + main_menu_loop(&state)?; + + // Write marker file to indicate we've completed the sync process + write_sync_marker(&state)?; + + #[cfg(target_os = "macos")] { - // If sync fails due to things like a missing wheel on pypi, - // we need to remove the lockfile or uv will cache the bad result. - let _ = remove_file(&uv_lock_path); - return Err(e.into()); + let cmd = build_python_command(&state, &[])?; + platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?; } - // Write marker file to indicate successful sync - write_file(&sync_complete_marker, "")?; + if cfg!(unix) && !cfg!(target_os = "macos") { + println!("\nPress enter to start Anki."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + } else { + // on Windows/macOS, the user needs to close the terminal/console + // currently, but ideas on how we can avoid this would be good! + println!(); + println!("Anki will start shortly."); + println!("\x1B[1mYou can close this window.\x1B[0m\n"); + } - // First launch - let anki_bin = get_anki_binary_path(&uv_install_root); - handle_first_launch(&anki_bin)?; - - // Then launch the binary as detached subprocess so the terminal can close - launch_anki_detached(&anki_bin, &config)?; + // respawn the launcher as a disconnected subprocess for normal startup + respawn_launcher()?; Ok(()) } + +fn extract_aqt_version(state: &State) -> Option { + // Check if .venv exists first + if !state.venv_folder.exists() { + return None; + } + + let output = Command::new(&state.uv_path) + .current_dir(&state.uv_install_root) + .env("VIRTUAL_ENV", &state.venv_folder) + .args(["pip", "show", "aqt"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + for line in stdout.lines() { + if let Some(version) = line.strip_prefix("Version: ") { + return Some(version.trim().to_string()); + } + } + None +} + +fn check_versions(state: &mut State) { + // If sync_complete_marker is missing, do nothing + if !state.sync_complete_marker.exists() { + return; + } + + // Determine current version by invoking uv pip show aqt + match extract_aqt_version(state) { + Some(version) => { + state.current_version = Some(version); + } + None => { + println!("Warning: Could not determine current Anki version"); + } + } + + // Read previous version from "previous-version" file + let previous_version_path = state.uv_install_root.join("previous-version"); + if let Ok(content) = read_file(&previous_version_path) { + if let Ok(version_str) = String::from_utf8(content) { + let version = version_str.trim().to_string(); + if !version.is_empty() { + state.previous_version = Some(version); + } + } + } +} + +fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Result<()> { + update_pyproject_for_version(choice.clone(), state)?; + + // Extract current version before syncing (but don't write to file yet) + let previous_version_to_save = extract_aqt_version(state); + + // Remove sync marker before attempting sync + let _ = remove_file(&state.sync_complete_marker); + + println!("Updating Anki...\n"); + + let python_version_trimmed = if state.user_python_version_path.exists() { + let python_version = read_file(&state.user_python_version_path)?; + let python_version_str = + String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; + Some(python_version_str.trim().to_string()) + } else { + None + }; + + let have_venv = state.venv_folder.exists(); + if cfg!(target_os = "macos") && !have_developer_tools() && !have_venv { + println!("If you see a pop-up about 'install_name_tool', you can cancel it, and ignore the warning below.\n"); + } + + // Prepare to sync the venv + let mut command = Command::new(&state.uv_path); + command.current_dir(&state.uv_install_root); + + // remove UV_* environment variables to avoid interference + for (key, _) in std::env::vars() { + if key.starts_with("UV_") || key == "VIRTUAL_ENV" { + command.env_remove(key); + } + } + + // remove CONDA_PREFIX/bin from PATH to avoid conda interference + #[cfg(target_os = "macos")] + if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") { + if let Ok(current_path) = std::env::var("PATH") { + let conda_bin = format!("{conda_prefix}/bin"); + let filtered_paths: Vec<&str> = current_path + .split(':') + .filter(|&path| path != conda_bin) + .collect(); + let new_path = filtered_paths.join(":"); + command.env("PATH", new_path); + } + } + + command + .env("UV_CACHE_DIR", &state.uv_cache_dir) + .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) + .env( + "UV_HTTP_TIMEOUT", + std::env::var("UV_HTTP_TIMEOUT").unwrap_or_else(|_| "180".to_string()), + ) + .args(["sync", "--upgrade", "--managed-python", "--no-config"]); + + // Add python version if .python-version file exists + if let Some(version) = &python_version_trimmed { + command.args(["--python", version]); + } + + if state.no_cache_marker.exists() { + command.env("UV_NO_CACHE", "1"); + } + + match command.ensure_success() { + Ok(_) => { + // Sync succeeded + if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) { + inject_helper_addon()?; + } + + // Now that sync succeeded, save the previous version + if let Some(current_version) = previous_version_to_save { + let previous_version_path = state.uv_install_root.join("previous-version"); + if let Err(e) = write_file(&previous_version_path, ¤t_version) { + println!("Warning: Could not save previous version: {e}"); + } + } + + Ok(()) + } + Err(e) => { + // If sync fails due to things like a missing wheel on pypi, + // we need to remove the lockfile or uv will cache the bad result. + let _ = remove_file(&state.uv_lock_path); + println!("Install failed: {e:#}"); + println!(); + Err(e.into()) + } + } +} + +fn main_menu_loop(state: &State) -> Result<()> { + loop { + let menu_choice = get_main_menu_choice(state)?; + + match menu_choice { + MainMenuChoice::KeepExisting => { + if state.pyproject_modified_by_user { + // User has custom edits, sync them + handle_version_install_or_update(state, MainMenuChoice::KeepExisting)?; + } + break; + } + MainMenuChoice::ToggleBetas => { + // Toggle beta prerelease file + if state.prerelease_marker.exists() { + let _ = remove_file(&state.prerelease_marker); + println!("Beta releases disabled."); + } else { + write_file(&state.prerelease_marker, "")?; + println!("Beta releases enabled."); + } + println!(); + continue; + } + MainMenuChoice::ToggleCache => { + // Toggle cache disable file + if state.no_cache_marker.exists() { + let _ = remove_file(&state.no_cache_marker); + println!("Download caching enabled."); + } else { + write_file(&state.no_cache_marker, "")?; + // Delete the cache directory and everything in it + if state.uv_cache_dir.exists() { + let _ = anki_io::remove_dir_all(&state.uv_cache_dir); + } + println!("Download caching disabled and cache cleared."); + } + println!(); + continue; + } + MainMenuChoice::DownloadMirror => { + show_mirror_submenu(state)?; + println!(); + continue; + } + MainMenuChoice::Uninstall => { + if handle_uninstall(state)? { + std::process::exit(0); + } + continue; + } + choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { + handle_version_install_or_update(state, choice.clone())?; + break; + } + } + } + Ok(()) +} + +fn write_sync_marker(state: &State) -> Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(); + write_file(&state.sync_complete_marker, timestamp.to_string())?; + Ok(()) +} + +/// Get mtime of provided file, or 0 if unavailable +fn file_timestamp_secs(path: &std::path::Path) -> i64 { + modified_time(path) + .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + .unwrap_or_default() +} + +fn get_main_menu_choice(state: &State) -> Result { + loop { + println!("1) Latest Anki (press Enter)"); + println!("2) Choose a version"); + + if let Some(current_version) = &state.current_version { + let normalized_current = normalize_version(current_version); + + if state.pyproject_modified_by_user { + println!("3) Sync project changes"); + } else { + println!("3) Keep existing version ({normalized_current})"); + } + } + + if let Some(prev_version) = &state.previous_version { + if state.current_version.as_ref() != Some(prev_version) { + let normalized_prev = normalize_version(prev_version); + println!("4) Revert to previous version ({normalized_prev})"); + } + } + println!(); + + let betas_enabled = state.prerelease_marker.exists(); + println!( + "5) Allow betas: {}", + if betas_enabled { "on" } else { "off" } + ); + let cache_enabled = !state.no_cache_marker.exists(); + println!( + "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!("8) Uninstall"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + println!(); + + return Ok(match input { + "" | "1" => MainMenuChoice::Latest, + "2" => { + match get_version_kind(state)? { + Some(version_kind) => MainMenuChoice::Version(version_kind), + None => continue, // Return to main menu + } + } + "3" => { + if state.current_version.is_some() { + MainMenuChoice::KeepExisting + } else { + println!("Invalid input. Please try again.\n"); + continue; + } + } + "4" => { + if let Some(prev_version) = &state.previous_version { + if state.current_version.as_ref() != Some(prev_version) { + if let Some(version_kind) = parse_version_kind(prev_version) { + return Ok(MainMenuChoice::Version(version_kind)); + } + } + } + println!("Invalid input. Please try again.\n"); + continue; + } + "5" => MainMenuChoice::ToggleBetas, + "6" => MainMenuChoice::ToggleCache, + "7" => MainMenuChoice::DownloadMirror, + "8" => MainMenuChoice::Uninstall, + _ => { + println!("Invalid input. Please try again."); + continue; + } + }); + } +} + +fn get_version_kind(state: &State) -> Result> { + let releases = get_releases(state)?; + let releases_str = releases + .latest + .iter() + .map(|v| v.as_str()) + .collect::>() + .join(", "); + println!("Latest releases: {releases_str}"); + + println!("Enter the version you want to install:"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + if input.is_empty() { + return Ok(None); + } + + // Normalize the input version for comparison + let normalized_input = normalize_version(input); + + // Check if the version exists in the available versions + let version_exists = releases.all.iter().any(|v| v == &normalized_input); + + match (parse_version_kind(input), version_exists) { + (Some(version_kind), true) => { + println!(); + Ok(Some(version_kind)) + } + (None, true) => { + println!("Versions before 2.1.50 can't be installed."); + Ok(None) + } + _ => { + println!("Invalid version.\n"); + Ok(None) + } + } +} + +fn with_only_latest_patch(versions: &[String]) -> Vec { + // Only show the latest patch release for a given (major, minor) + let mut seen_major_minor = std::collections::HashSet::new(); + versions + .iter() + .filter(|v| { + let (major, minor, _, _) = parse_version_for_filtering(v); + if major == 2 { + return true; + } + let major_minor = (major, minor); + if seen_major_minor.contains(&major_minor) { + false + } else { + seen_major_minor.insert(major_minor); + true + } + }) + .cloned() + .collect() +} + +fn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) { + // Remove any build metadata after + + let version_str = version_str.split('+').next().unwrap_or(version_str); + + // Check for prerelease markers + let is_prerelease = ["a", "b", "rc", "alpha", "beta"] + .iter() + .any(|marker| version_str.to_lowercase().contains(marker)); + + // Extract numeric parts (stop at first non-digit/non-dot character) + let numeric_end = version_str + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(version_str.len()); + let numeric_part = &version_str[..numeric_end]; + + let parts: Vec<&str> = numeric_part.split('.').collect(); + + let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + + (major, minor, patch, is_prerelease) +} + +fn normalize_version(version: &str) -> String { + let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version); + + if major <= 2 { + // Don't transform versions <= 2.x + return version.to_string(); + } + + // For versions > 2, pad the minor version with leading zero if < 10 + let normalized_minor = if minor < 10 { + format!("0{minor}") + } else { + minor.to_string() + }; + + // Find any prerelease suffix + let mut prerelease_suffix = ""; + + // Look for prerelease markers after the numeric part + let numeric_end = version + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(version.len()); + if numeric_end < version.len() { + let suffix_part = &version[numeric_end..]; + let suffix_lower = suffix_part.to_lowercase(); + + for marker in ["alpha", "beta", "rc", "a", "b"] { + if suffix_lower.starts_with(marker) { + prerelease_suffix = &version[numeric_end..]; + break; + } + } + } + + // Reconstruct the version + if version.matches('.').count() >= 2 { + format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}") + } else { + format!("{major}.{normalized_minor}{prerelease_suffix}") + } +} + +fn filter_and_normalize_versions( + all_versions: Vec, + include_prereleases: bool, +) -> Vec { + let mut valid_versions: Vec = all_versions + .into_iter() + .map(|v| normalize_version(&v)) + .collect(); + + // Reverse to get chronological order (newest first) + valid_versions.reverse(); + + if !include_prereleases { + valid_versions.retain(|v| { + let (_, _, _, is_prerelease) = parse_version_for_filtering(v); + !is_prerelease + }); + } + + valid_versions +} + +fn fetch_versions(state: &State) -> Result> { + let versions_script = state.resources_dir.join("versions.py"); + + 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,requests[socks]"]); + + let python_version = read_file(&state.dist_python_version_path)?; + let python_version_str = + String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; + let version_trimmed = python_version_str.trim(); + if !version_trimmed.is_empty() { + cmd.args(["--python", version_trimmed]); + } + + cmd.arg(&versions_script); + + let output = match cmd.utf8_output() { + Ok(output) => output, + Err(e) => { + print!("Unable to check for Anki versions. Please check your internet connection.\n\n"); + return Err(e.into()); + } + }; + let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; + Ok(versions) +} + +fn get_releases(state: &State) -> Result { + println!("Checking for updates..."); + let include_prereleases = state.prerelease_marker.exists(); + let all_versions = fetch_versions(state)?; + let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); + + let latest_patches = with_only_latest_patch(&all_versions); + let latest_releases: Vec = latest_patches.into_iter().take(5).collect(); + Ok(Releases { + latest: latest_releases, + all: all_versions, + }) +} + +fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { + let content = read_file(&state.dist_pyproject_path)?; + let content_str = String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; + let updated_content = match version_kind { + VersionKind::PyOxidizer(version) => { + // Replace package name and add PyQt6 dependencies + content_str.replace( + "anki-release", + &format!( + concat!( + "aqt[qt6]=={}\",\n", + " \"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\",\n", + " \"pyqt6==6.6.1\",\n", + " \"pyqt6-qt6==6.6.2\",\n", + " \"pyqt6-webengine==6.6.0\",\n", + " \"pyqt6-webengine-qt6==6.6.2\",\n", + " \"pyqt6_sip==13.6.0" + ), + version + ), + ) + } + VersionKind::Uv(version) => content_str.replace( + "anki-release", + &format!("anki-release=={version}\",\n \"anki=={version}\",\n \"aqt=={version}"), + ), + }; + + // 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 { + VersionKind::PyOxidizer(_) => { + write_file(&state.user_python_version_path, "3.9")?; + } + VersionKind::Uv(_) => { + copy_file( + &state.dist_python_version_path, + &state.user_python_version_path, + )?; + } + } + Ok(()) +} + +fn update_pyproject_for_version(menu_choice: MainMenuChoice, state: &State) -> Result<()> { + match menu_choice { + MainMenuChoice::Latest => { + // Get the latest release version and create a VersionKind for it + let releases = get_releases(state)?; + let latest_version = releases.latest.first().context("No latest version found")?; + apply_version_kind(&VersionKind::Uv(latest_version.clone()), state)?; + } + MainMenuChoice::KeepExisting => { + // Do nothing - keep existing pyproject.toml and .python-version + } + MainMenuChoice::ToggleBetas => { + unreachable!(); + } + MainMenuChoice::ToggleCache => { + unreachable!(); + } + MainMenuChoice::DownloadMirror => { + unreachable!(); + } + MainMenuChoice::Uninstall => { + unreachable!(); + } + MainMenuChoice::Version(version_kind) => { + apply_version_kind(&version_kind, state)?; + } + } + Ok(()) +} + +fn parse_version_kind(version: &str) -> Option { + let numeric_chars: String = version + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect(); + + let parts: Vec<&str> = numeric_chars.split('.').collect(); + + if parts.len() < 2 { + return None; + } + + let major: u32 = match parts[0].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let minor: u32 = match parts[1].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let patch: u32 = if parts.len() >= 3 { + match parts[2].parse() { + Ok(val) => val, + Err(_) => return None, + } + } else { + 0 // Default patch to 0 if not provided + }; + + // Reject versions < 2.1.50 + if major == 2 && (minor != 1 || patch < 50) { + return None; + } + + if major < 25 || (major == 25 && minor < 6) { + Some(VersionKind::PyOxidizer(version.to_string())) + } else { + Some(VersionKind::Uv(version.to_string())) + } +} + +fn inject_helper_addon() -> Result<()> { + let addons21_path = get_anki_addons21_path()?; + + if !addons21_path.exists() { + return Ok(()); + } + + let addon_folder = addons21_path.join("anki-launcher"); + + // Remove existing anki-launcher folder if it exists + if addon_folder.exists() { + anki_io::remove_dir_all(&addon_folder)?; + } + + // Create the anki-launcher folder + create_dir_all(&addon_folder)?; + + // Write the embedded files + let init_py_content = include_str!("../addon/__init__.py"); + let manifest_json_content = include_str!("../addon/manifest.json"); + + write_file(addon_folder.join("__init__.py"), init_py_content)?; + write_file(addon_folder.join("manifest.json"), manifest_json_content)?; + + Ok(()) +} + +fn get_anki_base_path() -> Result { + let anki_base_path = if cfg!(target_os = "windows") { + // Windows: %APPDATA%\Anki2 + dirs::config_dir() + .context("Unable to determine config directory")? + .join("Anki2") + } else if cfg!(target_os = "macos") { + // macOS: ~/Library/Application Support/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + } else { + // Linux: ~/.local/share/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + }; + + Ok(anki_base_path) +} + +fn get_anki_addons21_path() -> Result { + Ok(get_anki_base_path()?.join("addons21")) +} + +fn handle_uninstall(state: &State) -> Result { + println!("Uninstall Anki's program files? (y/n)"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input != "y" { + println!("Uninstall cancelled."); + println!(); + return Ok(false); + } + + // Remove program files + if state.uv_install_root.exists() { + anki_io::remove_dir_all(&state.uv_install_root)?; + println!("Program files removed."); + } + + println!(); + println!("Remove all profiles/cards? (y/n)"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input == "y" && state.anki_base_folder.exists() { + anki_io::remove_dir_all(&state.anki_base_folder)?; + println!("User data removed."); + } + + println!(); + + // Platform-specific messages + #[cfg(target_os = "macos")] + platform::mac::finalize_uninstall(); + + #[cfg(target_os = "windows")] + platform::windows::finalize_uninstall(); + + #[cfg(all(unix, not(target_os = "macos")))] + platform::unix::finalize_uninstall(); + + Ok(true) +} + +fn have_developer_tools() -> bool { + Command::new("xcode-select") + .args(["-p"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn build_python_command(state: &State, args: &[String]) -> Result { + let python_exe = if cfg!(target_os = "windows") { + let show_console = std::env::var("ANKI_CONSOLE").is_ok(); + if show_console { + state.venv_folder.join("Scripts/python.exe") + } else { + state.venv_folder.join("Scripts/pythonw.exe") + } + } else { + state.venv_folder.join("bin/python") + }; + + let mut cmd = Command::new(&python_exe); + cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; aqt.run()"]); + cmd.args(args); + // tell the Python code it was invoked by the launcher, and updating is + // available + cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str()); + + // Set UV and Python paths for the Python code + cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); + cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); + + 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::*; + + #[test] + fn test_normalize_version() { + // Test versions <= 2.x (should not be transformed) + assert_eq!(normalize_version("2.1.50"), "2.1.50"); + + // Test basic versions > 2 with zero-padding + assert_eq!(normalize_version("25.7"), "25.07"); + assert_eq!(normalize_version("25.07"), "25.07"); + assert_eq!(normalize_version("25.10"), "25.10"); + assert_eq!(normalize_version("24.6.1"), "24.06.1"); + assert_eq!(normalize_version("24.06.1"), "24.06.1"); + + // Test prerelease versions + assert_eq!(normalize_version("25.7a1"), "25.07a1"); + assert_eq!(normalize_version("25.7.1a1"), "25.07.1a1"); + + // Test versions with patch = 0 + assert_eq!(normalize_version("25.7.0"), "25.07.0"); + assert_eq!(normalize_version("25.7.0a1"), "25.07.0a1"); + } +} diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index b5157dd4b..f97d7fd07 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -1,46 +1,65 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::os::unix::process::CommandExt; +use std::io; +use std::io::Write; +use std::path::Path; use std::process::Command; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::thread; +use std::time::Duration; use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -// Re-export Unix functions that macOS uses -pub use super::unix::{ - exec_anki, - get_anki_binary_path, - initial_terminal_setup, -}; +pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<()> { + // Pre-validate by running --version to trigger any Gatekeeper checks + print!("\n\x1B[1mThis may take a few minutes. Please wait\x1B[0m"); + io::stdout().flush().unwrap(); -pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) -> Result<()> { - use std::process::Stdio; + // Start progress indicator + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let progress_thread = thread::spawn(move || { + while running_clone.load(Ordering::Relaxed) { + print!("."); + io::stdout().flush().unwrap(); + thread::sleep(Duration::from_secs(1)); + } + }); - let child = Command::new(anki_bin) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .process_group(0) - .ensure_spawn()?; - std::mem::forget(child); - Ok(()) -} + let _ = cmd + .env("ANKI_FIRST_RUN", "1") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .ensure_success(); -pub fn handle_terminal_launch() -> Result<()> { - let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout()); - if stdout_is_terminal { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); - } else { - // If launched from GUI, relaunch in Terminal.app - relaunch_in_terminal()?; + if cfg!(target_os = "macos") { + // older Anki versions had a short mpv timeout and didn't support + // ANKI_FIRST_RUN, so we need to ensure mpv passes Gatekeeper + // validation prior to launch + let mpv_path = root.join(".venv/lib/python3.9/site-packages/anki_audio/mpv"); + if mpv_path.exists() { + let _ = Command::new(&mpv_path) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .ensure_success(); + } } + + // Stop progress indicator + running.store(false, Ordering::Relaxed); + progress_thread.join().unwrap(); + println!(); // New line after dots Ok(()) } -fn relaunch_in_terminal() -> Result<()> { +pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; Command::new("open") .args(["-a", "Terminal"]) @@ -49,32 +68,31 @@ fn relaunch_in_terminal() -> Result<()> { std::process::exit(0); } -pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> { - // Pre-validate by running --version to trigger any Gatekeeper checks - println!("\n\x1B[1mThis may take a few minutes. Please wait...\x1B[0m"); - let _ = Command::new(anki_bin) - .env("ANKI_FIRST_RUN", "1") - .arg("--version") - .ensure_success(); - Ok(()) -} - -pub fn get_exe_and_resources_dirs() -> Result<(std::path::PathBuf, std::path::PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - let resources_dir = exe_dir - .parent() - .context("Failed to get parent directory")? - .join("Resources"); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - // macOS uses standard uv binary name - "uv" +pub fn finalize_uninstall() { + if let Ok(exe_path) = std::env::current_exe() { + // Find the .app bundle by walking up the directory tree + let mut app_bundle_path = exe_path.as_path(); + while let Some(parent) = app_bundle_path.parent() { + if let Some(name) = parent.file_name() { + if name.to_string_lossy().ends_with(".app") { + let result = Command::new("trash").arg(parent).output(); + + match result { + Ok(output) if output.status.success() => { + println!("Anki has been uninstalled."); + return; + } + _ => { + // Fall back to manual instructions + println!( + "Please manually drag Anki.app to the trash to complete uninstall." + ); + } + } + return; + } + } + app_bundle_path = parent; + } + } } diff --git a/qt/launcher/src/platform/mod.rs b/qt/launcher/src/platform/mod.rs index bb7208abe..6a582f1aa 100644 --- a/qt/launcher/src/platform/mod.rs +++ b/qt/launcher/src/platform/mod.rs @@ -1,18 +1,138 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -#[cfg(unix)] -mod unix; - -#[cfg(target_os = "macos")] -mod mac; - -#[cfg(target_os = "windows")] -mod windows; - -#[cfg(target_os = "macos")] -pub use mac::*; #[cfg(all(unix, not(target_os = "macos")))] -pub use unix::*; +pub mod unix; + +#[cfg(target_os = "macos")] +pub mod mac; + #[cfg(target_os = "windows")] -pub use windows::*; +pub mod windows; + +use std::path::PathBuf; + +use anki_process::CommandExt; +use anyhow::Context; +use anyhow::Result; + +pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { + let exe_dir = std::env::current_exe() + .context("Failed to get current executable path")? + .parent() + .context("Failed to get executable directory")? + .to_owned(); + + let resources_dir = if cfg!(target_os = "macos") { + // On macOS, resources are in ../Resources relative to the executable + exe_dir + .parent() + .context("Failed to get parent directory")? + .join("Resources") + } else { + // On other platforms, resources are in the same directory as executable + exe_dir.clone() + }; + + Ok((exe_dir, resources_dir)) +} + +pub fn get_uv_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "uv.exe" + } else if cfg!(target_os = "macos") { + "uv" + } else if cfg!(target_arch = "x86_64") { + "uv.amd64" + } else { + "uv.arm64" + } +} + +pub fn respawn_launcher() -> Result<()> { + use std::process::Stdio; + + let mut launcher_cmd = if cfg!(target_os = "macos") { + // On macOS, we need to launch the .app bundle, not the executable directly + let current_exe = + std::env::current_exe().context("Failed to get current executable path")?; + + // Navigate from Contents/MacOS/launcher to the .app bundle + let app_bundle = current_exe + .parent() // MacOS + .and_then(|p| p.parent()) // Contents + .and_then(|p| p.parent()) // .app + .context("Failed to find .app bundle")?; + + let mut cmd = std::process::Command::new("open"); + cmd.arg(app_bundle); + cmd + } else { + let current_exe = + std::env::current_exe().context("Failed to get current executable path")?; + std::process::Command::new(current_exe) + }; + + launcher_cmd + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; + launcher_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::os::unix::process::CommandExt; + launcher_cmd.process_group(0); + } + + let child = launcher_cmd.ensure_spawn()?; + std::mem::forget(child); + + Ok(()) +} + +pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { + #[cfg(windows)] + { + crate::platform::windows::prepare_to_launch_normally(); + cmd.ensure_success()?; + } + #[cfg(unix)] + cmd.ensure_exec()?; + Ok(()) +} + +#[cfg(windows)] +pub use windows::ensure_terminal_shown; + +#[cfg(unix)] +pub fn ensure_terminal_shown() -> Result<()> { + use std::io::IsTerminal; + + let want_terminal = std::env::var("ANKI_LAUNCHER_WANT_TERMINAL").is_ok(); + let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout()); + if want_terminal || !stdout_is_terminal { + #[cfg(target_os = "macos")] + mac::relaunch_in_terminal()?; + #[cfg(not(target_os = "macos"))] + unix::relaunch_in_terminal()?; + } + + // Set terminal title to "Anki Launcher" + print!("\x1b]2;Anki Launcher\x07"); + Ok(()) +} + +pub fn ensure_os_supported() -> Result<()> { + #[cfg(all(unix, not(target_os = "macos")))] + unix::ensure_glibc_supported()?; + + Ok(()) +} diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 4155f39d1..5e4bddda3 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -1,74 +1,105 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -#![allow(dead_code)] - -use std::path::PathBuf; use std::process::Command; -use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -use crate::Config; +pub fn relaunch_in_terminal() -> Result<()> { + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; -pub fn initial_terminal_setup(_config: &mut Config) { - // No special terminal setup needed on Unix -} + // Try terminals in roughly most specific to least specific. + // First, try commonly used terminals for riced systems. + // Second, try common defaults. + // Finally, try x11 compatibility terminals. + let terminals = [ + // commonly used for riced systems + ("alacritty", vec!["-e"]), + ("kitty", vec![]), + ("foot", vec![]), + // the user's default terminal in Debian/Ubuntu + ("x-terminal-emulator", vec!["-e"]), + // default installs for the most common distros + ("xfce4-terminal", vec!["-e"]), + ("gnome-terminal", vec!["-e"]), + ("konsole", vec!["-e"]), + // x11-compatibility terminals + ("urxvt", vec!["-e"]), + ("xterm", vec!["-e"]), + ]; -pub fn handle_terminal_launch() -> Result<()> { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); - // Skip terminal relaunch on non-macOS Unix systems as we don't know which - // terminal is installed - Ok(()) -} + for (terminal_cmd, args) in &terminals { + // Check if terminal exists + if Command::new("which") + .arg(terminal_cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + // Try to launch the terminal + let mut cmd = Command::new(terminal_cmd); + if args.is_empty() { + cmd.arg(¤t_exe); + } else { + cmd.args(args).arg(¤t_exe); + } -pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> PathBuf { - uv_install_root.join(".venv/bin/anki") -} - -pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - // On non-macOS Unix systems, we don't need to detach since we never spawned a - // terminal - exec_anki(anki_bin, config) -} - -pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> { - // No special first launch handling needed for generic Unix systems - Ok(()) -} - -pub fn exec_anki(anki_bin: &std::path::Path, _config: &Config) -> Result<()> { - let args: Vec = std::env::args().skip(1).collect(); - Command::new(anki_bin) - .args(args) - .ensure_exec() - .map_err(anyhow::Error::new) -} - -pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - // On generic Unix systems, assume resources are in the same directory as - // executable - let resources_dir = exe_dir.clone(); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - // Use architecture-specific uv binary for non-Mac Unix systems - if cfg!(target_arch = "x86_64") { - "uv.amd64" - } else if cfg!(target_arch = "aarch64") { - "uv.arm64" - } else { - // Fallback to generic uv for other architectures - "uv" + if cmd.spawn().is_ok() { + std::process::exit(0); + } + } } + + // If no terminal worked, continue without relaunching + Ok(()) +} + +pub fn finalize_uninstall() { + use std::io::stdin; + use std::io::stdout; + use std::io::Write; + + let uninstall_script = std::path::Path::new("/usr/local/share/anki/uninstall.sh"); + + if uninstall_script.exists() { + println!("To finish uninstalling, run 'sudo /usr/local/share/anki/uninstall.sh'"); + } else { + println!("Anki has been uninstalled."); + } + println!("Press enter to quit."); + let _ = stdout().flush(); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); +} + +pub fn ensure_glibc_supported() -> Result<()> { + use std::ffi::CStr; + let get_glibc_version = || -> Option<(u32, u32)> { + let version_ptr = unsafe { libc::gnu_get_libc_version() }; + if version_ptr.is_null() { + return None; + } + + let version_cstr = unsafe { CStr::from_ptr(version_ptr) }; + let version_str = version_cstr.to_str().ok()?; + + // Parse version string (format: "2.36" or "2.36.1") + let version_parts: Vec<&str> = version_str.split('.').collect(); + if version_parts.len() < 2 { + return None; + } + + let major: u32 = version_parts[0].parse().ok()?; + let minor: u32 = version_parts[1].parse().ok()?; + + Some((major, minor)) + }; + + let (major, minor) = get_glibc_version().unwrap_or_default(); + if major < 2 || (major == 2 && minor < 36) { + anyhow::bail!("Anki requires a modern Linux distro with glibc 2.36 or later."); + } + + Ok(()) } diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index c536c8c76..ebdff6261 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -1,33 +1,111 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::path::PathBuf; +use std::io::stdin; use std::process::Command; -use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; +use widestring::u16cstr; +use windows::core::PCWSTR; +use windows::Wdk::System::SystemServices::RtlGetVersion; +use windows::Win32::System::Console::AttachConsole; +use windows::Win32::System::Console::GetConsoleWindow; +use windows::Win32::System::Console::ATTACH_PARENT_PROCESS; +use windows::Win32::System::Registry::RegCloseKey; +use windows::Win32::System::Registry::RegOpenKeyExW; +use windows::Win32::System::Registry::RegQueryValueExW; +use windows::Win32::System::Registry::HKEY; +use windows::Win32::System::Registry::HKEY_CURRENT_USER; +use windows::Win32::System::Registry::KEY_READ; +use windows::Win32::System::Registry::REG_SZ; +use windows::Win32::System::SystemInformation::OSVERSIONINFOW; +use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; -use crate::Config; - -pub fn handle_terminal_launch() -> Result<()> { - // uv will do this itself - Ok(()) +/// Returns true if running on Windows 10 (not Windows 11) +fn is_windows_10() -> bool { + unsafe { + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if RtlGetVersion(&mut info).is_ok() { + // Windows 10 has build numbers < 22000, Windows 11 >= 22000 + info.dwBuildNumber < 22000 && info.dwMajorVersion == 10 + } else { + false + } + } } -/// If parent process has a console (eg cmd.exe), redirect our output there. -/// Sets config.show_console to true if successfully attached to console. -pub fn initial_terminal_setup(config: &mut Config) { +pub fn ensure_terminal_shown() -> Result<()> { + unsafe { + if !GetConsoleWindow().is_invalid() { + // We already have a console, no need to spawn anki-console.exe + return Ok(()); + } + } + + if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() { + // This black magic triggers Windows to switch to the new + // ANSI-supporting console host, which is usually only available + // when the app is built with the console subsystem. + // Only needed on Windows 10, not Windows 11. + if is_windows_10() { + let _ = Command::new("cmd").args(["/C", ""]).status(); + } + + // Successfully attached to parent console + reconnect_stdio_to_console(); + return Ok(()); + } + + // No console available, spawn anki-console.exe and exit + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; + let exe_dir = current_exe + .parent() + .context("Failed to get executable directory")?; + + let console_exe = exe_dir.join("anki-console.exe"); + + if !console_exe.exists() { + anyhow::bail!("anki-console.exe not found in the same directory"); + } + + // Spawn anki-console.exe without waiting + Command::new(&console_exe) + .env("ANKI_IMPLICIT_CONSOLE", "1") + .spawn() + .context("Failed to spawn anki-console.exe")?; + + // Exit immediately after spawning + std::process::exit(0); +} + +pub fn attach_to_parent_console() -> bool { + unsafe { + if !GetConsoleWindow().is_invalid() { + // we have a console already + return false; + } + + if AttachConsole(ATTACH_PARENT_PROCESS).is_ok() { + // successfully attached to parent + reconnect_stdio_to_console(); + true + } else { + false + } + } +} + +/// Reconnect stdin/stdout/stderr to the console. +fn reconnect_stdio_to_console() { use std::ffi::CString; use libc_stdhandle::*; - use winapi::um::wincon; - - let console_attached = unsafe { wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) }; - if console_attached == 0 { - return; - } + // we launched without a console, so we'll need to open stdin/out/err let conin = CString::new("CONIN$").unwrap(); let conout = CString::new("CONOUT$").unwrap(); let r = CString::new("r").unwrap(); @@ -39,80 +117,127 @@ pub fn initial_terminal_setup(config: &mut Config) { libc::freopen(conout.as_ptr(), w.as_ptr(), stdout()); libc::freopen(conout.as_ptr(), w.as_ptr(), stderr()); } - - config.show_console = true; } -pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> std::path::PathBuf { - uv_install_root.join(".venv/Scripts/anki.exe") +pub fn finalize_uninstall() { + let uninstaller_path = get_uninstaller_path(); + + match uninstaller_path { + Some(path) => { + println!("Launching Windows uninstaller..."); + let result = Command::new(&path).env("ANKI_LAUNCHER", "1").spawn(); + + match result { + Ok(_) => { + println!("Uninstaller launched successfully."); + return; + } + Err(e) => { + println!("Failed to launch uninstaller: {e}"); + println!("You can manually run: {}", path.display()); + } + } + } + None => { + println!("Windows uninstaller not found."); + println!("You may need to uninstall via Windows Settings > Apps."); + } + } + println!("Press enter to close..."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); } -fn build_python_command( - anki_bin: &std::path::Path, - args: &[String], - config: &Config, -) -> Result { - let venv_dir = anki_bin - .parent() - .context("Failed to get venv Scripts directory")? - .parent() - .context("Failed to get venv directory")?; +fn get_uninstaller_path() -> Option { + // Try to read install directory from registry + if let Some(install_dir) = read_registry_install_dir() { + let uninstaller = install_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } + } - // Use python.exe if show_console is true, otherwise pythonw.exe - let python_exe = if config.show_console { - venv_dir.join("Scripts/python.exe") - } else { - venv_dir.join("Scripts/pythonw.exe") - }; + // Fall back to default location + let default_dir = dirs::data_local_dir()?.join("Programs").join("Anki"); + let uninstaller = default_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } - let mut cmd = Command::new(python_exe); - cmd.args(["-c", "import aqt; aqt.run()"]); - cmd.args(args); - - Ok(cmd) + None } -pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - use std::os::windows::process::CommandExt; - use std::process::Stdio; +fn read_registry_install_dir() -> Option { + unsafe { + let mut hkey = HKEY::default(); - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; + // Convert the registry path to wide string + let subkey = u16cstr!("SOFTWARE\\Anki"); - let mut cmd = build_python_command(anki_bin, &[], config)?; - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) - .ensure_spawn()?; - Ok(()) + // Open the registry key + let result = RegOpenKeyExW( + HKEY_CURRENT_USER, + PCWSTR(subkey.as_ptr()), + Some(0), + KEY_READ, + &mut hkey, + ); + + if result.is_err() { + return None; + } + + // Query the Install_Dir64 value + let value_name = u16cstr!("Install_Dir64"); + + let mut value_type = REG_SZ; + let mut data_size = 0u32; + + // First call to get the size + let result = RegQueryValueExW( + hkey, + PCWSTR(value_name.as_ptr()), + None, + Some(&mut value_type), + None, + Some(&mut data_size), + ); + + if result.is_err() || data_size == 0 { + let _ = RegCloseKey(hkey); + return None; + } + + // Allocate buffer and read the value + let mut buffer: Vec = vec![0; (data_size / 2) as usize]; + let result = RegQueryValueExW( + hkey, + PCWSTR(value_name.as_ptr()), + None, + Some(&mut value_type), + Some(buffer.as_mut_ptr() as *mut u8), + Some(&mut data_size), + ); + + let _ = RegCloseKey(hkey); + + if result.is_ok() { + // Convert wide string back to PathBuf + let len = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len()); + let path_str = String::from_utf16_lossy(&buffer[..len]); + Some(std::path::PathBuf::from(path_str)) + } else { + None + } + } } -pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> { - Ok(()) -} - -pub fn exec_anki(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - let args: Vec = std::env::args().skip(1).collect(); - let mut cmd = build_python_command(anki_bin, &args, config)?; - cmd.ensure_success()?; - Ok(()) -} - -pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - // On Windows, resources dir is the same as exe_dir - let resources_dir = exe_dir.clone(); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - // Windows uses standard uv binary name - "uv.exe" +pub fn prepare_to_launch_normally() { + // Set the App User Model ID for Windows taskbar grouping + unsafe { + let _ = + SetCurrentProcessExplicitAppUserModelID(PCWSTR(u16cstr!("Ankitects.Anki").as_ptr())); + } + + attach_to_parent_console(); } diff --git a/qt/launcher/versions.py b/qt/launcher/versions.py new file mode 100644 index 000000000..0fdf69c84 --- /dev/null +++ b/qt/launcher/versions.py @@ -0,0 +1,44 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import json +import sys + +import pip_system_certs.wrapt_requests +import requests + +pip_system_certs.wrapt_requests.inject_truststore() + + +def main(): + """Fetch and return all versions from PyPI, sorted by upload time.""" + url = "https://pypi.org/pypi/aqt/json" + + try: + 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)) + + # 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)) + except Exception as e: + print(f"Error fetching versions: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/qt/launcher/win/anki-console.bat b/qt/launcher/win/anki-console.bat deleted file mode 100644 index a565fa7b6..000000000 --- a/qt/launcher/win/anki-console.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -"%~dp0"\anki %* -pause - - diff --git a/qt/launcher/win/anki.template.nsi b/qt/launcher/win/anki.template.nsi index 7b2bfd8fc..36b32a893 100644 --- a/qt/launcher/win/anki.template.nsi +++ b/qt/launcher/win/anki.template.nsi @@ -24,7 +24,7 @@ Name "Anki" Unicode true ; The file to write (relative to nsis directory) -OutFile "..\launcher_exe\anki-install.exe" +OutFile "..\launcher_exe\anki-launcher-ANKI_VERSION-windows.exe" ; Non elevated RequestExecutionLevel user @@ -214,7 +214,7 @@ Section "" ; Write the uninstall keys for Windows WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayName" "Anki Launcher" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayVersion" "1.0.0" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayVersion" "ANKI_VERSION" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "UninstallString" '"$INSTDIR\uninstall.exe"' WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S' WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "NoModify" 1 @@ -250,8 +250,18 @@ FunctionEnd ; Uninstaller function un.onInit - MessageBox MB_OKCANCEL "This will remove Anki's program files, but will not delete your card data. If you wish to delete your card data as well, you can do so via File>Switch Profile inside Anki first. Are you sure you wish to uninstall Anki?" /SD IDOK IDOK next - Quit + ; Check for ANKI_LAUNCHER environment variable + ReadEnvStr $R0 "ANKI_LAUNCHER" + ${If} $R0 != "" + ; Wait for launcher to exit + Sleep 2000 + Goto next + ${Else} + ; Try to launch anki.exe with ANKI_LAUNCHER_UNINSTALL=1 + IfFileExists "$INSTDIR\anki.exe" 0 next + nsExec::Exec 'cmd /c "set ANKI_LAUNCHER_UNINSTALL=1 && start /b "" "$INSTDIR\anki.exe""' + Quit + ${EndIf} next: functionEnd diff --git a/qt/launcher/win/build.bat b/qt/launcher/win/build.bat index b21831462..da574f210 100644 --- a/qt/launcher/win/build.bat +++ b/qt/launcher/win/build.bat @@ -1,5 +1,10 @@ @echo off -set CODESIGN=1 -REM set NO_COMPRESS=1 +if "%NOCOMP%"=="1" ( + set NO_COMPRESS=1 + set CODESIGN=0 +) else ( + set CODESIGN=1 + set NO_COMPRESS=0 +) cargo run --bin build_win diff --git a/qt/mac/anki_mac_helper/__init__.py b/qt/mac/anki_mac_helper/__init__.py new file mode 100644 index 000000000..a0adb469e --- /dev/null +++ b/qt/mac/anki_mac_helper/__init__.py @@ -0,0 +1,51 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import sys +from collections.abc import Callable +from ctypes import CDLL, CFUNCTYPE, c_bool, c_char_p +from pathlib import Path + + +class _MacOSHelper: + def __init__(self) -> None: + # Look for the dylib in the same directory as this module + module_dir = Path(__file__).parent + path = module_dir / "libankihelper.dylib" + + self._dll = CDLL(str(path)) + self._dll.system_is_dark.restype = c_bool + + def system_is_dark(self) -> bool: + return self._dll.system_is_dark() + + def set_darkmode_enabled(self, enabled: bool) -> bool: + return self._dll.set_darkmode_enabled(enabled) + + def start_wav_record(self, path: str, on_error: Callable[[str], None]) -> None: + global _on_audio_error + _on_audio_error = on_error + self._dll.start_wav_record(path.encode("utf8"), _audio_error_callback) + + def end_wav_record(self) -> None: + "On completion, file should be saved if no error has arrived." + self._dll.end_wav_record() + + +# this must not be overwritten or deallocated +@CFUNCTYPE(None, c_char_p) # type: ignore +def _audio_error_callback(msg: str) -> None: + if handler := _on_audio_error: + handler(msg) + + +_on_audio_error: Callable[[str], None] | None = None + +macos_helper: _MacOSHelper | None = None +if sys.platform == "darwin": + try: + macos_helper = _MacOSHelper() + except Exception as e: + print("macos_helper:", e) diff --git a/qt/mac/anki_mac_helper/py.typed b/qt/mac/anki_mac_helper/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/qt/mac/build.sh b/qt/mac/build.sh new file mode 100755 index 000000000..4c14a13f4 --- /dev/null +++ b/qt/mac/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +set -e + +# Get the project root directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJ_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# Build the dylib first +echo "Building macOS helper dylib..." +"$PROJ_ROOT/out/pyenv/bin/python" "$SCRIPT_DIR/helper_build.py" + +# Create the wheel using uv +echo "Creating wheel..." +cd "$SCRIPT_DIR" +"$PROJ_ROOT/out/extracted/uv/uv" build --wheel + +echo "Build complete!" diff --git a/qt/mac/helper_build.py b/qt/mac/helper_build.py index 0638c9249..aaf997669 100644 --- a/qt/mac/helper_build.py +++ b/qt/mac/helper_build.py @@ -7,8 +7,16 @@ import subprocess import sys from pathlib import Path -out_dylib, *src_files = sys.argv[1:] -out_dir = Path(out_dylib).parent.resolve() +# If no arguments provided, build for the anki_mac_helper package +if len(sys.argv) == 1: + script_dir = Path(__file__).parent + out_dylib = script_dir / "anki_mac_helper" / "libankihelper.dylib" + src_files = list(script_dir.glob("*.swift")) +else: + out_dylib, *src_files = sys.argv[1:] + +out_dylib = Path(out_dylib) +out_dir = out_dylib.parent.resolve() src_dir = Path(src_files[0]).parent.resolve() # Build for both architectures @@ -29,12 +37,20 @@ for arch in architectures: "ankihelper", "-O", ] - args.extend(src_dir / Path(file).name for file in src_files) + if isinstance(src_files[0], Path): + args.extend(src_files) + else: + args.extend(src_dir / Path(file).name for file in src_files) args.extend(["-o", str(temp_out)]) subprocess.run(args, check=True, cwd=out_dir) +# Ensure output directory exists +out_dir.mkdir(parents=True, exist_ok=True) + # Create universal binary -lipo_args = ["lipo", "-create", "-output", out_dylib] + [str(f) for f in temp_files] +lipo_args = ["lipo", "-create", "-output", str(out_dylib)] + [ + str(f) for f in temp_files +] subprocess.run(lipo_args, check=True) # Clean up temporary files diff --git a/qt/mac/pyproject.toml b/qt/mac/pyproject.toml new file mode 100644 index 000000000..93f4e939b --- /dev/null +++ b/qt/mac/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "anki-mac-helper" +version = "0.1.0" +description = "Small support library for Anki on Macs" +requires-python = ">=3.9" +license = { text = "AGPL-3.0-or-later" } +authors = [ + { name = "Anki Team" }, +] +urls = { Homepage = "https://github.com/ankitects/anki" } + +[tool.hatch.build.targets.wheel] +packages = ["anki_mac_helper"] diff --git a/qt/pyproject.toml b/qt/pyproject.toml index ab16afc3d..1ff34f59d 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -1,7 +1,6 @@ [project] name = "aqt" -# dynamic = ["version"] -version = "0.1.2" +dynamic = ["version"] requires-python = ">=3.9" license = "AGPL-3.0-or-later" dependencies = [ @@ -12,18 +11,12 @@ dependencies = [ "requests", "send2trash", "waitress>=2.0.0", - "psutil; sys.platform == 'win32'", "pywin32; sys.platform == 'win32'", + "anki-mac-helper; sys.platform == 'darwin'", "pip-system-certs!=5.1", - "mock", - "types-decorator", - "types-flask", - "types-flask-cors", - "types-markdown", - "types-waitress", - "types-pywin32", "pyqt6>=6.2", "pyqt6-webengine>=6.2", + # anki dependency is added dynamically in hatch_build.py with exact version ] [project.optional-dependencies] @@ -45,6 +38,13 @@ qt67 = [ "pyqt6_sip==13.10.2", ] qt = [ + "pyqt6==6.9.1", + "pyqt6-qt6==6.9.1", + "pyqt6-webengine==6.8.0", + "pyqt6-webengine-qt6==6.8.2", + "pyqt6_sip==13.10.2", +] +qt68 = [ "pyqt6==6.8.0", "pyqt6-qt6==6.8.1", "pyqt6-webengine==6.8.0", @@ -58,9 +58,13 @@ conflicts = [ { extra = "qt" }, { extra = "qt66" }, { extra = "qt67" }, + { extra = "qt68" }, ], ] +[tool.uv.sources] +anki = { workspace = true } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -68,9 +72,12 @@ build-backend = "hatchling.build" [project.scripts] anki = "aqt:run" +[project.gui-scripts] +ankiw = "aqt:run" + [tool.hatch.build.targets.wheel] packages = ["aqt"] -exclude = ["**/*.scss", "**/*.ui"] +exclude = ["aqt/data", "**/*.ui"] [tool.hatch.version] source = "code" diff --git a/qt/release/update.sh b/qt/release/build.sh similarity index 77% rename from qt/release/update.sh rename to qt/release/build.sh index e25eaec49..423638bc4 100755 --- a/qt/release/update.sh +++ b/qt/release/build.sh @@ -2,7 +2,7 @@ set -e -test -f update.sh || { +test -f build.sh || { echo "run from release folder" exit 1 } @@ -13,8 +13,8 @@ PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" # Use extracted uv binary UV="$PROJ_ROOT/out/extracted/uv/uv" -# Prompt for wheel version -read -p "Wheel version: " VERSION +# Read version from .version file +VERSION=$(cat "$PROJ_ROOT/.version" | tr -d '[:space:]') # Copy existing pyproject.toml to .old if it exists if [ -f pyproject.toml ]; then @@ -24,7 +24,7 @@ fi # Export dependencies using uv echo "Exporting dependencies..." rm -f pyproject.toml -DEPS=$("$UV" export --no-hashes --no-annotate --no-header --extra audio --extra qt --all-packages --no-dev --no-emit-workspace) +DEPS=$(cd "$PROJ_ROOT" && "$UV" export --no-hashes --no-annotate --no-header --extra audio --extra qt --all-packages --no-dev --no-emit-workspace) # Generate the pyproject.toml file cat > pyproject.toml << EOF @@ -49,12 +49,6 @@ done cat >> pyproject.toml << 'EOF' ] -[[tool.uv.index]] -name = "testpypi" -url = "https://test.pypi.org/simple/" -publish-url = "https://test.pypi.org/legacy/" -explicit = true - [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -69,6 +63,9 @@ echo "Generated pyproject.toml with version $VERSION" # Show diff if .old file exists if [ -f pyproject.toml.old ]; then echo - echo "Differences from previous version:" + echo "Differences from previous release version:" diff -u --color=always pyproject.toml.old pyproject.toml || true fi + +echo "Building wheel..." +"$UV" build --wheel --out-dir "$PROJ_ROOT/out/wheels" diff --git a/qt/release/publish.sh b/qt/release/publish.sh deleted file mode 100755 index b906637f9..000000000 --- a/qt/release/publish.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test) - -# Get the project root (two levels up from qt/release) -PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" - -# Use extracted uv binary -UV="$PROJ_ROOT/out/extracted/uv/uv" - -rm -rf dist -"$UV" build --wheel -"$UV" publish --index testpypi diff --git a/qt/tools/build_ui.py b/qt/tools/build_ui.py index 776375598..b87031213 100644 --- a/qt/tools/build_ui.py +++ b/qt/tools/build_ui.py @@ -6,16 +6,10 @@ from __future__ import annotations import io import re import sys +from dataclasses import dataclass from pathlib import Path -try: - from PyQt6.uic import compileUi -except ImportError: - # ARM64 Linux builds may not have access to PyQt6, and may have aliased - # it to PyQt5. We allow fallback, but the _qt6.py files will not be valid. - from PyQt5.uic import compileUi # type: ignore - -from dataclasses import dataclass +from PyQt6.uic import compileUi def compile(ui_file: str | Path) -> str: @@ -53,21 +47,9 @@ def with_fixes_for_qt6(code: str) -> str: return "\n".join(outlines) -def with_fixes_for_qt5(code: str) -> str: - code = code.replace( - "from PyQt5 import QtCore, QtGui, QtWidgets", - "from PyQt5 import QtCore, QtGui, QtWidgets\nfrom aqt.utils import tr\n", - ) - code = code.replace("Qt6", "Qt5") - code = code.replace("QtGui.QAction", "QtWidgets.QAction") - code = code.replace("import icons_rc", "") - return code - - @dataclass class UiFileAndOutputs: ui_file: Path - qt5_file: str qt6_file: str @@ -82,7 +64,6 @@ def get_files() -> list[UiFileAndOutputs]: out.append( UiFileAndOutputs( ui_file=path, - qt5_file=outpath.replace(".ui", "_qt5.py"), qt6_file=outpath.replace(".ui", "_qt6.py"), ) ) @@ -93,8 +74,5 @@ if __name__ == "__main__": for entry in get_files(): stock = compile(entry.ui_file) for_qt6 = with_fixes_for_qt6(stock) - for_qt5 = with_fixes_for_qt5(for_qt6) - with open(entry.qt5_file, "w") as file: - file.write(for_qt5) with open(entry.qt6_file, "w") as file: file.write(for_qt6) diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index d3c7e215f..9be9e8d87 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -48,6 +48,7 @@ async-trait.workspace = true axum.workspace = true axum-client-ip.workspace = true axum-extra.workspace = true +bitflags.workspace = true blake3.workspace = true bytes.workspace = true chrono.workspace = true @@ -80,6 +81,7 @@ pin-project.workspace = true prost.workspace = true pulldown-cmark.workspace = true rand.workspace = true +rayon.workspace = true regex.workspace = true reqwest.workspace = true rusqlite.workspace = true diff --git a/rslib/i18n/check.rs b/rslib/i18n/check.rs index f168406d1..48521bbdd 100644 --- a/rslib/i18n/check.rs +++ b/rslib/i18n/check.rs @@ -21,14 +21,11 @@ pub fn check(lang_map: &TranslationsByLang) { fn check_content(lang: &str, fname: &str, content: &str) { let lang_id: LanguageIdentifier = "en-US".parse().unwrap(); let resource = FluentResource::try_new(content.into()).unwrap_or_else(|e| { - panic!("{}\nUnable to parse {}/{}: {:?}", content, lang, fname, e); + panic!("{content}\nUnable to parse {lang}/{fname}: {e:?}"); }); let mut bundle: FluentBundle = FluentBundle::new(vec![lang_id]); bundle.add_resource(resource).unwrap_or_else(|e| { - panic!( - "{}\nUnable to bundle - duplicate key? {}/{}: {:?}", - content, lang, fname, e - ); + panic!("{content}\nUnable to bundle - duplicate key? {lang}/{fname}: {e:?}"); }); } diff --git a/rslib/i18n/gather.rs b/rslib/i18n/gather.rs index a25e5813e..835e108af 100644 --- a/rslib/i18n/gather.rs +++ b/rslib/i18n/gather.rs @@ -48,8 +48,7 @@ fn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) { let text = fs::read_to_string(entry.path()).unwrap(); assert!( text.ends_with('\n'), - "file was missing final newline: {:?}", - entry + "file was missing final newline: {entry:?}" ); map_entry.entry(module).or_default().push_str(&text); println!("cargo:rerun-if-changed={}", entry.path().to_str().unwrap()); diff --git a/rslib/i18n/src/lib.rs b/rslib/i18n/src/lib.rs index 1d79198bf..bfd6f5ba2 100644 --- a/rslib/i18n/src/lib.rs +++ b/rslib/i18n/src/lib.rs @@ -130,7 +130,7 @@ fn get_bundle( ) -> Option> { let res = FluentResource::try_new(text.into()) .map_err(|e| { - println!("Unable to parse translations file: {:?}", e); + println!("Unable to parse translations file: {e:?}"); }) .ok()?; @@ -138,14 +138,14 @@ fn get_bundle( bundle .add_resource(res) .map_err(|e| { - println!("Duplicate key detected in translation file: {:?}", e); + println!("Duplicate key detected in translation file: {e:?}"); }) .ok()?; if !extra_text.is_empty() { match FluentResource::try_new(extra_text) { Ok(res) => bundle.add_resource_overriding(res), - Err((_res, e)) => println!("Unable to parse translations file: {:?}", e), + Err((_res, e)) => println!("Unable to parse translations file: {e:?}"), } } @@ -291,7 +291,7 @@ impl I18n { let mut errs = vec![]; let out = bundle.format_pattern(pat, args.as_ref(), &mut errs); if !errs.is_empty() { - println!("Error(s) in translation '{}': {:?}", key, errs); + println!("Error(s) in translation '{key}': {errs:?}"); } // clone so we can discard args return out.to_string().into(); diff --git a/rslib/i18n/typescript.rs b/rslib/i18n/typescript.rs index 0c2230338..ce30048e2 100644 --- a/rslib/i18n/typescript.rs +++ b/rslib/i18n/typescript.rs @@ -81,7 +81,7 @@ fn get_args(variables: &[Variable]) -> String { .iter() .map(|v| format!("\"{}\": args.{}", v.name, typescript_arg_name(&v.name))) .join(", "); - format!("{{{}}}", out) + format!("{{{out}}}") } } diff --git a/rslib/i18n/write_strings.rs b/rslib/i18n/write_strings.rs index f9df5716f..36af62eeb 100644 --- a/rslib/i18n/write_strings.rs +++ b/rslib/i18n/write_strings.rs @@ -69,12 +69,6 @@ impl I18n { {var_build} self.translate("{key}"{out_args}) }}"#, - func = func, - key = key, - doc = doc, - in_args = in_args, - out_args = out_args, - var_build = var_build, ) .unwrap(); } @@ -103,9 +97,6 @@ fn build_vars(translation: &Translation) -> String { writeln!( buf, r#" args.set("{fluent_name}", {rust_name}{trailer});"#, - fluent_name = fluent_name, - rust_name = rust_name, - trailer = trailer, ) .unwrap(); } @@ -204,13 +195,7 @@ pub(crate) const {lang_name}: phf::Map<&str, &str> = phf::phf_map! {{", .unwrap(); for (module, contents) in modules { - writeln!( - buf, - r###" "{module}" => r##"{contents}"##,"###, - module = module, - contents = contents - ) - .unwrap(); + writeln!(buf, r###" "{module}" => r##"{contents}"##,"###).unwrap(); } buf.push_str("};\n"); diff --git a/rslib/io/src/lib.rs b/rslib/io/src/lib.rs index c1d4c0205..cb44467e6 100644 --- a/rslib/io/src/lib.rs +++ b/rslib/io/src/lib.rs @@ -152,6 +152,34 @@ pub fn copy_file(src: impl AsRef, dst: impl AsRef) -> Result { }) } +/// Copy a file from src to dst if dst doesn't exist or if src is newer than +/// dst. Preserves the modification time from the source file. +pub fn copy_if_newer(src: impl AsRef, dst: impl AsRef) -> Result { + let src = src.as_ref(); + let dst = dst.as_ref(); + + let should_copy = if !dst.exists() { + true + } else { + let src_time = modified_time(src)?; + let dst_time = modified_time(dst)?; + src_time > dst_time + }; + + if should_copy { + copy_file(src, dst)?; + + // Preserve the modification time from the source file + let src_mtime = modified_time(src)?; + let times = FileTimes::new().set_modified(src_mtime); + set_file_times(dst, times)?; + + Ok(true) + } else { + Ok(false) + } +} + /// Like [read_file], but skips the section that is potentially locked by /// SQLite. pub fn read_locked_db_file(path: impl AsRef) -> Result> { @@ -188,6 +216,14 @@ pub fn metadata(path: impl AsRef) -> Result { }) } +/// Get the modification time of a file. +pub fn modified_time(path: impl AsRef) -> Result { + metadata(&path)?.modified().context(FileIoSnafu { + path: path.as_ref(), + op: FileOp::Metadata, + }) +} + pub fn new_tempfile() -> Result { NamedTempFile::new().context(FileIoSnafu { path: std::env::temp_dir(), diff --git a/rslib/process/src/lib.rs b/rslib/process/src/lib.rs index dcf0703f6..2a82bb9cc 100644 --- a/rslib/process/src/lib.rs +++ b/rslib/process/src/lib.rs @@ -11,6 +11,24 @@ use snafu::ensure; use snafu::ResultExt; use snafu::Snafu; +#[derive(Debug)] +pub struct CodeDisplay(Option); + +impl std::fmt::Display for CodeDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(code) => write!(f, "{code}"), + None => write!(f, "?"), + } + } +} + +impl From> for CodeDisplay { + fn from(code: Option) -> Self { + CodeDisplay(code) + } +} + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("Failed to execute: {cmdline}"))] @@ -18,8 +36,15 @@ pub enum Error { cmdline: String, source: std::io::Error, }, - #[snafu(display("Failed with code {code:?}: {cmdline}"))] - ReturnedError { cmdline: String, code: Option }, + #[snafu(display("Failed to run ({code}): {cmdline}"))] + ReturnedError { cmdline: String, code: CodeDisplay }, + #[snafu(display("Failed to run ({code}): {cmdline}: {stdout}{stderr}"))] + ReturnedWithOutputError { + cmdline: String, + code: CodeDisplay, + stdout: String, + stderr: String, + }, #[snafu(display("Couldn't decode stdout/stderr as utf8"))] InvalidUtf8 { cmdline: String, @@ -71,31 +96,36 @@ impl CommandExt for Command { status.success(), ReturnedSnafu { cmdline: get_cmdline(self), - code: status.code(), + code: CodeDisplay::from(status.code()), } ); Ok(self) } fn utf8_output(&mut self) -> Result { + let cmdline = get_cmdline(self); let output = self.output().with_context(|_| DidNotExecuteSnafu { - cmdline: get_cmdline(self), + cmdline: cmdline.clone(), })?; + + let stdout = String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { + cmdline: cmdline.clone(), + })?; + let stderr = String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu { + cmdline: cmdline.clone(), + })?; + ensure!( output.status.success(), - ReturnedSnafu { - cmdline: get_cmdline(self), - code: output.status.code(), + ReturnedWithOutputSnafu { + cmdline, + code: CodeDisplay::from(output.status.code()), + stdout: stdout.clone(), + stderr: stderr.clone(), } ); - Ok(Utf8Output { - stdout: String::from_utf8(output.stdout).with_context(|_| InvalidUtf8Snafu { - cmdline: get_cmdline(self), - })?, - stderr: String::from_utf8(output.stderr).with_context(|_| InvalidUtf8Snafu { - cmdline: get_cmdline(self), - })?, - }) + + Ok(Utf8Output { stdout, stderr }) } fn ensure_spawn(&mut self) -> Result { @@ -135,7 +165,10 @@ mod test { #[cfg(not(windows))] assert!(matches!( Command::new("false").ensure_success(), - Err(Error::ReturnedError { code: Some(1), .. }) + Err(Error::ReturnedError { + code: CodeDisplay(_), + .. + }) )); } } diff --git a/rslib/proto/python.rs b/rslib/proto/python.rs index 0ca2c15ea..a5adb4179 100644 --- a/rslib/proto/python.rs +++ b/rslib/proto/python.rs @@ -183,9 +183,9 @@ fn python_type(field: &FieldDescriptor, output: bool) -> String { }; if field.is_list() { if output { - format!("Sequence[{}]", kind) + format!("Sequence[{kind}]") } else { - format!("Iterable[{}]", kind) + format!("Iterable[{kind}]") } } else if field.is_map() { let map_kind = field.kind(); @@ -213,7 +213,6 @@ fn write_header(out: &mut impl Write) -> Result<()> { out.write_all( br#"# Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; https://www.gnu.org/licenses/agpl.html -# pylint: skip-file from __future__ import annotations diff --git a/rslib/rust_interface.rs b/rslib/rust_interface.rs index a75100b5b..6861df7dc 100644 --- a/rslib/rust_interface.rs +++ b/rslib/rust_interface.rs @@ -263,7 +263,7 @@ impl MethodHelpers for Method { fn get_input_arg_with_label(&self) -> String { self.input_type() .as_ref() - .map(|t| format!("input: {}", t)) + .map(|t| format!("input: {t}")) .unwrap_or_default() } diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index 349f2d9af..b6e81ce2a 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -39,6 +39,7 @@ impl From for BoolKey { BoolKeyProto::RenderLatex => BoolKey::RenderLatex, BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, BoolKeyProto::FsrsShortTermWithStepsEnabled => BoolKey::FsrsShortTermWithStepsEnabled, + BoolKeyProto::FsrsLegacyEvaluate => BoolKey::FsrsLegacyEvaluate, } } } diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index 022708a80..4d943e408 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -105,7 +105,8 @@ impl Card { /// Returns true if the card has a due date in terms of days. fn is_due_in_days(&self) -> bool { - matches!(self.queue, CardQueue::DayLearn | CardQueue::Review) + 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()) } @@ -125,20 +126,20 @@ 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, - ) - } else { + /// If last_review_date isn't stored in the card, this uses card.due and + /// card.ivl to infer the elapsed time, which won't be accurate if + /// 'set due date' or an add-on has changed the due date. + 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() { self.due_time(timing).map(|due| { (due.adding_secs(-86_400 * self.interval as i64) - .elapsed_secs() - / 86_400) as u32 + .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) } } } @@ -515,7 +516,7 @@ impl RowContext { return "".into(); }; if self.cards[0].is_undue_queue() { - format!("({})", due) + format!("({due})") } else { due.into() } @@ -541,12 +542,12 @@ impl RowContext { self.cards[0] .memory_state .as_ref() - .zip(self.cards[0].days_since_last_review(&self.timing)) + .zip(self.cards[0].seconds_since_last_review(&self.timing)) .zip(Some(self.cards[0].decay.unwrap_or(FSRS5_DEFAULT_DECAY))) - .map(|((state, days_elapsed), decay)| { - let r = FSRS::new(None).unwrap().current_retrievability( + .map(|((state, seconds), decay)| { + let r = FSRS::new(None).unwrap().current_retrievability_seconds( (*state).into(), - days_elapsed, + seconds, decay, ); format!("{:.0}%", r * 100.) @@ -623,7 +624,7 @@ impl RowContext { if self.notes_mode { let decks = self.cards.iter().map(|c| c.deck_id).unique().count(); if decks > 1 { - return format!("({})", decks); + return format!("({decks})"); } } let deck_name = self.deck.human_name(); diff --git a/rslib/src/card/mod.rs b/rslib/src/card/mod.rs index 49d952ecf..b6b9ce807 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(), } } @@ -185,12 +187,16 @@ impl Card { self.usn = usn; } - /// Caller must ensure provided deck exists and is not filtered. - fn set_deck(&mut self, deck: DeckId) { - self.remove_from_filtered_deck_restoring_queue(); + pub fn clear_fsrs_data(&mut self) { self.memory_state = None; self.desired_retention = None; self.decay = None; + } + + /// Caller must ensure provided deck exists and is not filtered. + fn set_deck(&mut self, deck: DeckId) { + self.remove_from_filtered_deck_restoring_queue(); + self.clear_fsrs_data(); self.deck_id = deck; } 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/card_rendering/parser.rs b/rslib/src/card_rendering/parser.rs index 6f1cc662e..b124c069d 100644 --- a/rslib/src/card_rendering/parser.rs +++ b/rslib/src/card_rendering/parser.rs @@ -14,14 +14,14 @@ use nom::combinator::recognize; use nom::combinator::rest; use nom::combinator::success; use nom::combinator::value; -use nom::multi::fold_many0; use nom::multi::many0; use nom::sequence::delimited; use nom::sequence::pair; use nom::sequence::preceded; use nom::sequence::separated_pair; use nom::sequence::terminated; -use nom::sequence::tuple; +use nom::Input; +use nom::Parser; use super::CardNodes; use super::Directive; @@ -86,9 +86,12 @@ impl<'a> Directive<'a> { } /// Consume 0 or more of anything in " \t\r\n" after `parser`. -fn trailing_whitespace0<'parser, 's, P, O>(parser: P) -> impl FnMut(&'s str) -> IResult<'s, O> +fn trailing_whitespace0(parser: P) -> impl Parser where - P: FnMut(&'s str) -> IResult<'s, O> + 'parser, + I: Input, + ::Item: nom::AsChar, + E: nom::error::ParseError, + P: Parser, { terminated(parser, multispace0) } @@ -97,11 +100,11 @@ where fn is_not0<'parser, 'arr: 'parser, 's: 'parser>( arr: &'arr str, ) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser { - alt((is_not(arr), success(""))) + move |s| alt((is_not(arr), success(""))).parse(s) } fn node(s: &str) -> IResult { - alt((sound_node, tag_node, text_node))(s) + alt((sound_node, tag_node, text_node)).parse(s) } /// A sound tag `[sound:resource]`, where `resource` is pointing to a sound or @@ -110,11 +113,11 @@ fn sound_node(s: &str) -> IResult { map( delimited(tag("[sound:"), is_not("]"), tag("]")), Node::SoundOrVideo, - )(s) + ) + .parse(s) } fn take_till_potential_tag_start(s: &str) -> IResult<&str> { - use nom::InputTake; // first char could be '[', but wasn't part of a node, so skip (eof ends parse) let (after, offset) = anychar(s).map(|(s, c)| (s, c.len_utf8()))?; Ok(match after.find('[') { @@ -127,7 +130,7 @@ fn take_till_potential_tag_start(s: &str) -> IResult<&str> { fn tag_node(s: &str) -> IResult { /// Match the start of an opening tag and return its name. fn name(s: &str) -> IResult<&str> { - preceded(tag("[anki:"), is_not("] \t\r\n"))(s) + preceded(tag("[anki:"), is_not("] \t\r\n")).parse(s) } /// Return a parser to match an opening `name` tag and return its options. @@ -138,31 +141,35 @@ fn tag_node(s: &str) -> IResult { /// empty. fn options(s: &str) -> IResult> { fn key(s: &str) -> IResult<&str> { - is_not("] \t\r\n=")(s) + is_not("] \t\r\n=").parse(s) } fn val(s: &str) -> IResult<&str> { alt(( delimited(tag("\""), is_not0("\""), tag("\"")), is_not0("] \t\r\n\""), - ))(s) + )) + .parse(s) } - many0(trailing_whitespace0(separated_pair(key, tag("="), val)))(s) + many0(trailing_whitespace0(separated_pair(key, tag("="), val))).parse(s) } - delimited( - pair(tag("[anki:"), trailing_whitespace0(tag(name))), - options, - tag("]"), - ) + move |s| { + delimited( + pair(tag("[anki:"), trailing_whitespace0(tag(name))), + options, + tag("]"), + ) + .parse(s) + } } /// Return a parser to match a closing `name` tag. fn closing_parser<'parser, 'name: 'parser, 's: 'parser>( name: &'name str, ) -> impl FnMut(&'s str) -> IResult<'s, ()> + 'parser { - value((), tuple((tag("[/anki:"), tag(name), tag("]")))) + move |s| value((), (tag("[/anki:"), tag(name), tag("]"))).parse(s) } /// Return a parser to match and return anything until a closing `name` tag @@ -170,12 +177,13 @@ fn tag_node(s: &str) -> IResult { fn content_parser<'parser, 'name: 'parser, 's: 'parser>( name: &'name str, ) -> impl FnMut(&'s str) -> IResult<'s, &'s str> + 'parser { - recognize(fold_many0( - pair(not(closing_parser(name)), take_till_potential_tag_start), - // we don't need to accumulate anything - || (), - |_, _| (), - )) + move |s| { + recognize(many0(pair( + not(closing_parser(name)), + take_till_potential_tag_start, + ))) + .parse(s) + } } let (_, tag_name) = name(s)?; @@ -185,11 +193,12 @@ fn tag_node(s: &str) -> IResult { closing_parser(tag_name), ), |(options, content)| Node::Directive(Directive::new(tag_name, options, content)), - )(s) + ) + .parse(s) } fn text_node(s: &str) -> IResult { - map(take_till_potential_tag_start, Node::Text)(s) + map(take_till_potential_tag_start, Node::Text).parse(s) } #[cfg(test)] diff --git a/rslib/src/card_rendering/writer.rs b/rslib/src/card_rendering/writer.rs index 892cb9087..22fb1fb34 100644 --- a/rslib/src/card_rendering/writer.rs +++ b/rslib/src/card_rendering/writer.rs @@ -52,7 +52,7 @@ trait Write { } fn write_sound(&mut self, buf: &mut String, resource: &str) { - write!(buf, "[sound:{}]", resource).unwrap(); + write!(buf, "[sound:{resource}]").unwrap(); } fn write_directive(&mut self, buf: &mut String, directive: &Directive) { @@ -94,9 +94,9 @@ trait Write { fn write_directive_option(&mut self, buf: &mut String, key: &str, val: &str) { if val.contains([']', ' ', '\t', '\r', '\n']) { - write!(buf, " {}=\"{}\"", key, val).unwrap(); + write!(buf, " {key}=\"{val}\"").unwrap(); } else { - write!(buf, " {}={}", key, val).unwrap(); + write!(buf, " {key}={val}").unwrap(); } } @@ -158,7 +158,7 @@ impl Write for AvExtractor<'_> { fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) { if let Some(error) = directive.error(self.tr) { - write!(buf, "[{}]", error).unwrap(); + write!(buf, "[{error}]").unwrap(); return; } @@ -173,7 +173,7 @@ impl Write for AvExtractor<'_> { other_args: directive .options .iter() - .map(|(key, val)| format!("{}={}", key, val)) + .map(|(key, val)| format!("{key}={val}")) .collect(), }, )), @@ -204,7 +204,7 @@ impl AvPrettifier { impl Write for AvPrettifier { fn write_sound(&mut self, buf: &mut String, resource: &str) { - write!(buf, "🔉{}🔉", resource).unwrap(); + write!(buf, "🔉{resource}🔉").unwrap(); } fn write_tts_directive(&mut self, buf: &mut String, directive: &TtsDirective) { diff --git a/rslib/src/cloze.rs b/rslib/src/cloze.rs index f57d07ab0..02919dc12 100644 --- a/rslib/src/cloze.rs +++ b/rslib/src/cloze.rs @@ -15,6 +15,7 @@ use nom::bytes::complete::tag; use nom::bytes::complete::take_while; use nom::combinator::map; use nom::IResult; +use nom::Parser; use regex::Captures; use regex::Regex; @@ -24,6 +25,9 @@ use crate::latex::contains_latex; use crate::template::RenderContext; use crate::text::strip_html_preserving_entities; +static CLOZE: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)\{\{c\d+::(.*?)(::.*?)?\}\}").unwrap()); + static MATHJAX: LazyLock = LazyLock::new(|| { Regex::new( r"(?xsi) @@ -72,7 +76,7 @@ fn tokenize(mut text: &str) -> impl Iterator { } fn close_cloze(text: &str) -> IResult<&str, Token> { - map(tag("}}"), |_| Token::CloseCloze)(text) + map(tag("}}"), |_| Token::CloseCloze).parse(text) } /// Match a run of text until an open/close marker is encountered. @@ -87,7 +91,7 @@ fn tokenize(mut text: &str) -> impl Iterator { // start with the no-match case let mut index = text.len(); for (idx, _) in text.char_indices() { - if other_token(&text[idx..]).is_ok() { + if other_token.parse(&text[idx..]).is_ok() { index = idx; break; } @@ -99,8 +103,9 @@ fn tokenize(mut text: &str) -> impl Iterator { if text.is_empty() { None } else { - let (remaining_text, token) = - alt((open_cloze, close_cloze, normal_text))(text).unwrap(); + let (remaining_text, token) = alt((open_cloze, close_cloze, normal_text)) + .parse(text) + .unwrap(); text = remaining_text; Some(token) } @@ -451,6 +456,10 @@ pub fn cloze_number_in_fields(fields: impl IntoIterator>) -> Ha set } +pub(crate) fn strip_clozes(text: &str) -> Cow<'_, str> { + CLOZE.replace_all(text, "$1") +} + fn strip_html_inside_mathjax(text: &str) -> Cow { MATHJAX.replace_all(text, |caps: &Captures| -> String { format!( @@ -608,6 +617,16 @@ mod test { ); } + #[test] + fn strip_clozes_regex() { + assert_eq!( + strip_clozes( + r#"The {{c1::moon::🌛}} {{c2::orbits::this hint has "::" in it}} the {{c3::🌏}}."# + ), + "The moon orbits the 🌏." + ); + } + #[test] fn mathjax_html() { // escaped angle brackets should be preserved diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index 39273b931..c76787cb0 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -41,6 +41,7 @@ pub enum BoolKey { WithDeckConfigs, Fsrs, FsrsHealthCheck, + FsrsLegacyEvaluate, LoadBalancerEnabled, FsrsShortTermWithStepsEnabled, #[strum(to_string = "normalize_note_text")] diff --git a/rslib/src/config/deck.rs b/rslib/src/config/deck.rs index d684534c0..a88ca61a7 100644 --- a/rslib/src/config/deck.rs +++ b/rslib/src/config/deck.rs @@ -41,5 +41,5 @@ impl Collection { } fn build_aux_deck_key(deck: DeckId, key: &str) -> String { - format!("_deck_{deck}_{key}", deck = deck, key = key) + format!("_deck_{deck}_{key}") } diff --git a/rslib/src/config/notetype.rs b/rslib/src/config/notetype.rs index 0d3fd9611..f9b70292d 100644 --- a/rslib/src/config/notetype.rs +++ b/rslib/src/config/notetype.rs @@ -32,7 +32,7 @@ impl Collection { }; Ok(get_aux_notetype_config_key( ntid, - &format!("{}_{}", key, ordinal), + &format!("{key}_{ordinal}"), )) } } @@ -70,5 +70,5 @@ impl Collection { } pub fn get_aux_notetype_config_key(ntid: NotetypeId, key: &str) -> String { - format!("_nt_{ntid}_{key}", ntid = ntid, key = key) + format!("_nt_{ntid}_{key}") } diff --git a/rslib/src/dbcheck.rs b/rslib/src/dbcheck.rs index ae960ab5c..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(()) } @@ -387,10 +405,10 @@ impl Collection { let mut basic = all_stock_notetypes(&self.tr).remove(0); let mut field = 3; while basic.fields.len() < field_count { - basic.add_field(format!("{}", field)); + basic.add_field(format!("{field}")); field += 1; } - basic.name = format!("db-check-{}-{}", stamp, field_count); + basic.name = format!("db-check-{stamp}-{field_count}"); let qfmt = basic.templates[0].config.q_format.clone(); let afmt = basic.templates[0].config.a_format.clone(); for n in 0..extra_cards_required { diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index bc6bce8f4..11c4288d3 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -1,6 +1,10 @@ // 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 anki_proto::generic; +use rayon::iter::IntoParallelIterator; +use rayon::iter::ParallelIterator; use crate::collection::Collection; use crate::deckconfig::DeckConfSchema11; @@ -9,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( @@ -101,68 +106,41 @@ impl crate::services::DeckConfigService for Collection { &mut self, input: anki_proto::deck_config::GetRetentionWorkloadRequest, ) -> Result { - const LEARN_SPAN: usize = 100_000_000; - const TERMINATION_PROB: f32 = 0.001; - // the default values are from https://github.com/open-spaced-repetition/Anki-button-usage/blob/881009015c2a85ac911021d76d0aacb124849937/analysis.ipynb - const DEFAULT_LEARN_COST: f32 = 19.4698; - const DEFAULT_PASS_COST: f32 = 7.8454; - const DEFAULT_FAIL_COST: f32 = 23.185; - const DEFAULT_INITIAL_PASS_RATE: f32 = 0.7645; - + let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let guard = self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; - let costs = guard.col.storage.get_costs_for_retention()?; - fn smoothing(obs: f32, default: f32, count: u32) -> f32 { - let alpha = count as f32 / (50.0 + count as f32); - obs * alpha + default * (1.0 - alpha) - } + let revlogs = guard + .col + .storage + .get_revlog_entries_for_searched_cards_in_card_order()?; - let cost_success = smoothing( - costs.average_pass_time_ms / 1000.0, - DEFAULT_PASS_COST, - costs.pass_count, - ); - let cost_failure = smoothing( - costs.average_fail_time_ms / 1000.0, - DEFAULT_FAIL_COST, - costs.fail_count, - ); - let cost_learn = smoothing( - costs.average_learn_time_ms / 1000.0, - DEFAULT_LEARN_COST, - costs.learn_count, - ); - let initial_pass_rate = smoothing( - costs.initial_pass_rate, - DEFAULT_INITIAL_PASS_RATE, - costs.pass_count, - ); + 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 before = fsrs::expected_workload( - &input.w, - input.before, - LEARN_SPAN, - cost_success, - cost_failure, - cost_learn, - initial_pass_rate, - TERMINATION_PROB, - )?; - let after = fsrs::expected_workload( - &input.w, - input.after, - LEARN_SPAN, - cost_success, - cost_failure, - cost_learn, - initial_pass_rate, - TERMINATION_PROB, - )?; + let costs = (70u32..=99u32) + .into_par_iter() + .map(|dr| { + Ok(( + dr, + fsrs::expected_workload_with_existing_cards( + &input.w, + dr as f32 / 100., + &config, + &cards, + )?, + )) + }) + .collect::>>()?; - Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { - factor: after / before, - }) + Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { costs }) } } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 128e43770..0bd549a20 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -74,6 +74,7 @@ impl Collection { apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits), fsrs: self.get_config_bool(BoolKey::Fsrs), fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck), + fsrs_legacy_evaluate: self.get_config_bool(BoolKey::FsrsLegacyEvaluate), days_since_last_fsrs_optimize, }) } @@ -211,10 +212,13 @@ impl Collection { if fsrs_toggled { self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; } + let mut deck_desired_retention: HashMap = Default::default(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { let deck_id = deck.id; - + if let Some(desired_retention) = normal.desired_retention { + deck_desired_retention.insert(deck_id, desired_retention); + } // previous order & params let previous_config_id = DeckConfigId(normal.config_id); let previous_config = configs_before_update.get(&previous_config_id); @@ -276,10 +280,11 @@ impl Collection { if req.fsrs { Some(UpdateMemoryStateRequest { params: c.fsrs_params().clone(), - desired_retention: c.inner.desired_retention, + preset_desired_retention: c.inner.desired_retention, max_interval: c.inner.maximum_review_interval, reschedule: req.fsrs_reschedule, historical_retention: c.inner.historical_retention, + deck_desired_retention: deck_desired_retention.clone(), }) } else { None @@ -408,6 +413,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { .new_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), + desired_retention: deck.desired_retention, } } @@ -416,6 +422,7 @@ fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) { deck.new_limit = limits.new; update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today); + deck.desired_retention = limits.desired_retention; } fn update_day_limit(day_limit: &mut Option, new_limit: Option, today: u32) { diff --git a/rslib/src/decks/addupdate.rs b/rslib/src/decks/addupdate.rs index be4cb34cd..eb9e242a5 100644 --- a/rslib/src/decks/addupdate.rs +++ b/rslib/src/decks/addupdate.rs @@ -93,7 +93,7 @@ impl Collection { pub(crate) fn recover_missing_deck(&mut self, did: DeckId, usn: Usn) -> Result<()> { let mut deck = Deck::new_normal(); deck.id = did; - deck.name = NativeDeckName::from_native_str(format!("recovered{}", did)); + deck.name = NativeDeckName::from_native_str(format!("recovered{did}")); deck.set_modified(usn); self.add_or_update_single_deck_with_existing_id(&mut deck, usn) } diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index d16ebac49..44b5d9e59 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -31,6 +31,7 @@ pub(crate) use name::immediate_parent_name; pub use name::NativeDeckName; pub use schema11::DeckSchema11; +use crate::deckconfig::DeckConfig; use crate::define_newtype; use crate::error::FilteredDeckError; use crate::markdown::render_markdown; @@ -89,6 +90,16 @@ impl Deck { } } + /// Get the effective desired retention value for a deck. + /// Returns deck-specific desired retention if available, otherwise falls + /// back to config default. + pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 { + self.normal() + .ok() + .and_then(|d| d.desired_retention) + .unwrap_or(config.inner.desired_retention) + } + // used by tests at the moment #[allow(dead_code)] diff --git a/rslib/src/decks/remove.rs b/rslib/src/decks/remove.rs index befb770f8..a3bc78209 100644 --- a/rslib/src/decks/remove.rs +++ b/rslib/src/decks/remove.rs @@ -28,7 +28,7 @@ impl Collection { let card_count = match deck.kind { DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, DeckKind::Filtered(_) => { - self.return_all_cards_in_filtered_deck(deck.id)?; + self.return_all_cards_in_filtered_deck(deck)?; 0 } }; diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index e10820ca1..5cd4094f0 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -325,6 +325,7 @@ impl From for NormalDeck { new_limit: deck.new_limit, review_limit_today: deck.review_limit_today, new_limit_today: deck.new_limit_today, + desired_retention: None, } } } diff --git a/rslib/src/error/db.rs b/rslib/src/error/db.rs index b44f771dc..a36cd1a60 100644 --- a/rslib/src/error/db.rs +++ b/rslib/src/error/db.rs @@ -67,7 +67,7 @@ impl From for AnkiError { } AnkiError::DbError { source: DbError { - info: format!("{:?}", err), + info: format!("{err:?}"), kind: DbErrorKind::Other, }, } @@ -88,7 +88,7 @@ impl From for AnkiError { } AnkiError::DbError { source: DbError { - info: format!("{:?}", err), + info: format!("{err:?}"), kind: DbErrorKind::Other, }, } @@ -101,7 +101,7 @@ impl DbError { DbErrorKind::Corrupt => self.info.clone(), // fixme: i18n DbErrorKind::Locked => "Anki already open, or media currently syncing.".into(), - _ => format!("{:?}", self), + _ => format!("{self:?}"), } } } diff --git a/rslib/src/error/invalid_input.rs b/rslib/src/error/invalid_input.rs index f62174578..970e1c692 100644 --- a/rslib/src/error/invalid_input.rs +++ b/rslib/src/error/invalid_input.rs @@ -26,7 +26,7 @@ impl InvalidInputError { pub fn context(&self) -> String { if let Some(source) = &self.source { - format!("{}", source) + format!("{source}") } else { String::new() } diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index 0da89e0ff..d2bd11cf0 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -149,13 +149,13 @@ impl AnkiError { } CardTypeErrorDetails::MissingCloze => tr.card_templates_missing_cloze(), }; - format!("{}
{}", header, details) + format!("{header}
{details}") } AnkiError::DbError { source } => source.message(tr), AnkiError::SearchError { source } => source.message(tr), AnkiError::ParseNumError => tr.errors_parse_number_fail().into(), AnkiError::FilteredDeckError { source } => source.message(tr), - AnkiError::InvalidRegex { info: source } => format!("

{}
", source), + AnkiError::InvalidRegex { info: source } => format!("
{source}
"), AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(), AnkiError::DatabaseCheckRequired => tr.errors_please_check_database().into(), AnkiError::MediaCheckRequired => tr.errors_please_check_media().into(), @@ -172,7 +172,7 @@ impl AnkiError { | AnkiError::InvalidServiceIndex | AnkiError::InvalidMethodIndex | AnkiError::UndoEmpty - | AnkiError::InvalidCertificateFormat => format!("{:?}", self), + | AnkiError::InvalidCertificateFormat => format!("{self:?}"), AnkiError::FileIoError { source } => source.message(), AnkiError::InvalidInput { source } => source.message(), AnkiError::NotFound { source } => source.message(tr), diff --git a/rslib/src/error/network.rs b/rslib/src/error/network.rs index 469978cff..eb293c359 100644 --- a/rslib/src/error/network.rs +++ b/rslib/src/error/network.rs @@ -68,7 +68,7 @@ impl AnkiError { impl From<&reqwest::Error> for AnkiError { fn from(err: &reqwest::Error) -> Self { let url = err.url().map(|url| url.as_str()).unwrap_or(""); - let str_err = format!("{}", err); + let str_err = format!("{err}"); // strip url from error to avoid exposing keys let info = str_err.replace(url, ""); @@ -205,7 +205,7 @@ impl NetworkError { NetworkErrorKind::Other => tr.network_other(), }; let details = tr.network_details(self.info.as_str()); - format!("{}\n\n{}", summary, details) + format!("{summary}\n\n{details}") } } @@ -226,7 +226,7 @@ impl From for AnkiError { } .into() } else { - AnkiError::sync_error(format!("{:?}", err), SyncErrorKind::Other) + AnkiError::sync_error(format!("{err:?}"), SyncErrorKind::Other) } } } diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs index 9319d85c6..fdf8ea4fd 100644 --- a/rslib/src/image_occlusion/imagedata.rs +++ b/rslib/src/image_occlusion/imagedata.rs @@ -77,7 +77,7 @@ impl Collection { ) -> Result { let value = match self.get_image_occlusion_note_inner(note_id) { Ok(note) => Value::Note(note), - Err(err) => Value::Error(format!("{:?}", err)), + Err(err) => Value::Error(format!("{err:?}")), }; Ok(GetImageOcclusionNoteResponse { value: Some(value) }) } diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs index 2ba83374f..1de86bf87 100644 --- a/rslib/src/image_occlusion/imageocclusion.rs +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -13,6 +13,7 @@ use nom::character::complete::char; use nom::error::ErrorKind; use nom::sequence::preceded; use nom::sequence::separated_pair; +use nom::Parser; fn unescape(text: &str) -> String { text.replace("\\:", ":") @@ -22,11 +23,12 @@ pub fn parse_image_cloze(text: &str) -> Option { if let Some((shape, _)) = text.split_once(':') { let mut properties = vec![]; let mut remaining = &text[shape.len()..]; - while let Ok((rem, (name, value))) = separated_pair::<_, _, _, _, (_, ErrorKind), _, _, _>( + while let Ok((rem, (name, value))) = separated_pair::<_, _, _, (_, ErrorKind), _, _, _>( preceded(tag(":"), is_not("=")), tag("="), escaped(is_not("\\:"), '\\', char(':')), - )(remaining) + ) + .parse(remaining) { remaining = rem; let value = unescape(value); @@ -96,7 +98,7 @@ pub fn get_image_cloze_data(text: &str) -> String { let Some((x, y)) = point_pair.split_once(',') else { continue; }; - write!(&mut point_str, "{},{} ", x, y).unwrap(); + write!(&mut point_str, "{x},{y} ").unwrap(); } // remove the trailing space point_str.pop(); diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs index f8422f6bd..e45bbca1b 100644 --- a/rslib/src/import_export/text/csv/import.rs +++ b/rslib/src/import_export/text/csv/import.rs @@ -61,6 +61,7 @@ impl CsvDeckExt for CsvDeck { match self { Self::DeckId(did) => NameOrId::Id(*did), Self::DeckColumn(_) => NameOrId::default(), + Self::DeckName(name) => NameOrId::Name(name.into()), } } @@ -68,6 +69,7 @@ impl CsvDeckExt for CsvDeck { match self { Self::DeckId(_) => None, Self::DeckColumn(column) => Some(*column as usize), + Self::DeckName(_) => None, } } } diff --git a/rslib/src/import_export/text/csv/metadata.rs b/rslib/src/import_export/text/csv/metadata.rs index 7e2f64f5e..d505c60d2 100644 --- a/rslib/src/import_export/text/csv/metadata.rs +++ b/rslib/src/import_export/text/csv/metadata.rs @@ -163,6 +163,8 @@ impl Collection { "deck" => { if let Ok(Some(did)) = self.deck_id_by_name_or_id(&NameOrId::parse(value)) { metadata.deck = Some(CsvDeck::DeckId(did.0)); + } else if !value.is_empty() { + metadata.deck = Some(CsvDeck::DeckName(value.to_string())); } } "notetype column" => { @@ -626,6 +628,7 @@ pub(in crate::import_export) mod test { pub trait CsvMetadataTestExt { fn defaults_for_testing() -> Self; fn unwrap_deck_id(&self) -> i64; + fn unwrap_deck_name(&self) -> &str; fn unwrap_notetype_id(&self) -> i64; fn unwrap_notetype_map(&self) -> &[u32]; } @@ -660,6 +663,13 @@ pub(in crate::import_export) mod test { } } + fn unwrap_deck_name(&self) -> &str { + match &self.deck { + Some(CsvDeck::DeckName(name)) => name, + _ => panic!("no deck name"), + } + } + fn unwrap_notetype_id(&self) -> i64 { match self.notetype { Some(CsvNotetype::GlobalNotetype(ref nt)) => nt.id, @@ -683,8 +693,11 @@ pub(in crate::import_export) mod test { metadata!(col, format!("#deck:{deck_id}\n")).unwrap_deck_id(), deck_id ); + // unknown deck + assert_eq!(metadata!(col, "#deck:foo\n").unwrap_deck_name(), "foo"); + assert_eq!(metadata!(col, "#deck:1234\n").unwrap_deck_name(), "1234"); // fallback - assert_eq!(metadata!(col, "#deck:foo\n").unwrap_deck_id(), 1); + assert_eq!(metadata!(col, "#deck:\n").unwrap_deck_id(), 1); assert_eq!(metadata!(col, "\n").unwrap_deck_id(), 1); } @@ -726,8 +739,8 @@ pub(in crate::import_export) mod test { numeric_deck_2_id ); assert_eq!( - metadata!(col, format!("#deck:1234\n")).unwrap_deck_id(), - 1 // default deck + metadata!(col, format!("#deck:1234\n")).unwrap_deck_name(), + "1234" ); } diff --git a/rslib/src/import_export/text/import.rs b/rslib/src/import_export/text/import.rs index f28c27ca3..202189eb6 100644 --- a/rslib/src/import_export/text/import.rs +++ b/rslib/src/import_export/text/import.rs @@ -147,7 +147,7 @@ impl Duplicate { } impl DeckIdsByNameOrId { - fn new(col: &mut Collection, default: &NameOrId) -> Result { + fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result { let names: HashMap, DeckId> = col .get_all_normal_deck_names(false)? .into_iter() @@ -160,6 +160,13 @@ impl DeckIdsByNameOrId { default: None, }; new.default = new.get(default); + if new.default.is_none() && *default != NameOrId::default() { + let mut deck = Deck::new_normal(); + deck.name = NativeDeckName::from_human_name(default.to_string()); + col.add_deck_inner(&mut deck, usn)?; + new.insert(deck.id, deck.human_name()); + new.default = Some(deck.id); + } Ok(new) } @@ -193,7 +200,7 @@ impl<'a> Context<'a> { NameOrId::default(), col.notetype_by_name_or_id(&data.default_notetype)?, ); - let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck)?; + let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck, usn)?; let existing_checksums = ExistingChecksums::new(col, data.match_scope)?; let existing_guids = col.storage.all_notes_by_guid()?; @@ -274,6 +281,9 @@ impl<'a> Context<'a> { deck.name = NativeDeckName::from_human_name(name); self.col.add_deck_inner(&mut deck, self.usn)?; self.deck_ids.insert(deck.id, deck.human_name()); + if name.is_empty() { + self.deck_ids.default = Some(deck.id); + } Some(deck.id) } else { None diff --git a/rslib/src/import_export/text/mod.rs b/rslib/src/import_export/text/mod.rs index e9e2da766..fdf94971a 100644 --- a/rslib/src/import_export/text/mod.rs +++ b/rslib/src/import_export/text/mod.rs @@ -83,6 +83,15 @@ impl From for NameOrId { } } +impl std::fmt::Display for NameOrId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NameOrId::Id(did) => write!(f, "{did}"), + NameOrId::Name(name) => write!(f, "{name}"), + } + } +} + impl ForeignNote { pub(crate) fn into_log_note(self) -> LogNote { LogNote { diff --git a/rslib/src/latex.rs b/rslib/src/latex.rs index 3ebeebf8a..e5cb002ac 100644 --- a/rslib/src/latex.rs +++ b/rslib/src/latex.rs @@ -100,7 +100,7 @@ fn fname_for_latex(latex: &str, svg: bool) -> String { let ext = if svg { "svg" } else { "png" }; let csum = hex::encode(sha1_of_data(latex.as_bytes())); - format!("latex-{}.{}", csum, ext) + format!("latex-{csum}.{ext}") } fn image_link_for_fname(src: &str, fname: &str) -> String { @@ -122,11 +122,7 @@ mod test { assert_eq!( extract_latex("a[latex]one
and
two[/latex]b", false), ( - format!( - "a\"one
and
two\"b", - fname - ) - .into(), + format!("a\"one
and
two\"b").into(), vec![ExtractedLatex { fname: fname.into(), latex: "one\nand\ntwo".into() diff --git a/rslib/src/log.rs b/rslib/src/log.rs index 4fb4dcfaf..fedc597c4 100644 --- a/rslib/src/log.rs +++ b/rslib/src/log.rs @@ -69,8 +69,8 @@ fn maybe_rotate_log(path: &str) -> io::Result<()> { return Ok(()); } - let path2 = format!("{}.1", path); - let path3 = format!("{}.2", path); + let path2 = format!("{path}.1"); + let path3 = format!("{path}.2"); // if a rotated file already exists, rename it if let Err(e) = fs::rename(&path2, path3) { diff --git a/rslib/src/media/files.rs b/rslib/src/media/files.rs index 9fd3bc85f..6974e2f81 100644 --- a/rslib/src/media/files.rs +++ b/rslib/src/media/files.rs @@ -218,7 +218,7 @@ fn truncate_filename(fname: &str, max_bytes: usize) -> Cow { let mut new_name = if ext.is_empty() { stem.to_string() } else { - format!("{}.{}", stem, ext) + format!("{stem}.{ext}") }; // make sure we don't break Windows by ending with a space or dot diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 2b53321b9..932022e99 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -270,7 +270,7 @@ impl Note { self.fields .last_mut() .unwrap() - .push_str(&format!("; {}", last)); + .push_str(&format!("; {last}")); } } } diff --git a/rslib/src/notetype/schema11.rs b/rslib/src/notetype/schema11.rs index 272456ab7..8d713cbe7 100644 --- a/rslib/src/notetype/schema11.rs +++ b/rslib/src/notetype/schema11.rs @@ -126,7 +126,7 @@ fn other_to_bytes(other: &HashMap) -> Vec { } else { serde_json::to_vec(other).unwrap_or_else(|e| { // theoretically should never happen - println!("serialization failed for {:?}: {}", other, e); + println!("serialization failed for {other:?}: {e}"); vec![] }) } @@ -140,7 +140,7 @@ pub(crate) fn parse_other_fields( Default::default() } else { let mut map: HashMap = serde_json::from_slice(bytes).unwrap_or_else(|e| { - println!("deserialization failed for other: {}", e); + println!("deserialization failed for other: {e}"); Default::default() }); map.retain(|k, _v| !reserved.contains(k)); diff --git a/rslib/src/notetype/stock.rs b/rslib/src/notetype/stock.rs index f17f6b949..9b5df66d5 100644 --- a/rslib/src/notetype/stock.rs +++ b/rslib/src/notetype/stock.rs @@ -179,8 +179,8 @@ pub(crate) fn cloze(tr: &I18n) -> Notetype { let back_extra = tr.notetypes_back_extra_field(); config = nt.add_field(back_extra.as_ref()); config.tag = Some(ClozeField::BackExtra as u32); - let qfmt = format!("{{{{cloze:{}}}}}", text); - let afmt = format!("{}
\n{{{{{}}}}}", qfmt, back_extra); + let qfmt = format!("{{{{cloze:{text}}}}}"); + let afmt = format!("{qfmt}
\n{{{{{back_extra}}}}}"); nt.add_template(nt.name.clone(), qfmt, afmt); nt } 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/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index d498f7eaf..6ff8c6e2d 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -33,6 +33,7 @@ use crate::deckconfig::LeechAction; use crate::decks::Deck; use crate::prelude::*; use crate::scheduler::fsrs::memory_state::fsrs_item_for_memory_state; +use crate::scheduler::fsrs::memory_state::get_decay_from_params; use crate::scheduler::states::PreviewState; use crate::search::SearchNode; @@ -227,28 +228,31 @@ impl Collection { /// Return the next states that will be applied for each answer button. pub fn get_scheduling_states(&mut self, cid: CardId) -> Result { let card = self.storage.get_card(cid)?.or_not_found(cid)?; - let deck = self.get_deck(card.deck_id)?.or_not_found(card.deck_id)?; - - let note_id = deck - .config_id() - .map(|deck_config_id| self.get_deck_config(deck_config_id, false)) - .transpose()? - .flatten() - .map(|deck_config| deck_config.inner.bury_reviews) - .unwrap_or(false) - .then_some(card.note_id); + let note_id = card.note_id; let ctx = self.card_state_updater(card)?; let current = ctx.current_card_state(); - let load_balancer_ctx = self.state.card_queues.as_ref().and_then(|card_queues| { - match card_queues.load_balancer.as_ref() { - None => None, - Some(load_balancer) => { - Some(load_balancer.review_context(note_id, deck.config_id()?)) - } + let load_balancer_ctx = if let Some(load_balancer) = self + .state + .card_queues + .as_ref() + .and_then(|card_queues| card_queues.load_balancer.as_ref()) + { + // Only get_deck_config when load balancer is enabled + if let Some(deck_config_id) = ctx.deck.config_id() { + let note_id = self + .get_deck_config(deck_config_id, false)? + .map(|deck_config| deck_config.inner.bury_reviews) + .unwrap_or(false) + .then_some(note_id); + Some(load_balancer.review_context(note_id, deck_config_id)) + } else { + None } - }); + } else { + None + }; let state_ctx = ctx.state_context(load_balancer_ctx); Ok(current.next_states(&state_ctx)) @@ -333,7 +337,14 @@ impl Collection { self.update_deck_stats_from_answer(usn, answer, &updater, original.queue)?; self.maybe_bury_siblings(&original, &updater.config)?; let timing = updater.timing; + let deckconfig_id = updater.deck.config_id(); let mut card = updater.into_card(); + if !matches!( + answer.current_state, + CardState::Filtered(FilteredState::Preview(_)) + ) { + 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()?; @@ -345,12 +356,14 @@ impl Collection { } if card.queue == CardQueue::Review { - let deck = self.get_deck(card.deck_id)?; - if let Some(card_queues) = self.state.card_queues.as_mut() { - if let Some(deckconfig_id) = deck.and_then(|deck| deck.config_id()) { - if let Some(load_balancer) = card_queues.load_balancer.as_mut() { - load_balancer.add_card(card.id, card.note_id, deckconfig_id, card.interval) - } + if let Some(load_balancer) = self + .state + .card_queues + .as_mut() + .and_then(|card_queues| card_queues.load_balancer.as_mut()) + { + if let Some(deckconfig_id) = deckconfig_id { + load_balancer.add_card(card.id, card.note_id, deckconfig_id, card.interval) } } } @@ -431,9 +444,13 @@ impl Collection { .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; + + let desired_retention = deck.effective_desired_retention(&config); let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_next_states = if fsrs_enabled { - let fsrs = FSRS::new(Some(config.fsrs_params()))?; + let params = config.fsrs_params(); + let fsrs = FSRS::new(Some(params))?; + card.decay = Some(get_decay_from_params(params)); if card.memory_state.is_none() && card.ctype != CardType::New { // Card has been moved or imported into an FSRS deck after params were set, // and will need its initial memory state to be calculated based on review @@ -448,20 +465,23 @@ 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, + desired_retention, days_elapsed, )?) } else { None }; - let desired_retention = fsrs_enabled.then_some(config.inner.desired_retention); + let desired_retention = fsrs_enabled.then_some(desired_retention); let fsrs_short_term_with_steps = self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); let fsrs_allow_short_term = if fsrs_enabled { @@ -644,6 +664,43 @@ pub(crate) mod test { col.get_scheduling_states(card_id).unwrap().current } + // Test that deck-specific desired retention is used when available + #[test] + fn deck_specific_desired_retention() -> Result<()> { + let mut col = Collection::new(); + + // Enable FSRS + col.set_config_bool(BoolKey::Fsrs, true, false)?; + + // Create a deck with specific desired retention + let deck_id = DeckId(1); + let deck = col.get_deck(deck_id)?.unwrap(); + let mut deck_clone = (*deck).clone(); + deck_clone.normal_mut().unwrap().desired_retention = Some(0.85); + col.update_deck(&mut deck_clone)?; + + // Create a card in this deck + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, deck_id)?; + + // Get the card using search_cards + let cards = col.search_cards(note.id, SortMode::NoOrder)?; + let card = col.storage.get_card(cards[0])?.unwrap(); + + // Test that the card state updater uses deck-specific desired retention + let updater = col.card_state_updater(card)?; + + // Print debug information + println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs)); + println!("Desired retention: {:?}", updater.desired_retention); + + // Verify that the desired retention is from the deck, not the config + assert_eq!(updater.desired_retention, Some(0.85)); + + Ok(()) + } + // make sure the 'current' state for a card matches the // state we applied to it #[test] @@ -886,22 +943,20 @@ pub(crate) mod test { ) -> Result<()> { // Change due time to fake card answer_time, // works since answer_time is calculated as due - last_ivl - let update_due_string = format!("update cards set due={}", shift_due_time); + let update_due_string = format!("update cards set due={shift_due_time}"); col.storage.db.execute_batch(&update_due_string)?; col.clear_study_queues(); let current_card_state = current_state(col, post_answer.card_id); let state = match current_card_state { CardState::Normal(NormalState::Learning(state)) => state, - _ => panic!("State is not Normal: {:?}", current_card_state), + _ => panic!("State is not Normal: {current_card_state:?}"), }; let elapsed_secs = state.elapsed_secs as i32; // Give a 1 second leeway when the test runs on the off chance // that the test runs as a second rolls over. assert!( (elapsed_secs - expected_elapsed_secs).abs() <= 1, - "elapsed_secs: {} != expected_elapsed_secs: {}", - elapsed_secs, - expected_elapsed_secs + "elapsed_secs: {elapsed_secs} != expected_elapsed_secs: {expected_elapsed_secs}" ); Ok(()) diff --git a/rslib/src/scheduler/filtered/mod.rs b/rslib/src/scheduler/filtered/mod.rs index f1f3cc07d..331e54e5d 100644 --- a/rslib/src/scheduler/filtered/mod.rs +++ b/rslib/src/scheduler/filtered/mod.rs @@ -64,7 +64,8 @@ impl Collection { pub fn empty_filtered_deck(&mut self, did: DeckId) -> Result> { self.transact(Op::EmptyFilteredDeck, |col| { - col.return_all_cards_in_filtered_deck(did) + let deck = col.get_deck(did)?.or_not_found(did)?; + col.return_all_cards_in_filtered_deck(&deck) }) } @@ -78,8 +79,11 @@ impl Collection { } impl Collection { - pub(crate) fn return_all_cards_in_filtered_deck(&mut self, did: DeckId) -> Result<()> { - let cids = self.storage.all_cards_in_single_deck(did)?; + pub(crate) fn return_all_cards_in_filtered_deck(&mut self, deck: &Deck) -> Result<()> { + if !deck.is_filtered() { + return Err(FilteredDeckError::FilteredDeckRequired.into()); + } + let cids = self.storage.all_cards_in_single_deck(deck.id)?; self.return_cards_to_home_deck(&cids) } @@ -195,7 +199,7 @@ impl Collection { timing, }; - self.return_all_cards_in_filtered_deck(deck.id)?; + self.return_all_cards_in_filtered_deck(deck)?; self.build_filtered_deck(ctx) } @@ -214,14 +218,14 @@ impl Collection { .search_terms .get_mut(0) .unwrap(); - term1.search = format!("{} is:due", search); + term1.search = format!("{search} is:due"); let term2 = deck .filtered_mut() .unwrap() .search_terms .get_mut(1) .unwrap(); - term2.search = format!("{} is:new", search); + term2.search = format!("{search} is:new"); } } diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 787fa212d..062f5bcca 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -30,13 +30,26 @@ pub struct ComputeMemoryProgress { pub total_cards: u32, } +/// Helper function to determine the appropriate decay value based on FSRS +/// parameters +pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 { + if params.is_empty() { + FSRS6_DEFAULT_DECAY // default decay for FSRS-6 + } else if params.len() < 21 { + FSRS5_DEFAULT_DECAY // default decay for FSRS-4.5 and FSRS-5 + } else { + params[20] + } +} + #[derive(Debug)] pub(crate) struct UpdateMemoryStateRequest { pub params: Params, - pub desired_retention: f32, + pub preset_desired_retention: f32, pub historical_retention: f32, pub max_interval: u32, pub reschedule: bool, + pub deck_desired_retention: HashMap, } pub(crate) struct UpdateMemoryStateEntry { @@ -77,15 +90,7 @@ impl Collection { .then(|| Rescheduler::new(self)) .transpose()?; let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?; - let decay = req.as_ref().map(|w| { - if w.params.is_empty() { - FSRS6_DEFAULT_DECAY // default decay for FSRS-6 - } else if w.params.len() < 21 { - FSRS5_DEFAULT_DECAY // default decay for FSRS-4.5 and FSRS-5 - } else { - w.params[20] - } - }); + let decay = req.as_ref().map(|w| get_decay_from_params(&w.params)); let historical_retention = req.as_ref().map(|w| w.historical_retention); let items = fsrs_items_for_memory_states( &fsrs, @@ -94,84 +99,103 @@ impl Collection { historical_retention.unwrap_or(0.9), ignore_before, )?; - let desired_retention = req.as_ref().map(|w| w.desired_retention); + let preset_desired_retention = + req.as_ref().map(|w| w.preset_desired_retention).unwrap(); let mut progress = self.new_progress_handler::(); progress.update(false, |s| s.total_cards = items.len() as u32)?; for (idx, (card_id, item)) in items.into_iter().enumerate() { progress.update(true, |state| state.current_cards = idx as u32 + 1)?; let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let original = card.clone(); - if let (Some(req), Some(item)) = (&req, item) { - card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?; - card.desired_retention = desired_retention; + if let Some(req) = &req { + // Store decay and desired retention in the card so that add-ons, card info, + // stats and browser search/sorts don't need to access the deck config. + // Unlike memory states, scheduler doesn't use decay and dr stored in the card. + let deck_id = card.original_or_current_deck_id(); + let desired_retention = *req + .deck_desired_retention + .get(&deck_id) + .unwrap_or(&preset_desired_retention); + card.desired_retention = Some(desired_retention); card.decay = decay; - // if rescheduling - if let Some(reviews) = &last_revlog_info { - // and we have a last review time for the card - if let Some(last_info) = reviews.get(&card.id) { - if let Some(last_review) = &last_info.last_reviewed_at { - let days_elapsed = - timing.next_day_at.elapsed_days_since(*last_review) as i32; - // and the card's not new - if let Some(state) = &card.memory_state { - // or in (re)learning - if card.ctype == CardType::Review { - let deck = self - .get_deck(card.original_or_current_deck_id())? - .or_not_found(card.original_or_current_deck_id())?; - let deckconfig_id = deck.config_id().unwrap(); - // reschedule it - let original_interval = card.interval; - let interval = fsrs.next_interval( - Some(state.stability), - card.desired_retention.unwrap(), - 0, - ); - card.interval = rescheduler - .as_mut() - .and_then(|r| { - r.find_interval( - interval, - 1, - req.max_interval, - days_elapsed as u32, - deckconfig_id, - get_fuzz_seed(&card, true), - ) - }) - .unwrap_or_else(|| { - with_review_fuzz( - card.get_fuzz_factor(true), - interval, - 1, - req.max_interval, - ) - }); - let due = if card.original_due != 0 { - &mut card.original_due - } else { - &mut card.due - }; - let new_due = (timing.days_elapsed as i32) - days_elapsed - + card.interval as i32; - if let Some(rescheduler) = &mut rescheduler { - rescheduler.update_due_cnt_per_day( - *due, - new_due, - deckconfig_id, + if let Some(item) = item { + card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?; + // if rescheduling + if let Some(reviews) = &last_revlog_info { + // and we have a last review time for the card + if let Some(last_info) = reviews.get(&card.id) { + if let Some(last_review) = &last_info.last_reviewed_at { + let days_elapsed = + timing.next_day_at.elapsed_days_since(*last_review) as i32; + // and the card's not new + if let Some(state) = &card.memory_state { + // or in (re)learning + if card.ctype == CardType::Review { + let deck = self + .get_deck(card.original_or_current_deck_id())? + .or_not_found(card.original_or_current_deck_id())?; + let deckconfig_id = deck.config_id().unwrap(); + // reschedule it + let original_interval = card.interval; + let interval = fsrs.next_interval( + Some(state.stability), + desired_retention, + 0, ); + card.interval = rescheduler + .as_mut() + .and_then(|r| { + r.find_interval( + interval, + 1, + req.max_interval, + days_elapsed as u32, + deckconfig_id, + get_fuzz_seed(&card, true), + ) + }) + .unwrap_or_else(|| { + with_review_fuzz( + card.get_fuzz_factor(true), + interval, + 1, + req.max_interval, + ) + }); + let due = if card.original_due != 0 { + &mut card.original_due + } else { + &mut card.due + }; + let new_due = (timing.days_elapsed as i32) + - days_elapsed + + card.interval as i32; + if let Some(rescheduler) = &mut rescheduler { + rescheduler.update_due_cnt_per_day( + *due, + new_due, + deckconfig_id, + ); + } + *due = new_due; + // Add a rescheduled revlog entry + self.log_rescheduled_review( + &card, + original_interval, + usn, + )?; } - *due = new_due; - // Add a rescheduled revlog entry - self.log_rescheduled_review(&card, original_interval, usn)?; } } } } + } else { + // clear memory states if item is None + card.memory_state = None; } } else { - card.memory_state = None; - card.desired_retention = None; + // clear FSRS data if FSRS is disabled + card.clear_fsrs_data(); } self.update_card_inner(&mut card, original, usn)?; } @@ -188,9 +212,15 @@ impl Collection { .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; - let desired_retention = config.inner.desired_retention; + + // Get deck-specific desired retention if available, otherwise use config + // default + let desired_retention = deck.effective_desired_retention(&config); + let historical_retention = config.inner.historical_retention; - let fsrs = FSRS::new(Some(config.fsrs_params()))?; + let params = config.fsrs_params(); + let decay = get_decay_from_params(params); + let fsrs = FSRS::new(Some(params))?; let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?; let item = fsrs_item_for_memory_state( &fsrs, @@ -204,13 +234,13 @@ impl Collection { Ok(ComputeMemoryStateResponse { state: card.memory_state.map(Into::into), desired_retention, + decay, }) } else { - card.memory_state = None; - card.desired_retention = None; Ok(ComputeMemoryStateResponse { state: None, desired_retention, + decay, }) } } @@ -276,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() @@ -293,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 }); @@ -358,6 +390,7 @@ pub(crate) fn fsrs_item_for_memory_state( Ok(None) } } else { + // no revlogs (new card or caused by ignore_revlogs_before or deleted revlogs) Ok(None) } } diff --git a/rslib/src/scheduler/fsrs/params.rs b/rslib/src/scheduler/fsrs/params.rs index 76bc206be..726870fe1 100644 --- a/rslib/src/scheduler/fsrs/params.rs +++ b/rslib/src/scheduler/fsrs/params.rs @@ -299,6 +299,33 @@ impl Collection { .is_ok() })?) } + + pub fn evaluate_params_legacy( + &mut self, + params: &Params, + search: &str, + ignore_revlogs_before: TimestampMillis, + ) -> Result { + let timing = self.timing_today()?; + let mut anki_progress = self.new_progress_handler::(); + let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; + let revlogs: Vec = guard + .col + .storage + .get_revlog_entries_for_searched_cards_in_card_order()?; + let (items, review_count) = + fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before); + anki_progress.state.reviews = review_count as u32; + let fsrs = FSRS::new(Some(params))?; + Ok(fsrs.evaluate(items, |ip| { + anki_progress + .update(false, |p| { + p.total_iterations = ip.total as u32; + p.current_iteration = ip.current as u32; + }) + .is_ok() + })?) + } } #[derive(Default, Clone, Copy, Debug)] @@ -367,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); @@ -382,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() { @@ -445,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) @@ -533,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/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 34cc925d6..a26afda9c 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -1,20 +1,27 @@ // 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::Arc; use anki_proto::deck_config::deck_config::config::ReviewCardOrder; use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; +use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::simulate; use fsrs::PostSchedulingFn; use fsrs::ReviewPriorityFn; use fsrs::SimulatorConfig; +use fsrs::FSRS; use itertools::Itertools; use rand::rngs::StdRng; use rand::Rng; +use rayon::iter::IntoParallelIterator; +use rayon::iter::ParallelIterator; use crate::card::CardQueue; +use crate::card::CardType; +use crate::card::FsrsMemoryState; use crate::prelude::*; use crate::scheduler::states::fuzz::constrained_fuzz_bounds; use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers; @@ -114,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, @@ -126,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.queue != CardQueue::New - } // calculate any missing memory state for c in &mut cards { if is_included_card(c) && c.memory_state.is_none() { @@ -143,13 +151,29 @@ impl Collection { let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; let new_cards = cards .iter() - .filter(|c| c.memory_state.is_none() || c.queue == CardQueue::New) + .filter(|c| c.ctype == CardType::New && c.queue != CardQueue::Suspended) .count() + req.deck_size as usize; + let fsrs = FSRS::new(Some(&req.params))?; let mut converted_cards = cards .into_iter() .filter(is_included_card) - .filter_map(|c| Card::convert(c, days_elapsed)) + .filter_map(|c| { + let memory_state = match c.memory_state { + Some(state) => state, + // cards that lack memory states after compute_memory_state have no FSRS items, + // implying a truncated or ignored revlog + None => fsrs + .memory_state_from_sm2( + c.ease_factor(), + c.interval as f32, + req.historical_retention, + ) + .ok()? + .into(), + }; + Card::convert(c, days_elapsed, memory_state) + }) .collect_vec(); let introduced_today_count = self .search_cards(&format!("{} introduced:1", &req.search), SortMode::NoOrder)? @@ -214,8 +238,8 @@ impl Collection { learning_step_transitions: p.learning_step_transitions, relearning_step_transitions: p.relearning_step_transitions, state_rating_costs: p.state_rating_costs, - learning_step_count: p.learning_step_count, - relearning_step_count: p.relearning_step_count, + learning_step_count: req.learning_step_count as usize, + relearning_step_count: req.relearning_step_count as usize, }; Ok((config, converted_cards)) @@ -248,42 +272,73 @@ impl Collection { daily_time_cost: result.cost_per_day, }) } + + pub fn simulate_workload( + &mut self, + req: SimulateFsrsReviewRequest, + ) -> Result { + let (config, cards) = self.simulate_request_to_config(&req)?; + let dr_workload = (70u32..=99u32) + .into_par_iter() + .map(|dr| { + let result = simulate( + &config, + &req.params, + dr as f32 / 100., + None, + Some(cards.clone()), + )?; + Ok(( + dr, + ( + *result.memorized_cnt_per_day.last().unwrap_or(&0.), + result.cost_per_day.iter().sum::(), + result.review_cnt_per_day.iter().sum::() as u32, + ), + )) + }) + .collect::>>()?; + Ok(SimulateFsrsWorkloadResponse { + memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(), + cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(), + review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(), + }) + } } impl Card { - fn convert(card: Card, days_elapsed: i32) -> Option { - match card.memory_state { - Some(state) => match card.queue { - CardQueue::DayLearn | CardQueue::Review => { - let due = card.original_or_current_due(); - let relative_due = due - days_elapsed; - let last_date = (relative_due - card.interval as i32).min(0) as f32; - Some(fsrs::Card { - id: card.id.0, - difficulty: state.difficulty, - stability: state.stability, - last_date, - due: relative_due as f32, - interval: card.interval as f32, - lapses: card.lapses, - }) - } - CardQueue::New => None, - CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => { - Some(fsrs::Card { - id: card.id.0, - difficulty: state.difficulty, - stability: state.stability, - last_date: 0.0, - due: 0.0, - interval: card.interval as f32, - lapses: card.lapses, - }) - } - CardQueue::PreviewRepeat => None, - CardQueue::Suspended => None, - }, - None => None, + 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(); + let relative_due = due - days_elapsed; + let last_date = (relative_due - card.interval as i32).min(0) as f32; + Some(fsrs::Card { + id: card.id.0, + difficulty: memory_state.difficulty, + stability: memory_state.stability, + last_date, + due: relative_due as f32, + interval: card.interval as f32, + lapses: card.lapses, + }) + } + CardQueue::New => None, + CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => Some(fsrs::Card { + id: card.id.0, + difficulty: memory_state.difficulty, + stability: memory_state.stability, + last_date: 0.0, + due: 0.0, + interval: card.interval as f32, + lapses: card.lapses, + }), + CardQueue::PreviewRepeat => None, + CardQueue::Suspended => None, } } } diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index 7e61447da..f8d433f42 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -17,6 +17,7 @@ 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`. @@ -27,6 +28,7 @@ impl Card { fn set_due_date( &mut self, today: u32, + next_day_start: i64, days_from_today: u32, ease_factor: f32, force_reset: bool, @@ -34,8 +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 { - self.interval - .saturating_add_signed(new_due - self.original_or_current_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 { + 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 { @@ -114,6 +129,7 @@ impl Collection { 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(); @@ -137,7 +153,13 @@ impl Collection { }; let original = card.clone(); let days_from_today = distribution.sample(&mut rng); - card.set_due_date(today, days_from_today, ease_factor, spec.force_reset); + 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)?; } @@ -228,26 +250,26 @@ mod test { 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, 2, 1.8, false); + 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, 3, 2.5, false); + 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, 1, 2.4, false); + 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, 3, 2.3, true); + 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 @@ -259,7 +281,7 @@ mod test { c.original_deck_id = DeckId(1); c.due = -10000; c.queue = CardQueue::New; - c.set_due_date(6, 1, 2.2, false); + 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); @@ -271,7 +293,7 @@ mod test { c.ctype = CardType::Relearn; c.original_due = c.due; c.due = 12345678; - c.set_due_date(6, 10, 2.1, false); + 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 diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 993fd1dbe..9f42a79f7 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -16,6 +16,7 @@ use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; +use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::ComputeParametersInput; use fsrs::FSRSItem; use fsrs::FSRSReview; @@ -283,6 +284,13 @@ impl crate::services::SchedulerService for Collection { self.simulate_review(input) } + fn simulate_fsrs_workload( + &mut self, + input: SimulateFsrsReviewRequest, + ) -> Result { + self.simulate_workload(input) + } + fn compute_optimal_retention( &mut self, input: SimulateFsrsReviewRequest, @@ -307,6 +315,21 @@ impl crate::services::SchedulerService for Collection { }) } + fn evaluate_params_legacy( + &mut self, + input: scheduler::EvaluateParamsLegacyRequest, + ) -> Result { + let ret = self.evaluate_params_legacy( + &input.params, + &input.search, + input.ignore_revlogs_before_ms.into(), + )?; + Ok(scheduler::EvaluateParamsResponse { + log_loss: ret.log_loss, + rmse_bins: ret.rmse_bins, + }) + } + fn get_optimal_retention_parameters( &mut self, input: scheduler::GetOptimalRetentionParametersRequest, diff --git a/rslib/src/scheduler/timespan.rs b/rslib/src/scheduler/timespan.rs index c779d33bc..9ae53d78c 100644 --- a/rslib/src/scheduler/timespan.rs +++ b/rslib/src/scheduler/timespan.rs @@ -25,7 +25,7 @@ pub fn answer_button_time_collapsible(seconds: u32, collapse_secs: u32, tr: &I18 if seconds == 0 { tr.scheduling_end().into() } else if seconds < collapse_secs { - format!("<{}", string) + format!("<{string}") } else { string } @@ -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/search/builder.rs b/rslib/src/search/builder.rs index 452f4d832..a76af0560 100644 --- a/rslib/src/search/builder.rs +++ b/rslib/src/search/builder.rs @@ -219,7 +219,7 @@ impl From for SearchNode { impl From for SearchNode { fn from(n: NoteId) -> Self { - SearchNode::NoteIds(format!("{}", n)) + SearchNode::NoteIds(format!("{n}")) } } diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 63096dad8..d42ea8323 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -240,7 +240,7 @@ impl Collection { } else { self.storage.setup_searched_cards_table()?; } - let sql = format!("insert into search_cids {}", sql); + let sql = format!("insert into search_cids {sql}"); let cards = self .storage @@ -307,7 +307,7 @@ impl Collection { let (sql, args) = writer.build_query(&top_node, mode.required_table())?; self.storage.setup_searched_notes_table()?; - let sql = format!("insert into search_nids {}", sql); + let sql = format!("insert into search_nids {sql}"); let notes = self .storage @@ -378,9 +378,10 @@ fn card_order_from_sort_column(column: Column, timing: SchedTimingToday) -> Cow< Column::Stability => "extract_fsrs_variable(c.data, 's') asc".into(), Column::Difficulty => "extract_fsrs_variable(c.data, 'd') asc".into(), Column::Retrievability => format!( - "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {}, {}) asc", + "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {}, {}, {}) asc", timing.days_elapsed, - timing.next_day_at.0 + timing.next_day_at.0, + timing.now.0, ) .into(), } diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 93df4ea08..ae166ef54 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -19,6 +19,7 @@ use nom::error::ErrorKind as NomErrorKind; use nom::multi::many0; use nom::sequence::preceded; use nom::sequence::separated_pair; +use nom::Parser; use regex::Captures; use regex::Regex; @@ -93,6 +94,7 @@ pub enum SearchNode { WholeCollection, Regex(String), NoCombining(String), + StripClozes(String), WordBoundary(String), CustomData(String), Preset(String), @@ -202,18 +204,19 @@ fn group_inner(input: &str) -> IResult> { } fn whitespace0(s: &str) -> IResult> { - many0(one_of(" \u{3000}"))(s) + many0(one_of(" \u{3000}")).parse(s) } /// Optional leading space, then a (negated) group or text fn node(s: &str) -> IResult { - preceded(whitespace0, alt((negated_node, group, text)))(s) + preceded(whitespace0, alt((negated_node, group, text))).parse(s) } fn negated_node(s: &str) -> IResult { map(preceded(char('-'), alt((group, text))), |node| { Node::Not(Box::new(node)) - })(s) + }) + .parse(s) } /// One or more nodes surrounded by brackets, eg (one OR two) @@ -233,7 +236,7 @@ fn group(s: &str) -> IResult { /// Either quoted or unquoted text fn text(s: &str) -> IResult { - alt((quoted_term, partially_quoted_term, unquoted_term))(s) + alt((quoted_term, partially_quoted_term, unquoted_term)).parse(s) } /// Quoted text, including the outer double quotes. @@ -248,7 +251,8 @@ fn partially_quoted_term(s: &str) -> IResult { escaped(is_not("\"(): \u{3000}\\"), '\\', none_of(" \u{3000}")), char(':'), quoted_term_str, - )(s)?; + ) + .parse(s)?; Ok(( remaining, Node::Search(search_node_for_text_with_argument(key, val)?), @@ -274,7 +278,7 @@ fn unquoted_term(s: &str) -> IResult { Err(parse_failure( s, FailKind::UnknownEscape { - provided: format!("\\{}", c), + provided: format!("\\{c}"), }, )) } else if "\"() \u{3000}".contains(s.chars().next().unwrap()) { @@ -296,7 +300,7 @@ fn unquoted_term(s: &str) -> IResult { fn quoted_term_str(s: &str) -> IResult<&str> { let (opened, _) = char('"')(s)?; if let Ok((tail, inner)) = - escaped::<_, ParseError, _, _, _, _>(is_not(r#""\"#), '\\', anychar)(opened) + escaped::<_, ParseError, _, _>(is_not(r#""\"#), '\\', anychar).parse(opened) { if let Ok((remaining, _)) = char::<_, ParseError>('"')(tail) { Ok((remaining, inner)) @@ -321,7 +325,8 @@ fn search_node_for_text(s: &str) -> ParseResult { // leading : is only possible error for well-formed input let (tail, head) = verify(escaped(is_not(r":\"), '\\', anychar), |t: &str| { !t.is_empty() - })(s) + }) + .parse(s) .map_err(|_: nom::Err| parse_failure(s, FailKind::MissingKey))?; if tail.is_empty() { Ok(SearchNode::UnqualifiedText(unescape(head)?)) @@ -354,6 +359,7 @@ fn search_node_for_text_with_argument<'a>( "cid" => SearchNode::CardIds(check_id_list(val, key)?.into()), "re" => SearchNode::Regex(unescape_quotes(val)), "nc" => SearchNode::NoCombining(unescape(val)?), + "sc" => SearchNode::StripClozes(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?), "dupe" => parse_dupe(val)?, "has-cd" => SearchNode::CustomData(unescape(val)?), @@ -407,7 +413,7 @@ fn parse_resched(s: &str) -> ParseResult { /// eg prop:ivl>3, prop:ease!=2.5 fn parse_prop(prop_clause: &str) -> ParseResult { - let (tail, prop) = alt::<_, _, ParseError, _>(( + let (tail, prop) = alt(( tag("ivl"), tag("due"), tag("reps"), @@ -421,8 +427,9 @@ fn parse_prop(prop_clause: &str) -> ParseResult { tag("r"), recognize(preceded(tag("cdn:"), alphanumeric1)), recognize(preceded(tag("cds:"), alphanumeric1)), - ))(prop_clause) - .map_err(|_| { + )) + .parse(prop_clause) + .map_err(|_: nom::Err| { parse_failure( prop_clause, FailKind::InvalidPropProperty { @@ -431,15 +438,16 @@ fn parse_prop(prop_clause: &str) -> ParseResult { ) })?; - let (num, operator) = alt::<_, _, ParseError, _>(( + let (num, operator) = alt(( tag("<="), tag(">="), tag("!="), tag("="), tag("<"), tag(">"), - ))(tail) - .map_err(|_| { + )) + .parse(tail) + .map_err(|_: nom::Err| { parse_failure( prop_clause, FailKind::InvalidPropOperator { @@ -631,7 +639,7 @@ fn check_id_list<'a>(s: &'a str, context: &str) -> ParseResult<'a, &'a str> { s, // id lists are undocumented, so no translation FailKind::Other { - info: Some(format!("expected only digits and commas in {}:", context)), + info: Some(format!("expected only digits and commas in {context}:")), }, )) } @@ -1104,19 +1112,19 @@ mod test { for term in &["added", "edited", "rated", "resched"] { assert!(matches!( - failkind(&format!("{}:1.1", term)), + failkind(&format!("{term}:1.1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("{}:-1", term)), + failkind(&format!("{term}:-1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("{}:", term)), + failkind(&format!("{term}:")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("{}:foo", term)), + failkind(&format!("{term}:foo")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); } @@ -1217,19 +1225,19 @@ mod test { for term in &["ivl", "reps", "lapses", "pos"] { assert!(matches!( - failkind(&format!("prop:{}>", term)), + failkind(&format!("prop:{term}>")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("prop:{}=0.5", term)), + failkind(&format!("prop:{term}=0.5")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("prop:{}!=-1", term)), + failkind(&format!("prop:{term}!=-1")), SearchErrorKind::InvalidPositiveWholeNumber { .. } )); assert!(matches!( - failkind(&format!("prop:{} { self.write_unqualified( text, self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch), + false, )? } SearchNode::SingleField { field, text, is_re } => { @@ -143,7 +145,14 @@ impl SqlWriter<'_> { self.write_dupe(*notetype_id, &self.norm_note(text))? } SearchNode::Regex(re) => self.write_regex(&self.norm_note(re), false)?, - SearchNode::NoCombining(text) => self.write_unqualified(&self.norm_note(text), true)?, + SearchNode::NoCombining(text) => { + self.write_unqualified(&self.norm_note(text), true, false)? + } + SearchNode::StripClozes(text) => self.write_unqualified( + &self.norm_note(text), + self.col.get_config_bool(BoolKey::IgnoreAccentsInSearch), + true, + )?, SearchNode::WordBoundary(text) => self.write_word_boundary(&self.norm_note(text))?, // other @@ -158,13 +167,12 @@ impl SqlWriter<'_> { }, SearchNode::Deck(deck) => self.write_deck(&norm(deck))?, SearchNode::NotetypeId(ntid) => { - write!(self.sql, "n.mid = {}", ntid).unwrap(); + write!(self.sql, "n.mid = {ntid}").unwrap(); } SearchNode::DeckIdsWithoutChildren(dids) => { write!( self.sql, - "c.did in ({}) or (c.odid != 0 and c.odid in ({}))", - dids, dids + "c.did in ({dids}) or (c.odid != 0 and c.odid in ({dids}))" ) .unwrap(); } @@ -175,13 +183,13 @@ impl SqlWriter<'_> { SearchNode::Tag { tag, is_re } => self.write_tag(&norm(tag), *is_re), SearchNode::State(state) => self.write_state(state)?, SearchNode::Flag(flag) => { - write!(self.sql, "(c.flags & 7) == {}", flag).unwrap(); + write!(self.sql, "(c.flags & 7) == {flag}").unwrap(); } SearchNode::NoteIds(nids) => { write!(self.sql, "{} in ({})", self.note_id_column(), nids).unwrap(); } SearchNode::CardIds(cids) => { - write!(self.sql, "c.id in ({})", cids).unwrap(); + write!(self.sql, "c.id in ({cids})").unwrap(); } SearchNode::Property { operator, kind } => self.write_prop(operator, kind)?, SearchNode::CustomData(key) => self.write_custom_data(key)?, @@ -191,7 +199,12 @@ impl SqlWriter<'_> { Ok(()) } - fn write_unqualified(&mut self, text: &str, no_combining: bool) -> Result<()> { + fn write_unqualified( + &mut self, + text: &str, + no_combining: bool, + strip_clozes: bool, + ) -> Result<()> { let text = to_sql(text); let text = if no_combining { without_combining(&text) @@ -199,21 +212,41 @@ impl SqlWriter<'_> { text }; // implicitly wrap in % - let text = format!("%{}%", text); + let text = format!("%{text}%"); self.args.push(text); let arg_idx = self.args.len(); - let sfld_expr = if no_combining { - "coalesce(without_combining(cast(n.sfld as text)), n.sfld)" + let mut process_text_flags = ProcessTextFlags::empty(); + if no_combining { + process_text_flags.insert(ProcessTextFlags::NoCombining); + } + if strip_clozes { + process_text_flags.insert(ProcessTextFlags::StripClozes); + } + + let (sfld_expr, flds_expr) = if !process_text_flags.is_empty() { + let bits = process_text_flags.bits(); + ( + Cow::from(format!( + "coalesce(process_text(cast(n.sfld as text), {bits}), n.sfld)" + )), + Cow::from(format!("coalesce(process_text(n.flds, {bits}), n.flds)")), + ) } else { - "n.sfld" - }; - let flds_expr = if no_combining { - "coalesce(without_combining(n.flds), n.flds)" - } else { - "n.flds" + (Cow::from("n.sfld"), Cow::from("n.flds")) }; + if strip_clozes { + let cloze_notetypes_only_clause = self + .col + .get_all_notetypes()? + .iter() + .filter(|nt| nt.is_cloze()) + .map(|nt| format!("n.mid = {}", nt.id)) + .join(" or "); + write!(self.sql, "({cloze_notetypes_only_clause}) and ").unwrap(); + } + if let Some(field_indicies_by_notetype) = self.included_fields_by_notetype()? { let field_idx_str = format!("' || ?{arg_idx} || '"); let other_idx_str = "%".to_string(); @@ -279,7 +312,7 @@ impl SqlWriter<'_> { text => { write!(self.sql, "n.tags regexp ?").unwrap(); let re = &to_custom_re(text, r"\S"); - self.args.push(format!("(?i).* {}(::| ).*", re)); + self.args.push(format!("(?i).* {re}(::| ).*")); } } } @@ -293,10 +326,10 @@ impl SqlWriter<'_> { write!(self.sql, "c.id in (select cid from revlog where id").unwrap(); match op { - ">" => write!(self.sql, " >= {}", target_cutoff_ms), - ">=" => write!(self.sql, " >= {}", day_before_cutoff_ms), - "<" => write!(self.sql, " < {}", day_before_cutoff_ms), - "<=" => write!(self.sql, " < {}", target_cutoff_ms), + ">" => write!(self.sql, " >= {target_cutoff_ms}"), + ">=" => write!(self.sql, " >= {day_before_cutoff_ms}"), + "<" => write!(self.sql, " < {day_before_cutoff_ms}"), + "<=" => write!(self.sql, " < {target_cutoff_ms}"), "=" => write!( self.sql, " between {} and {}", @@ -314,7 +347,7 @@ impl SqlWriter<'_> { .unwrap(); match ease { - RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u), + RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {u})"), RatingKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), RatingKind::ManualReschedule => write!(self.sql, " and ease = 0)"), } @@ -356,9 +389,9 @@ impl SqlWriter<'_> { pos = pos ) .unwrap(), - PropertyKind::Interval(ivl) => write!(self.sql, "ivl {} {}", op, ivl).unwrap(), - PropertyKind::Reps(reps) => write!(self.sql, "reps {} {}", op, reps).unwrap(), - PropertyKind::Lapses(days) => write!(self.sql, "lapses {} {}", op, days).unwrap(), + PropertyKind::Interval(ivl) => write!(self.sql, "ivl {op} {ivl}").unwrap(), + PropertyKind::Reps(reps) => write!(self.sql, "reps {op} {reps}").unwrap(), + PropertyKind::Lapses(days) => write!(self.sql, "lapses {op} {days}").unwrap(), PropertyKind::Ease(ease) => { write!(self.sql, "factor {} {}", op, (ease * 1000.0) as u32).unwrap() } @@ -385,13 +418,13 @@ impl SqlWriter<'_> { write!(self.sql, "extract_fsrs_variable(c.data, 'd') {op} {d}").unwrap() } PropertyKind::Retrievability(r) => { - let (elap, next_day_at) = { + let (elap, next_day_at, now) = { let timing = self.col.timing_today()?; - (timing.days_elapsed, timing.next_day_at) + (timing.days_elapsed, timing.next_day_at, timing.now) }; write!( self.sql, - "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {elap}, {next_day_at}) {op} {r}" + "extract_fsrs_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, c.ivl, {elap}, {next_day_at}, {now}) {op} {r}" ) .unwrap() } @@ -474,7 +507,7 @@ impl SqlWriter<'_> { }; // convert to a regex that includes child decks - self.args.push(format!("(?i)^{}($|\x1f)", native_deck)); + self.args.push(format!("(?i)^{native_deck}($|\x1f)")); let arg_idx = self.args.len(); self.sql.push_str(&format!(concat!( "(c.did in (select id from decks where name regexp ?{n})", @@ -491,7 +524,7 @@ impl SqlWriter<'_> { let ids = self.col.storage.deck_id_with_children(&parent)?; let mut buf = String::new(); ids_to_string(&mut buf, &ids); - write!(self.sql, "c.did in {}", buf,).unwrap(); + write!(self.sql, "c.did in {buf}",).unwrap(); } else { self.sql.push_str("false") } @@ -502,7 +535,7 @@ impl SqlWriter<'_> { fn write_template(&mut self, template: &TemplateKind) { match template { TemplateKind::Ordinal(n) => { - write!(self.sql, "c.ord = {}", n).unwrap(); + write!(self.sql, "c.ord = {n}").unwrap(); } TemplateKind::Name(name) => { if is_glob(name) { @@ -550,7 +583,7 @@ impl SqlWriter<'_> { } fn write_all_fields_regexp(&mut self, val: &str) { - self.args.push(format!("(?i){}", val)); + self.args.push(format!("(?i){val}")); write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); } @@ -566,7 +599,7 @@ impl SqlWriter<'_> { return Ok(()); } - self.args.push(format!("(?i){}", val)); + self.args.push(format!("(?i){val}")); let arg_idx = self.args.len(); let all_notetype_clauses = field_indicies_by_notetype @@ -775,13 +808,13 @@ impl SqlWriter<'_> { fn write_added(&mut self, days: u32) -> Result<()> { let cutoff = self.previous_day_cutoff(days)?.as_millis(); - write!(self.sql, "c.id > {}", cutoff).unwrap(); + write!(self.sql, "c.id > {cutoff}").unwrap(); Ok(()) } fn write_edited(&mut self, days: u32) -> Result<()> { let cutoff = self.previous_day_cutoff(days)?; - write!(self.sql, "n.mod > {}", cutoff).unwrap(); + write!(self.sql, "n.mod > {cutoff}").unwrap(); Ok(()) } @@ -804,16 +837,19 @@ impl SqlWriter<'_> { fn write_regex(&mut self, word: &str, no_combining: bool) -> Result<()> { let flds_expr = if no_combining { - "coalesce(without_combining(n.flds), n.flds)" + Cow::from(format!( + "coalesce(process_text(n.flds, {}), n.flds)", + ProcessTextFlags::NoCombining.bits() + )) } else { - "n.flds" + Cow::from("n.flds") }; let word = if no_combining { without_combining(word) } else { std::borrow::Cow::Borrowed(word) }; - self.args.push(format!(r"(?i){}", word)); + self.args.push(format!(r"(?i){word}")); let arg_idx = self.args.len(); if let Some(field_indices_by_notetype) = self.included_fields_for_unqualified_regex()? { let notetype_clause = |ctx: &UnqualifiedRegexSearchContext| -> String { @@ -996,6 +1032,7 @@ impl SearchNode { SearchNode::Duplicates { .. } => RequiredTable::Notes, SearchNode::Regex(_) => RequiredTable::Notes, SearchNode::NoCombining(_) => RequiredTable::Notes, + SearchNode::StripClozes(_) => RequiredTable::Notes, SearchNode::WordBoundary(_) => RequiredTable::Notes, SearchNode::NotetypeId(_) => RequiredTable::Notes, SearchNode::Notetype(_) => RequiredTable::Notes, @@ -1300,6 +1337,9 @@ c.odue != 0 then c.odue else c.due end) != {days}) or (c.queue in (1,4) and "((c.did in (1) or c.odid in (1)))" ); assert_eq!(&s(ctx, "preset:typo").0, "(false)"); + + // strip clozes + assert_eq!(&s(ctx, "sc:abcdef").0, "((n.mid = 1581236385343) and (coalesce(process_text(cast(n.sfld as text), 2), n.sfld) like ?1 escape '\\' or coalesce(process_text(n.flds, 2), n.flds) like ?1 escape '\\'))"); } #[test] diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 600a18fd6..3bbe6fd0a 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -70,30 +70,31 @@ fn write_search_node(node: &SearchNode) -> String { match node { UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), SingleField { field, text, is_re } => write_single_field(field, text, *is_re), - AddedInDays(u) => format!("added:{}", u), - EditedInDays(u) => format!("edited:{}", u), - IntroducedInDays(u) => format!("introduced:{}", u), + AddedInDays(u) => format!("added:{u}"), + EditedInDays(u) => format!("edited:{u}"), + IntroducedInDays(u) => format!("introduced:{u}"), CardTemplate(t) => write_template(t), - Deck(s) => maybe_quote(&format!("deck:{}", s)), - DeckIdsWithoutChildren(s) => format!("did:{}", s), + Deck(s) => maybe_quote(&format!("deck:{s}")), + DeckIdsWithoutChildren(s) => format!("did:{s}"), // not exposed on the GUI end DeckIdWithChildren(_) => "".to_string(), - NotetypeId(NotetypeIdType(i)) => format!("mid:{}", i), - Notetype(s) => maybe_quote(&format!("note:{}", s)), + NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), + Notetype(s) => maybe_quote(&format!("note:{s}")), Rated { days, ease } => write_rated(days, ease), Tag { tag, is_re } => write_single_field("tag", tag, *is_re), Duplicates { notetype_id, text } => write_dupe(notetype_id, text), State(k) => write_state(k), - Flag(u) => format!("flag:{}", u), - NoteIds(s) => format!("nid:{}", s), - CardIds(s) => format!("cid:{}", s), + Flag(u) => format!("flag:{u}"), + NoteIds(s) => format!("nid:{s}"), + CardIds(s) => format!("cid:{s}"), Property { operator, kind } => write_property(operator, kind), WholeCollection => "deck:*".to_string(), - Regex(s) => maybe_quote(&format!("re:{}", s)), - NoCombining(s) => maybe_quote(&format!("nc:{}", s)), - WordBoundary(s) => maybe_quote(&format!("w:{}", s)), - CustomData(k) => maybe_quote(&format!("has-cd:{}", k)), - Preset(s) => maybe_quote(&format!("preset:{}", s)), + Regex(s) => maybe_quote(&format!("re:{s}")), + NoCombining(s) => maybe_quote(&format!("nc:{s}")), + StripClozes(s) => maybe_quote(&format!("sc:{s}")), + WordBoundary(s) => maybe_quote(&format!("w:{s}")), + CustomData(k) => maybe_quote(&format!("has-cd:{k}")), + Preset(s) => maybe_quote(&format!("preset:{s}")), } } @@ -128,23 +129,23 @@ fn write_single_field(field: &str, text: &str, is_re: bool) -> String { fn write_template(template: &TemplateKind) -> String { match template { TemplateKind::Ordinal(u) => format!("card:{}", u + 1), - TemplateKind::Name(s) => maybe_quote(&format!("card:{}", s)), + TemplateKind::Name(s) => maybe_quote(&format!("card:{s}")), } } fn write_rated(days: &u32, ease: &RatingKind) -> String { use RatingKind::*; match ease { - AnswerButton(n) => format!("rated:{}:{}", days, n), - AnyAnswerButton => format!("rated:{}", days), - ManualReschedule => format!("resched:{}", days), + AnswerButton(n) => format!("rated:{days}:{n}"), + AnyAnswerButton => format!("rated:{days}"), + ManualReschedule => format!("resched:{days}"), } } /// Escape double quotes and backslashes: \" fn write_dupe(notetype_id: &NotetypeId, text: &str) -> String { let esc = text.replace('\\', r"\\"); - maybe_quote(&format!("dupe:{},{}", notetype_id, esc)) + maybe_quote(&format!("dupe:{notetype_id},{esc}")) } fn write_state(kind: &StateKind) -> String { @@ -167,19 +168,19 @@ fn write_state(kind: &StateKind) -> String { fn write_property(operator: &str, kind: &PropertyKind) -> String { use PropertyKind::*; match kind { - Due(i) => format!("prop:due{}{}", operator, i), - Interval(u) => format!("prop:ivl{}{}", operator, u), - Reps(u) => format!("prop:reps{}{}", operator, u), - Lapses(u) => format!("prop:lapses{}{}", operator, u), - Ease(f) => format!("prop:ease{}{}", operator, f), - Position(u) => format!("prop:pos{}{}", operator, u), - Stability(u) => format!("prop:s{}{}", operator, u), - Difficulty(u) => format!("prop:d{}{}", operator, u), - Retrievability(u) => format!("prop:r{}{}", operator, u), + Due(i) => format!("prop:due{operator}{i}"), + Interval(u) => format!("prop:ivl{operator}{u}"), + Reps(u) => format!("prop:reps{operator}{u}"), + Lapses(u) => format!("prop:lapses{operator}{u}"), + Ease(f) => format!("prop:ease{operator}{f}"), + Position(u) => format!("prop:pos{operator}{u}"), + Stability(u) => format!("prop:s{operator}{u}"), + Difficulty(u) => format!("prop:d{operator}{u}"), + Retrievability(u) => format!("prop:r{operator}{u}"), Rated(u, ease) => match ease { - RatingKind::AnswerButton(val) => format!("prop:rated{}{}:{}", operator, u, val), - RatingKind::AnyAnswerButton => format!("prop:rated{}{}", operator, u), - RatingKind::ManualReschedule => format!("prop:resched{}{}", operator, u), + RatingKind::AnswerButton(val) => format!("prop:rated{operator}{u}:{val}"), + RatingKind::AnyAnswerButton => format!("prop:rated{operator}{u}"), + RatingKind::ManualReschedule => format!("prop:resched{operator}{u}"), }, CustomDataNumber { key, value } => format!("prop:cdn:{key}{operator}{value}"), CustomDataString { key, value } => { diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index b04539717..008977fe9 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,11 +30,24 @@ 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 last_review_time = if let Some(last_review_time) = card.last_review_time { + last_review_time + } else { + let mut new_card = card.clone(); + let last_review_time = self + .storage + .time_of_last_review(card.id)? + .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)) @@ -184,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/stats/graphs/retrievability.rs b/rslib/src/stats/graphs/retrievability.rs index 94f4d6bc9..6881a6062 100644 --- a/rslib/src/stats/graphs/retrievability.rs +++ b/rslib/src/stats/graphs/retrievability.rs @@ -30,10 +30,10 @@ impl GraphsContext { .or_insert((0.0, 0)); entry.1 += 1; if let Some(state) = card.memory_state { - let elapsed_days = card.days_since_last_review(&timing).unwrap_or_default(); - let r = fsrs.current_retrievability( + let elapsed_seconds = card.seconds_since_last_review(&timing).unwrap_or_default(); + let r = fsrs.current_retrievability_seconds( state.into(), - elapsed_days, + elapsed_seconds, card.decay.unwrap_or(FSRS5_DEFAULT_DECAY), ); 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/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/filtered.rs b/rslib/src/storage/card/filtered.rs index ea935c18c..ef436f6e8 100644 --- a/rslib/src/storage/card/filtered.rs +++ b/rslib/src/storage/card/filtered.rs @@ -14,6 +14,8 @@ pub(crate) fn order_and_limit_for_search( ) -> String { let temp_string; let today = timing.days_elapsed; + let next_day_at = timing.next_day_at.0; + let now = timing.now.0; let order = match term.order() { FilteredSearchOrder::OldestReviewedFirst => "(select max(id) from revlog where cid=c.id)", FilteredSearchOrder::Random => "random()", @@ -29,15 +31,13 @@ pub(crate) fn order_and_limit_for_search( &temp_string } FilteredSearchOrder::RetrievabilityAscending => { - let next_day_at = timing.next_day_at.0; temp_string = - build_retrievability_query(fsrs, today, next_day_at, SqlSortOrder::Ascending); + build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Ascending); &temp_string } FilteredSearchOrder::RetrievabilityDescending => { - let next_day_at = timing.next_day_at.0; temp_string = - build_retrievability_query(fsrs, today, next_day_at, SqlSortOrder::Descending); + build_retrievability_query(fsrs, today, next_day_at, now, SqlSortOrder::Descending); &temp_string } }; @@ -49,11 +49,12 @@ fn build_retrievability_query( fsrs: bool, today: u32, next_day_at: i64, + now: i64, order: SqlSortOrder, ) -> String { if fsrs { format!( - "extract_fsrs_relative_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, {today}, ivl, {next_day_at}) {order}" + "extract_fsrs_relative_retrievability(c.data, case when c.odue !=0 then c.odue else c.due end, {today}, ivl, {next_day_at}, {now}) {order}" ) } else { format!( diff --git a/rslib/src/storage/card/get_costs_for_retention.sql b/rslib/src/storage/card/get_costs_for_retention.sql deleted file mode 100644 index ba21cc3f6..000000000 --- a/rslib/src/storage/card/get_costs_for_retention.sql +++ /dev/null @@ -1,85 +0,0 @@ -WITH searched_revlogs AS ( - SELECT *, - RANK() OVER ( - PARTITION BY cid - ORDER BY id ASC - ) AS rank_num - FROM revlog - WHERE ease > 0 - AND cid IN search_cids - ORDER BY id DESC -- Use the last 10_000 reviews - LIMIT 10000 -), average_pass AS ( - SELECT AVG(time) - FROM searched_revlogs - WHERE ease > 1 - AND type = 1 -), -lapse_count AS ( - SELECT COUNT(time) AS lapse_count - FROM searched_revlogs - WHERE ease = 1 - AND type = 1 -), -fail_sum AS ( - SELECT SUM(time) AS total_fail_time - FROM searched_revlogs - WHERE ( - ease = 1 - AND type = 1 - ) - OR type = 2 -), --- (sum(Relearning) + sum(Lapses)) / count(Lapses) -average_fail AS ( - SELECT total_fail_time * 1.0 / NULLIF(lapse_count, 0) AS avg_fail_time - FROM fail_sum, - lapse_count -), --- Can lead to cards with partial learn histories skewing the time -summed_learns AS ( - SELECT cid, - SUM(time) AS total_time - FROM searched_revlogs - WHERE searched_revlogs.type = 0 - GROUP BY cid -), -average_learn AS ( - SELECT AVG(total_time) AS avg_learn_time - FROM summed_learns -), -initial_pass_rate AS ( - SELECT AVG( - CASE - WHEN ease > 1 THEN 1.0 - ELSE 0.0 - END - ) AS initial_pass_rate - FROM searched_revlogs - WHERE rank_num = 1 -), -pass_cnt AS ( - SELECT COUNT(*) AS cnt - FROM searched_revlogs - WHERE ease > 1 - AND type = 1 -), -fail_cnt AS ( - SELECT COUNT(*) AS cnt - FROM searched_revlogs - WHERE ease = 1 - AND type = 1 -), -learn_cnt AS ( - SELECT COUNT(*) AS cnt - FROM searched_revlogs - WHERE type = 0 -) -SELECT * -FROM average_pass, - average_fail, - average_learn, - initial_pass_rate, - pass_cnt, - fail_cnt, - learn_cnt; \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 38cf5ef0f..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,15 +43,11 @@ use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; use crate::types::Usn; -#[derive(Debug, Clone, Default)] -pub struct RetentionCosts { - pub average_pass_time_ms: f32, - pub average_fail_time_ms: f32, - pub average_learn_time_ms: f32, - pub initial_pass_rate: f32, - pub pass_count: u32, - pub fail_count: u32, - pub learn_count: u32, +#[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 { @@ -97,6 +94,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, }) } @@ -375,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"))? @@ -400,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 { @@ -758,24 +773,6 @@ impl super::SqliteStorage { .get(0)?) } - pub(crate) fn get_costs_for_retention(&self) -> Result { - let mut statement = self - .db - .prepare(include_str!("get_costs_for_retention.sql"))?; - let mut query = statement.query(params![])?; - let row = query.next()?.unwrap(); - - Ok(RetentionCosts { - average_pass_time_ms: row.get(0).unwrap_or(7000.), - average_fail_time_ms: row.get(1).unwrap_or(23_000.), - average_learn_time_ms: row.get(2).unwrap_or(30_000.), - initial_pass_rate: row.get(3).unwrap_or(0.5), - pass_count: row.get(4).unwrap_or(0), - fail_count: row.get(5).unwrap_or(0), - learn_count: row.get(6).unwrap_or(0), - }) - } - #[cfg(test)] pub(crate) fn get_all_cards(&self) -> Vec { self.db @@ -829,22 +826,22 @@ impl fmt::Display for ReviewOrderSubclause { ReviewOrderSubclause::RetrievabilitySm2 { today, order } => { temp_string = format!( // - (elapsed days+0.001)/(scheduled interval) - "-(1 + cast({today}-due+0.001 as real)/ivl) {order}", - today = today + "-(1 + cast({today}-due+0.001 as real)/ivl) {order}" ); &temp_string } ReviewOrderSubclause::RetrievabilityFsrs { timing, order } => { let today = timing.days_elapsed; let next_day_at = timing.next_day_at.0; + let now = timing.now.0; temp_string = - format!("extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, {today}, ivl, {next_day_at}) {order}"); + format!("extract_fsrs_relative_retrievability(data, case when odue !=0 then odue else due end, {today}, ivl, {next_day_at}, {now}) {order}"); &temp_string } ReviewOrderSubclause::Added => "nid asc, ord asc", ReviewOrderSubclause::ReverseAdded => "nid desc, ord asc", }; - write!(f, "{}", clause) + write!(f, "{clause}") } } diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index 7b1e08d58..d47d03894 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -33,7 +33,7 @@ fn row_to_deck(row: &Row) -> Result { common, kind: kind.kind.ok_or_else(|| { AnkiError::db_error( - format!("invalid deck kind: {}", id), + format!("invalid deck kind: {id}"), DbErrorKind::MissingEntity, ) })?, @@ -347,8 +347,8 @@ impl SqliteStorage { ))?; let top = current.name.as_native_str(); - let prefix_start = &format!("{}\x1f", top); - let prefix_end = &format!("{}\x20", top); + let prefix_start = &format!("{top}\x1f"); + let prefix_end = &format!("{top}\x20"); self.db .prepare_cached(include_str!("update_active.sql"))? @@ -379,7 +379,7 @@ impl SqliteStorage { let decks = self .get_schema11_decks() .map_err(|e| AnkiError::JsonError { - info: format!("decoding decks: {}", e), + info: format!("decoding decks: {e}"), })?; let mut names = HashSet::new(); for (_id, deck) in decks { diff --git a/rslib/src/storage/deckconfig/mod.rs b/rslib/src/storage/deckconfig/mod.rs index 2103e1512..5cc39cfc8 100644 --- a/rslib/src/storage/deckconfig/mod.rs +++ b/rslib/src/storage/deckconfig/mod.rs @@ -197,7 +197,7 @@ impl SqliteStorage { serde_json::from_value(conf) }) .map_err(|e| AnkiError::JsonError { - info: format!("decoding deck config: {}", e), + info: format!("decoding deck config: {e}"), }) })?; for (id, mut conf) in conf.into_iter() { diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index f240555eb..015f4fdc7 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -19,6 +19,7 @@ mod upgrades; use std::fmt::Write; +pub(crate) use sqlite::ProcessTextFlags; pub(crate) use sqlite::SqliteStorage; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,7 +53,7 @@ where { let mut trailing_sep = false; for id in ids { - write!(buf, "{},", id).unwrap(); + write!(buf, "{id},").unwrap(); trailing_sep = true; } if trailing_sep { diff --git a/rslib/src/storage/notetype/mod.rs b/rslib/src/storage/notetype/mod.rs index 88c4074ac..692b68887 100644 --- a/rslib/src/storage/notetype/mod.rs +++ b/rslib/src/storage/notetype/mod.rs @@ -345,7 +345,7 @@ impl SqliteStorage { let nts = self .get_schema11_notetypes() .map_err(|e| AnkiError::JsonError { - info: format!("decoding models: {:?}", e), + info: format!("decoding models: {e:?}"), })?; let mut names = HashSet::new(); for (mut ntid, nt) in nts { diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index e31fdd46a..3ce1baff0 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -9,6 +9,7 @@ use std::hash::Hasher; use std::path::Path; use std::sync::Arc; +use bitflags::bitflags; use fnv::FnvHasher; use fsrs::FSRS; use fsrs::FSRS5_DEFAULT_DECAY; @@ -24,6 +25,7 @@ use super::upgrades::SCHEMA_MAX_VERSION; use super::upgrades::SCHEMA_MIN_VERSION; use super::upgrades::SCHEMA_STARTING_VERSION; use super::SchemaVersion; +use crate::cloze::strip_clozes; use crate::config::schema11::schema11_config_as_string; use crate::error::DbErrorKind; use crate::prelude::*; @@ -31,6 +33,7 @@ use crate::scheduler::timing::local_minutes_west_for_stamp; use crate::scheduler::timing::v1_creation_date; use crate::storage::card::data::CardData; use crate::text::without_combining; +use crate::text::CowMapping; fn unicase_compare(s1: &str, s2: &str) -> Ordering { UniCase::new(s1).cmp(&UniCase::new(s2)) @@ -74,7 +77,7 @@ fn open_or_create_collection_db(path: &Path) -> Result { add_regexp_function(&db)?; add_regexp_fields_function(&db)?; add_regexp_tags_function(&db)?; - add_without_combining_function(&db)?; + add_process_text_function(&db)?; add_fnvhash_function(&db)?; add_extract_original_position_function(&db)?; add_extract_custom_data_function(&db)?; @@ -111,17 +114,28 @@ fn add_field_index_function(db: &Connection) -> rusqlite::Result<()> { ) } -fn add_without_combining_function(db: &Connection) -> rusqlite::Result<()> { +bitflags! { + pub(crate) struct ProcessTextFlags: u8 { + const NoCombining = 1; + const StripClozes = 1 << 1; + } +} + +fn add_process_text_function(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( - "without_combining", - 1, + "process_text", + 2, FunctionFlags::SQLITE_DETERMINISTIC, |ctx| { - let text = ctx.get_raw(0).as_str()?; - Ok(match without_combining(text) { - Cow::Borrowed(_) => None, - Cow::Owned(o) => Some(o), - }) + let mut text = Cow::from(ctx.get_raw(0).as_str()?); + let opt = ProcessTextFlags::from_bits_truncate(ctx.get_raw(1).as_i64()? as u8); + if opt.contains(ProcessTextFlags::StripClozes) { + text = text.map_cow(strip_clozes); + } + if opt.contains(ProcessTextFlags::NoCombining) { + text = text.map_cow(without_combining); + } + Ok(text.get_owned()) }, ) } @@ -296,14 +310,14 @@ fn add_extract_fsrs_variable(db: &Connection) -> rusqlite::Result<()> { } /// eg. extract_fsrs_retrievability(card.data, card.due, card.ivl, -/// timing.days_elapsed, timing.next_day_at) -> float | null +/// timing.days_elapsed, timing.next_day_at, timing.now) -> float | null fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_fsrs_retrievability", - 5, + 6, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { - assert_eq!(ctx.len(), 5, "called with unexpected number of arguments"); + assert_eq!(ctx.len(), 6, "called with unexpected number of arguments"); let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None); }; @@ -314,12 +328,18 @@ 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 Ok(now) = ctx.get_raw(5).as_i64() else { + return Ok(None); + }; + let seconds_elapsed = if let Some(last_review_time) = card_data.last_review_time { + now.saturating_sub(last_review_time.0) as u32 + } else if due > 365_000 { // (re)learning card in seconds - let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { + let Ok(ivl) = ctx.get_raw(2).as_i64() else { return Ok(None); }; - (next_day_at as u32).saturating_sub(due as u32) / 86_400 + let last_review_time = due.saturating_sub(ivl); + now.saturating_sub(last_review_time) as u32 } else { let Ok(ivl) = ctx.get_raw(2).as_i64() else { return Ok(None); @@ -328,29 +348,32 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> { return Ok(None); }; let review_day = due.saturating_sub(ivl); - (days_elapsed as u32).saturating_sub(review_day as u32) + days_elapsed.saturating_sub(review_day) as u32 * 86_400 }; let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); - Ok(card_data.memory_state().map(|state| { - FSRS::new(None) - .unwrap() - .current_retrievability(state.into(), days_elapsed, decay) - })) + let retrievability = card_data.memory_state().map(|state| { + FSRS::new(None).unwrap().current_retrievability_seconds( + state.into(), + seconds_elapsed, + decay, + ) + }); + Ok(retrievability) }, ) } /// eg. extract_fsrs_relative_retrievability(card.data, card.due, -/// timing.days_elapsed, card.ivl, timing.next_day_at) -> float | null. The -/// higher the number, the higher the card's retrievability relative to the -/// configured desired retention. +/// timing.days_elapsed, card.ivl, timing.next_day_at, timing.now) -> float | +/// null. The higher the number, the higher the card's retrievability relative +/// to the configured desired retention. fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result<()> { db.create_scalar_function( "extract_fsrs_relative_retrievability", - 5, + 6, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| { - assert_eq!(ctx.len(), 5, "called with unexpected number of arguments"); + assert_eq!(ctx.len(), 6, "called with unexpected number of arguments"); let Ok(due) = ctx.get_raw(1).as_i64() else { return Ok(None); @@ -361,6 +384,9 @@ fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result let Ok(next_day_at) = ctx.get_raw(4).as_i64() else { return Ok(None); }; + let Ok(now) = ctx.get_raw(5).as_i64() else { + return Ok(None); + }; let days_elapsed = if due > 365_000 { // (re)learning (next_day_at as u32).saturating_sub(due as u32) / 86_400 @@ -382,9 +408,30 @@ fn add_extract_fsrs_relative_retrievability(db: &Connection) -> rusqlite::Result desired_retrievability = desired_retrievability.max(0.0001); let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY); + let seconds_elapsed = + if let Some(last_review_time) = card_data.last_review_time { + now.saturating_sub(last_review_time.0) as u32 + } else if due > 365_000 { + // (re)learning card in seconds + let Ok(ivl) = ctx.get_raw(2).as_i64() else { + return Ok(None); + }; + let last_review_time = due.saturating_sub(ivl); + now.saturating_sub(last_review_time) as u32 + } else { + let Ok(ivl) = ctx.get_raw(2).as_i64() else { + return Ok(None); + }; + let Ok(days_elapsed) = ctx.get_raw(3).as_i64() else { + return Ok(None); + }; + let review_day = due.saturating_sub(ivl); + days_elapsed.saturating_sub(review_day) as u32 * 86_400 + }; + let current_retrievability = FSRS::new(None) .unwrap() - .current_retrievability(state.into(), days_elapsed, decay) + .current_retrievability_seconds(state.into(), seconds_elapsed, decay) .max(0.0001); return Ok(Some( @@ -587,7 +634,7 @@ impl SqliteStorage { }) { Ok(corrupt) => corrupt, Err(e) => { - println!("error: {:?}", e); + println!("error: {e:?}"); true } } diff --git a/rslib/src/storage/sync.rs b/rslib/src/storage/sync.rs index 4bd0e5242..256566d68 100644 --- a/rslib/src/storage/sync.rs +++ b/rslib/src/storage/sync.rs @@ -54,7 +54,7 @@ impl SqliteStorage { if let Some(new_usn) = server_usn_if_client { let mut stmt = self .db - .prepare_cached(&format!("update {} set usn=? where id=?", table))?; + .prepare_cached(&format!("update {table} set usn=? where id=?"))?; for id in ids { stmt.execute(params![new_usn, id])?; } diff --git a/rslib/src/storage/sync_check.rs b/rslib/src/storage/sync_check.rs index 50e92f7d0..7693a5921 100644 --- a/rslib/src/storage/sync_check.rs +++ b/rslib/src/storage/sync_check.rs @@ -11,7 +11,7 @@ impl SqliteStorage { fn table_has_usn(&self, table: &str) -> Result { Ok(self .db - .prepare(&format!("select null from {} where usn=-1", table))? + .prepare(&format!("select null from {table} where usn=-1"))? .query([])? .next()? .is_some()) @@ -19,7 +19,7 @@ impl SqliteStorage { fn table_count(&self, table: &str) -> Result { self.db - .query_row(&format!("select count() from {}", table), [], |r| r.get(0)) + .query_row(&format!("select count() from {table}"), [], |r| r.get(0)) .map_err(Into::into) } @@ -36,7 +36,7 @@ impl SqliteStorage { ] { if self.table_has_usn(table)? { return Err(AnkiError::sync_error( - format!("table had usn=-1: {}", table), + format!("table had usn=-1: {table}"), SyncErrorKind::Other, )); } 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, } } diff --git a/rslib/src/sync/collection/tests.rs b/rslib/src/sync/collection/tests.rs index abf82262f..a7aa6cc8d 100644 --- a/rslib/src/sync/collection/tests.rs +++ b/rslib/src/sync/collection/tests.rs @@ -100,7 +100,7 @@ where _lock = LOCK.lock().await; endpoint } else { - format!("http://{}/", addr) + format!("http://{addr}/") }; let endpoint = Url::try_from(endpoint.as_str()).unwrap(); let auth = SyncAuth { @@ -734,7 +734,7 @@ async fn regular_sync(ctx: &SyncTestContext) -> Result<()> { for table in &["cards", "notes", "decks"] { assert_eq!( col1.storage - .db_scalar::(&format!("select count() from {}", table))?, + .db_scalar::(&format!("select count() from {table}"))?, 2 ); } @@ -754,7 +754,7 @@ async fn regular_sync(ctx: &SyncTestContext) -> Result<()> { for table in &["cards", "notes", "decks"] { assert_eq!( col2.storage - .db_scalar::(&format!("select count() from {}", table))?, + .db_scalar::(&format!("select count() from {table}"))?, 1 ); } diff --git a/rslib/src/sync/media/database/client/mod.rs b/rslib/src/sync/media/database/client/mod.rs index 5fe493679..f9c6e5ed1 100644 --- a/rslib/src/sync/media/database/client/mod.rs +++ b/rslib/src/sync/media/database/client/mod.rs @@ -285,7 +285,7 @@ fn row_to_name_and_checksum(row: &Row) -> error::Result<(String, Sha1Hash)> { fn trace(event: rusqlite::trace::TraceEvent) { if let rusqlite::trace::TraceEvent::Stmt(_, sql) = event { - println!("sql: {}", sql); + println!("sql: {sql}"); } } diff --git a/rslib/src/tags/complete.rs b/rslib/src/tags/complete.rs index 1093017b0..f995b63a2 100644 --- a/rslib/src/tags/complete.rs +++ b/rslib/src/tags/complete.rs @@ -12,14 +12,20 @@ impl Collection { .map(component_to_regex) .collect::>()?; let mut tags = vec![]; + let mut priority = vec![]; self.storage.get_tags_by_predicate(|tag| { - if tags.len() <= limit && filters_match(&filters, tag) { - tags.push(tag.to_string()); + if priority.len() + tags.len() <= limit { + match filters_match(&filters, tag) { + Some(true) => priority.push(tag.to_string()), + Some(_) => tags.push(tag.to_string()), + _ => {} + } } // we only need the tag name false })?; - Ok(tags) + priority.append(&mut tags); + Ok(priority) } } @@ -27,20 +33,26 @@ fn component_to_regex(component: &str) -> Result { Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into) } -fn filters_match(filters: &[Regex], tag: &str) -> bool { +/// Returns None if tag wasn't a match, otherwise whether it was a consecutive +/// prefix match +fn filters_match(filters: &[Regex], tag: &str) -> Option { let mut remaining_tag_components = tag.split("::"); + let mut is_prefix = true; 'outer: for filter in filters { loop { if let Some(component) = remaining_tag_components.next() { - if filter.is_match(component) { + if let Some(m) = filter.find(component) { + is_prefix &= m.start() == 0; continue 'outer; + } else { + is_prefix = false; } } else { - return false; + return None; } } } - true + Some(is_prefix) } #[cfg(test)] @@ -50,28 +62,32 @@ mod test { #[test] fn matching() -> Result<()> { let filters = &[component_to_regex("b")?]; - assert!(filters_match(filters, "ABC")); - assert!(filters_match(filters, "ABC::def")); - assert!(filters_match(filters, "def::abc")); - assert!(!filters_match(filters, "def")); + assert!(filters_match(filters, "ABC").is_some()); + assert!(filters_match(filters, "ABC::def").is_some()); + assert!(filters_match(filters, "def::abc").is_some()); + assert!(filters_match(filters, "def").is_none()); let filters = &[component_to_regex("b")?, component_to_regex("E")?]; - assert!(!filters_match(filters, "ABC")); - assert!(filters_match(filters, "ABC::def")); - assert!(!filters_match(filters, "def::abc")); - assert!(!filters_match(filters, "def")); + assert!(filters_match(filters, "ABC").is_none()); + assert!(filters_match(filters, "ABC::def").is_some()); + assert!(filters_match(filters, "def::abc").is_none()); + assert!(filters_match(filters, "def").is_none()); let filters = &[ component_to_regex("a")?, component_to_regex("c")?, component_to_regex("e")?, ]; - assert!(!filters_match(filters, "ace")); - assert!(!filters_match(filters, "a::c")); - assert!(!filters_match(filters, "c::e")); - assert!(filters_match(filters, "a::c::e")); - assert!(filters_match(filters, "a::b::c::d::e")); - assert!(filters_match(filters, "1::a::b::c::d::e::f")); + assert!(filters_match(filters, "ace").is_none()); + assert!(filters_match(filters, "a::c").is_none()); + assert!(filters_match(filters, "c::e").is_none()); + assert!(filters_match(filters, "a::c::e").is_some()); + assert!(filters_match(filters, "a::b::c::d::e").is_some()); + assert!(filters_match(filters, "1::a::b::c::d::e::f").is_some()); + + assert_eq!(filters_match(filters, "a1::c2::e3"), Some(true)); + assert_eq!(filters_match(filters, "a1::c2::?::e4"), Some(false)); + assert_eq!(filters_match(filters, "a1::c2::3e"), Some(false)); Ok(()) } diff --git a/rslib/src/tags/findreplace.rs b/rslib/src/tags/findreplace.rs index 5db6e3ed2..b60b5ed88 100644 --- a/rslib/src/tags/findreplace.rs +++ b/rslib/src/tags/findreplace.rs @@ -35,7 +35,7 @@ impl Collection { }; if !match_case { - search = format!("(?i){}", search).into(); + search = format!("(?i){search}").into(); } self.transact(Op::UpdateTag, |col| { diff --git a/rslib/src/tags/matcher.rs b/rslib/src/tags/matcher.rs index b4961015e..d3c6ad88b 100644 --- a/rslib/src/tags/matcher.rs +++ b/rslib/src/tags/matcher.rs @@ -33,7 +33,7 @@ impl TagMatcher { (?:^|\ ) # 1: the tag prefix ( - {} + {tags} ) (?: # 2: an optional child separator @@ -41,8 +41,7 @@ impl TagMatcher { # or a space/end of string the end of the string |\ |$ ) - "#, - tags + "# ))?; Ok(Self { @@ -61,7 +60,7 @@ impl TagMatcher { let out = self.regex.replace(tag, |caps: &Captures| { // if we captured the child separator, add it to the replacement if caps.get(2).is_some() { - Cow::Owned(format!("{}::", replacement)) + Cow::Owned(format!("{replacement}::")) } else { Cow::Borrowed(replacement) } @@ -92,7 +91,7 @@ impl TagMatcher { let replacement = replacer(caps.get(1).unwrap().as_str()); // if we captured the child separator, add it to the replacement if caps.get(2).is_some() { - format!("{}::", replacement) + format!("{replacement}::") } else { replacement } diff --git a/rslib/src/tags/reparent.rs b/rslib/src/tags/reparent.rs index cbab806ff..4976b760e 100644 --- a/rslib/src/tags/reparent.rs +++ b/rslib/src/tags/reparent.rs @@ -109,7 +109,7 @@ fn reparented_name(existing_name: &str, new_parent: Option<&str>) -> Option baz::bar - let new_name = format!("{}::{}", new_parent, existing_base); + let new_name = format!("{new_parent}::{existing_base}"); if new_name != existing_name { Some(new_name) } else { diff --git a/rslib/src/template.rs b/rslib/src/template.rs index d09ade580..4895cc162 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -13,6 +13,7 @@ use nom::bytes::complete::tag; use nom::bytes::complete::take_until; use nom::combinator::map; use nom::sequence::delimited; +use nom::Parser; use regex::Regex; use crate::cloze::cloze_number_in_fields; @@ -67,7 +68,8 @@ impl TemplateMode { tag(self.end_tag()), ), |out| classify_handle(out), - )(s) + ) + .parse(s) } /// Return the next handlebar, comment or text token. @@ -127,7 +129,8 @@ fn comment_token(s: &str) -> nom::IResult<&str, Token> { tag(COMMENT_END), ), Token::Comment, - )(s) + ) + .parse(s) } fn tokens(mut template: &str) -> impl Iterator>> { @@ -262,10 +265,8 @@ fn template_error_to_anki_error( }; let details = htmlescape::encode_minimal(&localized_template_error(tr, err)); let more_info = tr.card_template_rendering_more_info(); - let source = format!( - "{}
{}
{}", - header, details, TEMPLATE_ERROR_LINK, more_info - ); + let source = + format!("{header}
{details}
{more_info}"); AnkiError::TemplateError { info: source } } @@ -276,32 +277,29 @@ fn localized_template_error(tr: &I18n, err: TemplateError) -> String { .card_template_rendering_no_closing_brackets("}}", tag) .into(), TemplateError::ConditionalNotClosed(tag) => tr - .card_template_rendering_conditional_not_closed(format!("{{{{/{}}}}}", tag)) + .card_template_rendering_conditional_not_closed(format!("{{{{/{tag}}}}}")) .into(), TemplateError::ConditionalNotOpen { closed, currently_open, } => if let Some(open) = currently_open { tr.card_template_rendering_wrong_conditional_closed( - format!("{{{{/{}}}}}", closed), - format!("{{{{/{}}}}}", open), + format!("{{{{/{closed}}}}}"), + format!("{{{{/{open}}}}}"), ) } else { tr.card_template_rendering_conditional_not_open( - format!("{{{{/{}}}}}", closed), - format!("{{{{#{}}}}}", closed), - format!("{{{{^{}}}}}", closed), + format!("{{{{/{closed}}}}}"), + format!("{{{{#{closed}}}}}"), + format!("{{{{^{closed}}}}}"), ) } .into(), TemplateError::FieldNotFound { field, filters } => tr - .card_template_rendering_no_such_field(format!("{{{{{}{}}}}}", filters, field), field) + .card_template_rendering_no_such_field(format!("{{{{{filters}{field}}}}}"), field) .into(), TemplateError::NoSuchConditional(condition) => tr - .card_template_rendering_no_such_field( - format!("{{{{{}}}}}", condition), - &condition[1..], - ) + .card_template_rendering_no_such_field(format!("{{{{{condition}}}}}"), &condition[1..]) .into(), } } @@ -520,10 +518,7 @@ impl RenderContext<'_> { Ok(false ^ negated) } else { let prefix = if negated { "^" } else { "#" }; - Err(TemplateError::NoSuchConditional(format!( - "{}{}", - prefix, key - ))) + Err(TemplateError::NoSuchConditional(format!("{prefix}{key}"))) } } } @@ -855,14 +850,14 @@ fn nodes_to_string(buf: &mut String, nodes: &[ParsedNode]) { .unwrap(); } ParsedNode::Conditional { key, children } => { - write!(buf, "{{{{#{}}}}}", key).unwrap(); + write!(buf, "{{{{#{key}}}}}").unwrap(); nodes_to_string(buf, children); - write!(buf, "{{{{/{}}}}}", key).unwrap(); + write!(buf, "{{{{/{key}}}}}").unwrap(); } ParsedNode::NegatedConditional { key, children } => { - write!(buf, "{{{{^{}}}}}", key).unwrap(); + write!(buf, "{{{{^{key}}}}}").unwrap(); nodes_to_string(buf, children); - write!(buf, "{{{{/{}}}}}", key).unwrap(); + write!(buf, "{{{{/{key}}}}}").unwrap(); } } } diff --git a/rslib/src/template_filters.rs b/rslib/src/template_filters.rs index cb3504fe3..4949e756d 100644 --- a/rslib/src/template_filters.rs +++ b/rslib/src/template_filters.rs @@ -165,15 +165,15 @@ fn furigana_filter(text: &str) -> Cow { /// convert to [[type:...]] for the gui code to process fn type_filter<'a>(field_name: &str) -> Cow<'a, str> { - format!("[[type:{}]]", field_name).into() + format!("[[type:{field_name}]]").into() } fn type_cloze_filter<'a>(field_name: &str) -> Cow<'a, str> { - format!("[[type:cloze:{}]]", field_name).into() + format!("[[type:cloze:{field_name}]]").into() } fn type_nc_filter<'a>(field_name: &str) -> Cow<'a, str> { - format!("[[type:nc:{}]]", field_name).into() + format!("[[type:nc:{field_name}]]").into() } fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> { @@ -191,18 +191,17 @@ fn hint_filter<'a>(text: &'a str, field_name: &str) -> Cow<'a, str> { r##" -{} - -"##, - id, field_name, id, text +{field_name} + +"## ) .into() } fn tts_filter(options: &str, text: &str) -> String { - format!("[anki:tts lang={}]{}[/anki:tts]", options, text) + format!("[anki:tts lang={options}]{text}[/anki:tts]") } // Tests diff --git a/rslib/src/text.rs b/rslib/src/text.rs index f83332ff8..590c05b39 100644 --- a/rslib/src/text.rs +++ b/rslib/src/text.rs @@ -484,7 +484,7 @@ pub(crate) fn to_custom_re<'a>(txt: &'a str, wildcard: &str) -> Cow<'a, str> { match s { r"\\" | r"\*" => s.to_string(), r"\_" => "_".to_string(), - "*" => format!("{}*", wildcard), + "*" => format!("{wildcard}*"), "_" => wildcard.to_string(), s => regex::escape(s), } diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index a020d706d..8a6ac4eb7 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -93,6 +93,10 @@ impl TimestampMillis { pub fn adding_secs(self, secs: i64) -> Self { Self(self.0 + secs * 1000) } + + pub fn elapsed_millis(self) -> u64 { + (Self::now().0 - self.0).max(0) as u64 + } } fn elapsed() -> time::Duration { diff --git a/rslib/sync/Cargo.toml b/rslib/sync/Cargo.toml index e2d960503..d23b4f380 100644 --- a/rslib/sync/Cargo.toml +++ b/rslib/sync/Cargo.toml @@ -13,4 +13,9 @@ path = "main.rs" name = "anki-sync-server" [dependencies] -anki.workspace = true + +[target.'cfg(windows)'.dependencies] +anki = { workspace = true, features = ["native-tls"] } + +[target.'cfg(not(windows))'.dependencies] +anki = { workspace = true, features = ["rustls"] } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index fa07f7fa5..8a21ec74e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] # older versions may fail to compile; newer versions may fail the clippy tests -channel = "1.87.0" +channel = "1.88.0" diff --git a/tools/build b/tools/build index 3df9456ed..4074ff98d 100755 --- a/tools/build +++ b/tools/build @@ -1,6 +1,8 @@ #!/bin/bash -set -e +set -eo pipefail + rm -rf out/wheels/* RELEASE=2 ./ninja wheels +(cd qt/release && ./build.sh) echo "wheels are in out/wheels" diff --git a/tools/minilints/src/main.rs b/tools/minilints/src/main.rs index 3a3c06f2c..37e213570 100644 --- a/tools/minilints/src/main.rs +++ b/tools/minilints/src/main.rs @@ -21,12 +21,7 @@ use walkdir::WalkDir; const NONSTANDARD_HEADER: &[&str] = &[ "./pylib/anki/_vendor/stringcase.py", - "./pylib/anki/importing/pauker.py", - "./pylib/anki/importing/supermemo_xml.py", "./pylib/anki/statsbg.py", - "./pylib/tools/protoc-gen-mypy.py", - "./python/pyqt/install.py", - "./python/write_wheel.py", "./qt/aqt/mpv.py", "./qt/aqt/winpaths.py", ]; @@ -113,7 +108,7 @@ impl LintContext { LazyCell::force(&self.unstaged_changes); fix_copyright(path)?; } else { - println!("missing standard copyright header: {:?}", path); + println!("missing standard copyright header: {path:?}"); self.found_problems = true; } } @@ -246,7 +241,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html .write(true) .open(path) .with_context(|| format!("opening {path}"))?; - write!(file, "{}{}", header, data).with_context(|| format!("writing {path}"))?; + write!(file, "{header}{data}").with_context(|| format!("writing {path}"))?; Ok(()) } diff --git a/tools/ninja.bat b/tools/ninja.bat index 7d939846e..6310103c3 100755 --- a/tools/ninja.bat +++ b/tools/ninja.bat @@ -1,5 +1,5 @@ @echo off -set CARGO_TARGET_DIR=%~dp0\..\out\rust +set CARGO_TARGET_DIR=%~dp0..\out\rust REM separate build+run steps so build env doesn't leak into subprocesses cargo build -p runner --release || exit /b 1 out\rust\release\runner build %* || exit /b 1 diff --git a/tools/publish b/tools/publish index 570f75c85..6214f27ed 100755 --- a/tools/publish +++ b/tools/publish @@ -1,7 +1,14 @@ #!/bin/bash set -e +shopt -s extglob -export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test) -out/extracted/uv/uv publish --index testpypi out/wheels/* +#export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test) +#out/extracted/uv/uv publish --index testpypi out/wheels/* +export UV_PUBLISH_TOKEN=$(pass show w/pypi-api) + +# Upload all wheels except anki_release*.whl first +out/extracted/uv/uv publish out/wheels/!(anki_release*).whl +# Then upload anki_release*.whl +out/extracted/uv/uv publish out/wheels/anki_release*.whl diff --git a/tools/reload_webviews.py b/tools/reload_webviews.py index 948401d9b..bb84c2554 100755 --- a/tools/reload_webviews.py +++ b/tools/reload_webviews.py @@ -43,11 +43,11 @@ except Exception as e: print_error( f"Could not establish connection to Chromium remote debugger. Is Anki Open? Exception:\n{e}" ) - exit(1) + sys.exit(1) if chrome.tabs is None: print_error("Was unable to get active web views.") - exit(1) + sys.exit(1) for tab_index, tab_data in enumerate(chrome.tabs): print(f"Reloading page: {tab_data['title']}") diff --git a/tools/run-qt6.7 b/tools/run-qt6.7 new file mode 100755 index 000000000..d01d46cea --- /dev/null +++ b/tools/run-qt6.7 @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +./ninja extract:uv + +export PYENV=./out/pyenv67 +UV_PROJECT_ENVIRONMENT=$PYENV ./out/extracted/uv/uv sync --all-packages --extra qt67 +./run $* diff --git a/tools/update-launcher-env b/tools/update-launcher-env new file mode 100755 index 000000000..c84569f55 --- /dev/null +++ b/tools/update-launcher-env @@ -0,0 +1,15 @@ +#!/bin/bash +# +# Install our latest anki/aqt code into the launcher venv + +set -e + +rm -rf out/wheels +./ninja wheels +if [[ "$OSTYPE" == "darwin"* ]]; then + export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv +else + export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv +fi +./out/extracted/uv/uv pip install out/wheels/* + diff --git a/tools/update-launcher-env.bat b/tools/update-launcher-env.bat new file mode 100644 index 000000000..9b0b814c6 --- /dev/null +++ b/tools/update-launcher-env.bat @@ -0,0 +1,8 @@ +@echo off +rem +rem Install our latest anki/aqt code into the launcher venv + +rmdir /s /q out\wheels 2>nul +call tools\ninja wheels +set VIRTUAL_ENV=%LOCALAPPDATA%\AnkiProgramFiles\.venv +for %%f in (out\wheels\*.whl) do out\extracted\uv\uv pip install "%%f" \ No newline at end of file diff --git a/ts/editable/Mathjax.svelte b/ts/editable/Mathjax.svelte index bc8e5cf81..74fbbba43 100644 --- a/ts/editable/Mathjax.svelte +++ b/ts/editable/Mathjax.svelte @@ -38,7 +38,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { pageTheme } from "$lib/sveltelib/theme"; import { convertMathjax, unescapeSomeEntities } from "./mathjax"; - import { ChangeTimer } from "./change-timer"; + import { CooldownTimer } from "./cooldown-timer"; export let mathjax: string; export let block: boolean; @@ -46,25 +46,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let converted: string, title: string; - const debouncedMathjax = writable(mathjax); - const debouncer = new ChangeTimer(); - $: debouncer.schedule(() => debouncedMathjax.set(mathjax), 500); + const debouncer = new CooldownTimer(500); - $: { + $: debouncer.schedule(() => { const cache = getCache($pageTheme.isDark, fontSize); - const entry = cache.get($debouncedMathjax); + const entry = cache.get(mathjax); if (entry) { [converted, title] = entry; } else { const entry = convertMathjax( - unescapeSomeEntities($debouncedMathjax), + unescapeSomeEntities(mathjax), $pageTheme.isDark, fontSize, ); [converted, title] = entry; - cache.set($debouncedMathjax, entry); + cache.set(mathjax, entry); } - } + }); $: empty = title === "MathJax"; $: encoded = encodeURIComponent(converted); diff --git a/ts/editable/cooldown-timer.ts b/ts/editable/cooldown-timer.ts new file mode 100644 index 000000000..892e2b05f --- /dev/null +++ b/ts/editable/cooldown-timer.ts @@ -0,0 +1,31 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +export class CooldownTimer { + private executing = false; + private queuedAction: (() => void) | null = null; + private delay: number; + + constructor(delayMs: number) { + this.delay = delayMs; + } + + schedule(action: () => void): void { + if (this.executing) { + this.queuedAction = action; + } else { + this.executing = true; + action(); + setTimeout(this.#pop.bind(this), this.delay); + } + } + + #pop(): void { + this.executing = false; + if (this.queuedAction) { + const action = this.queuedAction; + this.queuedAction = null; + this.schedule(action); + } + } +} diff --git a/ts/editor/ClozeButtons.svelte b/ts/editor/ClozeButtons.svelte index e9faae540..dfe4fb7c3 100644 --- a/ts/editor/ClozeButtons.svelte +++ b/ts/editor/ClozeButtons.svelte @@ -4,7 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html --> @@ -21,6 +23,11 @@ {/if} {#if item.help} + {#if item.global} +
+ +
+ {/if} {@html renderMarkdown(item.help)} {:else} {@html renderMarkdown( @@ -54,4 +61,12 @@ color: var(--fg-subtle); font-size: small; } + + .icon { + display: inline-block; + width: 1em; + fill: currentColor; + margin-right: 0.25em; + margin-bottom: 1.25em; + } diff --git a/ts/lib/components/RevertButton.svelte b/ts/lib/components/RevertButton.svelte index a1d6af06d..08376e7e6 100644 --- a/ts/lib/components/RevertButton.svelte +++ b/ts/lib/components/RevertButton.svelte @@ -76,7 +76,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } .hide :global(.badge) { - opacity: 0; + display: none; cursor: initial; } diff --git a/ts/lib/components/types.ts b/ts/lib/components/types.ts index 9a2105d9e..2f94a2778 100644 --- a/ts/lib/components/types.ts +++ b/ts/lib/components/types.ts @@ -9,6 +9,7 @@ export type HelpItem = { help?: string; url?: string; sched?: HelpItemScheduler; + global?: boolean; }; export enum HelpItemScheduler { diff --git a/ts/lib/sass/base.scss b/ts/lib/sass/base.scss index d28659ed8..571890102 100644 --- a/ts/lib/sass/base.scss +++ b/ts/lib/sass/base.scss @@ -44,6 +44,7 @@ html { } body { + font-family: inherit; overflow-x: hidden; &:not(.isMac), &:not(.isMac) * { diff --git a/ts/lib/tag-editor/TagEditor.svelte b/ts/lib/tag-editor/TagEditor.svelte index cbbbf3f57..eb033ef7a 100644 --- a/ts/lib/tag-editor/TagEditor.svelte +++ b/ts/lib/tag-editor/TagEditor.svelte @@ -510,7 +510,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html border: 1px solid var(--border); border-radius: var(--border-radius); padding: 6px; - margin: 1px; + margin: 1px 3px 3px 1px; &:focus-within { outline-offset: -1px; diff --git a/ts/lib/tag-editor/TagInput.svelte b/ts/lib/tag-editor/TagInput.svelte index a8d76bcee..31d3b51f6 100644 --- a/ts/lib/tag-editor/TagInput.svelte +++ b/ts/lib/tag-editor/TagInput.svelte @@ -166,7 +166,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } function onKeydown(event: KeyboardEvent): void { - switch (event.code) { + switch (event.key) { case "Enter": onEnter(event); break; diff --git a/ts/lib/tslib/help-page.ts b/ts/lib/tslib/help-page.ts index ff4e7e434..e3f209c6a 100644 --- a/ts/lib/tslib/help-page.ts +++ b/ts/lib/tslib/help-page.ts @@ -24,6 +24,7 @@ export const HelpPage = { displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order", maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday", newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday", + limitsFromTop: "https://docs.ankiweb.net/deck-options.html#limits-start-from-top", dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits", audio: "https://docs.ankiweb.net/deck-options.html#audio", fsrs: "http://docs.ankiweb.net/deck-options.html#fsrs", diff --git a/ts/lib/tslib/keys.ts b/ts/lib/tslib/keys.ts index 9bd6b42d8..58f571fac 100644 --- a/ts/lib/tslib/keys.ts +++ b/ts/lib/tslib/keys.ts @@ -90,7 +90,7 @@ export function keyToPlatformString(key: string): string { } export function isArrowLeft(event: KeyboardEvent): boolean { - if (event.code === "ArrowLeft") { + if (event.key === "ArrowLeft") { return true; } @@ -98,7 +98,7 @@ export function isArrowLeft(event: KeyboardEvent): boolean { } export function isArrowRight(event: KeyboardEvent): boolean { - if (event.code === "ArrowRight") { + if (event.key === "ArrowRight") { return true; } @@ -106,7 +106,7 @@ export function isArrowRight(event: KeyboardEvent): boolean { } export function isArrowUp(event: KeyboardEvent): boolean { - if (event.code === "ArrowUp") { + if (event.key === "ArrowUp") { return true; } @@ -114,7 +114,7 @@ export function isArrowUp(event: KeyboardEvent): boolean { } export function isArrowDown(event: KeyboardEvent): boolean { - if (event.code === "ArrowDown") { + if (event.key === "ArrowDown") { return true; } diff --git a/ts/lib/tslib/platform.ts b/ts/lib/tslib/platform.ts index 7aa5e137d..d055bcb53 100644 --- a/ts/lib/tslib/platform.ts +++ b/ts/lib/tslib/platform.ts @@ -13,3 +13,20 @@ export function isApplePlatform(): boolean { export function isDesktop(): boolean { return !(/iphone|ipad|ipod|android/i.test(window.navigator.userAgent)); } + +export function chromiumVersion(): number | null { + const userAgent = window.navigator.userAgent; + + // Check if it's a Chromium-based browser (Chrome, Edge, Opera, etc.) + // but exclude Safari which also contains "Chrome" in its user agent + if (userAgent.includes("Safari") && !userAgent.includes("Chrome")) { + return null; // Safari + } + + const chromeMatch = userAgent.match(/Chrome\/(\d+)/); + if (chromeMatch) { + return parseInt(chromeMatch[1], 10); + } + + return null; // Not a Chromium-based browser +} 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); diff --git a/ts/licenses.json b/ts/licenses.json index 2e88336b3..412d1dae3 100644 --- a/ts/licenses.json +++ b/ts/licenses.json @@ -95,8 +95,8 @@ "repository": "https://github.com/TooTallNate/node-agent-base", "publisher": "Nathan Rajlich", "email": "nathan@tootallnate.net", - "path": "node_modules/http-proxy-agent/node_modules/agent-base", - "licenseFile": "node_modules/http-proxy-agent/node_modules/agent-base/README.md" + "path": "node_modules/https-proxy-agent/node_modules/agent-base", + "licenseFile": "node_modules/https-proxy-agent/node_modules/agent-base/README.md" }, "asynckit@0.4.0": { "licenses": "MIT", diff --git a/ts/routes/deck-options/AdvancedOptions.svelte b/ts/routes/deck-options/AdvancedOptions.svelte index 31c3f0d4c..fb892b7ec 100644 --- a/ts/routes/deck-options/AdvancedOptions.svelte +++ b/ts/routes/deck-options/AdvancedOptions.svelte @@ -82,6 +82,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html title: tr.deckConfigCustomScheduling(), help: tr.deckConfigCustomSchedulingTooltip(), url: "https://faqs.ankiweb.net/the-2021-scheduler.html#add-ons-and-custom-scheduling", + global: true, }, }; const helpSections: HelpItem[] = Object.values(settings); diff --git a/ts/routes/deck-options/DailyLimits.svelte b/ts/routes/deck-options/DailyLimits.svelte index 9b156ca00..0e08ea38d 100644 --- a/ts/routes/deck-options/DailyLimits.svelte +++ b/ts/routes/deck-options/DailyLimits.svelte @@ -133,14 +133,15 @@ }, newCardsIgnoreReviewLimit: { title: tr.deckConfigNewCardsIgnoreReviewLimit(), - help: newCardsIgnoreReviewLimitHelp, url: HelpPage.DeckOptions.newCardsday, + global: true, }, applyAllParentLimits: { title: tr.deckConfigApplyAllParentLimits(), help: applyAllParentLimitsHelp, - url: HelpPage.DeckOptions.newCardsday, + url: HelpPage.DeckOptions.limitsFromTop, + global: true, }, }; const helpSections: HelpItem[] = Object.values(settings); diff --git a/ts/routes/deck-options/EasyDaysInput.svelte b/ts/routes/deck-options/EasyDaysInput.svelte index a7c13e2e2..c5fb4909a 100644 --- a/ts/routes/deck-options/EasyDaysInput.svelte +++ b/ts/routes/deck-options/EasyDaysInput.svelte @@ -20,53 +20,64 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -
- - - - - - - - - - - {#each easyDays as day, index} - - - - - {/each} - -
- {tr.deckConfigEasyDaysMinimum()} - - {tr.deckConfigEasyDaysReduced()} - - {tr.deckConfigEasyDaysNormal()} -
{day} - -
+
+
+ + {tr.deckConfigEasyDaysMinimum()} + {tr.deckConfigEasyDaysReduced()} + {tr.deckConfigEasyDaysNormal()} + + {#each easyDays as day, index} + {day} +
+ +
+ {/each} +
- diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index f573a0278..a166f2081 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb"; import { computeFsrsParams, - evaluateParams, + evaluateParamsLegacy, getRetentionWorkload, setWantsAbort, } from "@generated/backend"; @@ -21,7 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import SwitchRow from "$lib/components/SwitchRow.svelte"; import GlobalLabel from "./GlobalLabel.svelte"; - import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; + import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import Warning from "./Warning.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte"; @@ -29,8 +29,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import SimulatorModal from "./SimulatorModal.svelte"; import { GetRetentionWorkloadRequest, + type GetRetentionWorkloadResponse, UpdateDeckConfigsMode, } from "@generated/anki/deck_config_pb"; + import type Modal from "bootstrap/js/dist/modal"; + import TabbedValue from "./TabbedValue.svelte"; + import Item from "$lib/components/Item.svelte"; + import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -41,13 +46,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const defaults = state.defaults; const fsrsReschedule = state.fsrsReschedule; const daysSinceLastOptimization = state.daysSinceLastOptimization; + const limits = state.deckLimits; $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; let desiredRetentionFocused = false; let desiredRetentionEverFocused = false; let optimized = false; - const startingDesiredRetention = $config.desiredRetention.toFixed(2); $: if (desiredRetentionFocused) { desiredRetentionEverFocused = true; } @@ -62,28 +67,41 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; - $: roundedRetention = Number($config.desiredRetention.toFixed(2)); + $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2)); $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); - let timeoutId: ReturnType | undefined = undefined; - const WORKLOAD_UPDATE_DELAY_MS = 100; - let desiredRetentionChangeInfo = ""; - $: { - clearTimeout(timeoutId); - if (showDesiredRetentionTooltip) { - timeoutId = setTimeout(() => { - getRetentionChangeInfo(roundedRetention, fsrsParams($config)); - }, WORKLOAD_UPDATE_DELAY_MS); - } else { - desiredRetentionChangeInfo = ""; - } + $: if (showDesiredRetentionTooltip) { + getRetentionChangeInfo(roundedRetention, fsrsParams($config)); } $: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; + // Create tabs for desired retention + const desiredRetentionTabs: ValueTab[] = [ + new ValueTab( + tr.deckConfigSharedPreset(), + $config.desiredRetention, + (value) => ($config.desiredRetention = value!), + $config.desiredRetention, + null, + ), + new ValueTab( + tr.deckConfigDeckOnly(), + $limits.desiredRetention ?? null, + (value) => ($limits.desiredRetention = value ?? undefined), + null, + null, + ), + ]; + + // Get the effective desired retention value (deck-specific if set, otherwise config default) + let effectiveDesiredRetention = + $limits.desiredRetention ?? $config.desiredRetention; + const startingDesiredRetention = effectiveDesiredRetention.toFixed(2); + $: simulateFsrsRequest = new SimulateFsrsReviewRequest({ params: fsrsParams($config), desiredRetention: $config.desiredRetention, @@ -94,6 +112,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit, easyDaysPercentages: $config.easyDaysPercentages, reviewOrder: $config.reviewOrder, + historicalRetention: $config.historicalRetention, + learningStepCount: $config.learnSteps.length, + relearningStepCount: $config.relearnSteps.length, }); const DESIRED_RETENTION_LOW_THRESHOLD = 0.8; @@ -109,21 +130,37 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } } + let retentionWorkloadInfo: undefined | Promise = + undefined; + let lastParams = [...fsrsParams($config)]; + async function getRetentionChangeInfo(retention: number, params: number[]) { if (+startingDesiredRetention == roundedRetention) { desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged(); return; } - const request = new GetRetentionWorkloadRequest({ - w: params, - search: defaultparamSearch, - before: +startingDesiredRetention, - after: retention, - }); - const resp = await getRetentionWorkload(request); + if ( + // If the cache is empty and a request has not yet been made to fill it + !retentionWorkloadInfo || + // If the parameters have been changed + lastParams.toString() !== params.toString() + ) { + const request = new GetRetentionWorkloadRequest({ + w: params, + search: defaultparamSearch, + }); + lastParams = [...params]; + retentionWorkloadInfo = getRetentionWorkload(request); + } + + const previous = +startingDesiredRetention * 100; + const after = retention * 100; + const resp = await retentionWorkloadInfo; + const factor = resp.costs[after] / resp.costs[previous]; + desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({ - factor: resp.factor.toFixed(2), - previousDr: (+startingDesiredRetention * 100).toString(), + factor: factor.toFixed(2), + previousDr: previous.toString(), }); } @@ -183,29 +220,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html healthCheck: $healthCheck, }); - const already_optimal = + const alreadyOptimal = (params.length && params.every( (n, i) => n.toFixed(4) === resp.params[i].toFixed(4), )) || resp.params.length === 0; + let healthCheckMessage = ""; if (resp.healthCheckPassed !== undefined) { - if (resp.healthCheckPassed) { - setTimeout(() => alert(tr.deckConfigFsrsGoodFit()), 200); - } else { - setTimeout( - () => alert(tr.deckConfigFsrsBadFitWarning()), - 200, - ); - } - } else if (already_optimal) { - const msg = resp.fsrsItems + healthCheckMessage = resp.healthCheckPassed + ? tr.deckConfigFsrsGoodFit() + : tr.deckConfigFsrsBadFitWarning(); + } + let alreadyOptimalMessage = ""; + if (alreadyOptimal) { + alreadyOptimalMessage = resp.fsrsItems ? tr.deckConfigFsrsParamsOptimal() : tr.deckConfigFsrsParamsNoReviews(); - setTimeout(() => alert(msg), 200); } - if (!already_optimal) { + const message = [alreadyOptimalMessage, healthCheckMessage] + .filter((a) => a) + .join("\n\n"); + + if (message) { + setTimeout(() => alert(message), 200); + } + + if (!alreadyOptimal) { $config.fsrsParams6 = resp.params; setTimeout(() => { optimized = true; @@ -243,10 +285,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const search = $config.paramSearch ? $config.paramSearch : defaultparamSearch; - const resp = await evaluateParams({ + const resp = await evaluateParamsLegacy({ search, ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(), - numOfRelearningSteps: $config.relearnSteps.length, + params: fsrsParams($config), }); if (computeParamsProgress) { computeParamsProgress.current = computeParamsProgress.total; @@ -296,21 +338,41 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS); } - let showSimulator = false; + let simulatorModal: Modal; + let workloadModal: Modal; - + + + + openHelpModal("desiredRetention")}> + {tr.deckConfigDesiredRetention()} + + + + + + @@ -360,8 +422,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {tr.deckConfigOptimizeButton()} {/if} - {#if false} - + {#if state.legacyEvaluate}
-
-
+ + diff --git a/ts/routes/deck-options/FsrsOptionsOuter.svelte b/ts/routes/deck-options/FsrsOptionsOuter.svelte index 1f31e0bf9..fa543b5fc 100644 --- a/ts/routes/deck-options/FsrsOptionsOuter.svelte +++ b/ts/routes/deck-options/FsrsOptionsOuter.svelte @@ -35,10 +35,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html title: "FSRS", help: tr.deckConfigFsrsTooltip(), url: HelpPage.DeckOptions.fsrs, + global: true, }, desiredRetention: { title: tr.deckConfigDesiredRetention(), - help: tr.deckConfigDesiredRetentionTooltip(), + help: + tr.deckConfigDesiredRetentionTooltip() + + "\n\n" + + tr.deckConfigDesiredRetentionTooltip2(), sched: HelpItemScheduler.FSRS, }, modelParams: { @@ -53,6 +57,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html title: tr.deckConfigRescheduleCardsOnChange(), help: tr.deckConfigRescheduleCardsOnChangeTooltip(), sched: HelpItemScheduler.FSRS, + global: true, }, computeOptimalRetention: { title: tr.deckConfigComputeOptimalRetention(), @@ -62,10 +67,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html healthCheck: { title: tr.deckConfigHealthCheck(), help: + tr.deckConfigAffectsEntireCollection() + + "\n\n" + tr.deckConfigHealthCheckTooltip1() + "\n\n" + tr.deckConfigHealthCheckTooltip2(), sched: HelpItemScheduler.FSRS, + global: true, }, }; const helpSections: HelpItem[] = Object.values(settings); diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index 64b712560..c60f90455 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -13,15 +13,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import TableData from "../graphs/TableData.svelte"; import InputBox from "../graphs/InputBox.svelte"; import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; - import { SimulateSubgraph, type Point } from "../graphs/simulator"; + import { + SimulateSubgraph, + SimulateWorkloadSubgraph, + type Point, + type WorkloadPoint, + } from "../graphs/simulator"; import * as tr from "@generated/ftl"; - import { renderSimulationChart } from "../graphs/simulator"; - import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend"; + import { renderSimulationChart, renderWorkloadChart } from "../graphs/simulator"; + import { + computeOptimalRetention, + simulateFsrsReview, + simulateFsrsWorkload, + } from "@generated/backend"; import { runWithBackendProgress } from "@tslib/progress"; import type { ComputeOptimalRetentionResponse, SimulateFsrsReviewRequest, SimulateFsrsReviewResponse, + SimulateFsrsWorkloadResponse, } from "@generated/anki/scheduler_pb"; import type { DeckOptionsState } from "./lib"; import SwitchRow from "$lib/components/SwitchRow.svelte"; @@ -33,16 +43,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import EasyDaysInput from "./EasyDaysInput.svelte"; import Warning from "./Warning.svelte"; import type { ComputeRetentionProgress } from "@generated/anki/collection_pb"; + import Modal from "bootstrap/js/dist/modal"; - export let shown = false; export let state: DeckOptionsState; export let simulateFsrsRequest: SimulateFsrsReviewRequest; export let computing: boolean; export let openHelpModal: (key: string) => void; export let onPresetChange: () => void; + /** Do not modify this once set */ + export let workload: boolean = false; const config = state.currentConfig; let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count; + let simulateWorkloadSubgraph: SimulateWorkloadSubgraph = + SimulateWorkloadSubgraph.ratio; let tableData: TableDatum[] = []; let simulating: boolean = false; const fsrs = state.fsrs; @@ -50,7 +64,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let svg: HTMLElement | SVGElement | null = null; let simulationNumber = 0; - let points: Point[] = []; + let points: (WorkloadPoint | Point)[] = []; const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; let smooth = true; let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND; @@ -177,6 +191,43 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } } + async function simulateWorkload(): Promise { + let resp: SimulateFsrsWorkloadResponse | undefined; + updateRequest(); + try { + await runWithBackendProgress( + async () => { + simulating = true; + resp = await simulateFsrsWorkload(simulateFsrsRequest); + }, + () => {}, + ); + } finally { + simulating = false; + if (resp) { + simulationNumber += 1; + + points = points.concat( + Object.entries(resp.memorized).map(([dr, v]) => ({ + x: parseInt(dr), + timeCost: resp!.cost[dr], + memorized: v, + count: resp!.reviewCount[dr], + label: simulationNumber, + learnSpan: simulateFsrsRequest.daysToSimulate, + })), + ); + + tableData = renderWorkloadChart( + svg as SVGElement, + bounds, + points as WorkloadPoint[], + simulateWorkloadSubgraph, + ); + } + } + } + function clearSimulation() { points = points.filter((p) => p.label !== simulationNumber); simulationNumber = Math.max(0, simulationNumber - 1); @@ -188,6 +239,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ); } + function saveConfigToPreset() { + if (confirm(tr.deckConfigSaveOptionsToPresetConfirm())) { + $config.newPerDay = simulateFsrsRequest.newLimit; + $config.reviewsPerDay = simulateFsrsRequest.reviewLimit; + $config.maximumReviewInterval = simulateFsrsRequest.maxInterval; + if (!workload) { + $config.desiredRetention = simulateFsrsRequest.desiredRetention; + } + $newCardsIgnoreReviewLimit = simulateFsrsRequest.newCardsIgnoreReviewLimit; + $config.reviewOrder = simulateFsrsRequest.reviewOrder; + $config.leechAction = suspendLeeches + ? DeckConfig_Config_LeechAction.SUSPEND + : DeckConfig_Config_LeechAction.TAG_ONLY; + $config.leechThreshold = leechThreshold; + $config.easyDaysPercentages = [...easyDayPercentages]; + onPresetChange(); + } + } + $: if (svg) { let pointsToRender = points; if (smooth) { @@ -225,27 +295,48 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html }); } - tableData = renderSimulationChart( + const render_function = workload ? renderWorkloadChart : renderSimulationChart; + + tableData = render_function( svg as SVGElement, bounds, - pointsToRender, - simulateSubgraph, + // This cast shouldn't matter because we aren't switching between modes in the same modal + pointsToRender as WorkloadPoint[], + (workload ? simulateWorkloadSubgraph : simulateSubgraph) as any as never, ); } $: easyDayPercentages = [...$config.easyDaysPercentages]; + + export let modal: Modal | null = null; + + function setupModal(node: Element) { + modal = new Modal(node); + return { + destroy() { + modal?.dispose(); + modal = null; + }, + }; + } -