diff --git a/.version b/.version index ce73bf7c0..8dec45b51 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.08b1 +25.07.3rc1 diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e9e28969b..b4d1d9751 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -235,6 +235,8 @@ Sunong2008 Marvin Kopf Kevin Nakamura Felipe Colona +Bradley Szoke +jcznk ******************** diff --git a/Cargo.toml b/Cargo.toml index db5753893..2ff29cd1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,7 +141,7 @@ walkdir = "2.5.0" which = "8.0.0" 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_Foundation", "Win32_UI_Shell"] } +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/build/runner/src/pyenv.rs b/build/runner/src/pyenv.rs index d64c8fb3f..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", "--locked"]) + .args(["sync", "--locked", "--no-config"]) .args(args.extra_args), ); diff --git a/ftl/core-repo b/ftl/core-repo index 3d04bcbf7..b90ef6f03 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit 3d04bcbf7fefca0007bc9db307409d88210995d8 +Subproject commit b90ef6f03c251eb336029ac7c5f551200d41273f diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index ce24df434..23b72f267 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 8da1aace8..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 -## Retention rate represents your actual retention rate from past reviews, in +## Retention represents your actual retention from past reviews, in ## comparison to the "desired retention" setting of FSRS, which forecasts -## future retention. Retention rate is the percentage of all reviewed cards +## 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 = Retention rate +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 retention rate 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 c65a9587b..9aa63c335 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit c65a9587b1f18931986bdf145872e8e4c44c5c82 +Subproject commit 9aa63c335c61b30421d39cf43fd8e3975179059c diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 01f092a39..1294b4543 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -404,6 +404,7 @@ 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; } message SimulateFsrsReviewResponse { diff --git a/pylib/pyproject.toml b/pylib/pyproject.toml index 23e10077f..fb7422694 100644 --- a/pylib/pyproject.toml +++ b/pylib/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "decorator", "markdown", "orjson", - "protobuf>=4.21", + "protobuf>=6.0,<8.0", "requests[socks]", # remove after we update to min python 3.11+ "typing_extensions", diff --git a/qt/aqt/about.py b/qt/aqt/about.py index 228d3cfeb..03e989f2c 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -70,10 +70,10 @@ def show(mw: aqt.AnkiQt) -> QDialog: 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)) @@ -225,6 +225,7 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Adnane Taghi", "Anon_0000", "Bilolbek Normuminov", + "Sagiv Marzini", ) ) diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 807d4093c..0035e1f42 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -1292,9 +1292,10 @@ daily_backups weekly_backups monthly_backups - tabWidget syncAnkiHubLogout syncAnkiHubLogin + buttonBox + tabWidget diff --git a/qt/aqt/package.py b/qt/aqt/package.py index f85a17335..c8d481312 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -124,17 +124,14 @@ def launcher_executable() -> str | None: def trigger_launcher_run() -> None: - """Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run.""" + """Create a trigger file to request launcher UI on next run.""" try: root = launcher_root() if not root: return - pyproject_path = Path(root) / "pyproject.toml" - - if pyproject_path.exists(): - # Touch the file to update its mtime - pyproject_path.touch() + trigger_path = Path(root) / ".want-launcher" + trigger_path.touch() except Exception as e: print(e) @@ -150,6 +147,10 @@ def update_and_restart() -> None: with contextlib.suppress(ResourceWarning): env = os.environ.copy() + # 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 = ( diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index bd87ef830..afce6d489 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/stylesheets.py b/qt/aqt/stylesheets.py index a262e18b9..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, diff --git a/qt/launcher/addon/__init__.py b/qt/launcher/addon/__init__.py index 63a2cc5a9..799406e86 100644 --- a/qt/launcher/addon/__init__.py +++ b/qt/launcher/addon/__init__.py @@ -69,17 +69,14 @@ def add_python_requirements(reqs: list[str]) -> tuple[bool, str]: def trigger_launcher_run() -> None: - """Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run.""" + """Create a trigger file to request launcher UI on next run.""" try: root = launcher_root() if not root: return - pyproject_path = Path(root) / "pyproject.toml" - - if pyproject_path.exists(): - # Touch the file to update its mtime - pyproject_path.touch() + trigger_path = Path(root) / ".want-launcher" + trigger_path.touch() except Exception as e: print(e) diff --git a/qt/launcher/lin/build.sh b/qt/launcher/lin/build.sh index 7bd78c27d..f38f6defe 100755 --- a/qt/launcher/lin/build.sh +++ b/qt/launcher/lin/build.sh @@ -13,7 +13,8 @@ HOST_ARCH=$(uname -m) # Define output paths OUTPUT_DIR="../../../out/launcher" -LAUNCHER_DIR="$OUTPUT_DIR/anki-linux" +ANKI_VERSION=$(cat ../../../.version | tr -d '\n') +LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher-$ANKI_VERSION-linux" # Clean existing output directory rm -rf "$LAUNCHER_DIR" @@ -77,8 +78,8 @@ chmod +x \ chmod -R a+r "$LAUNCHER_DIR" ZSTD="zstd -c --long -T0 -18" -TRANSFORM="s%^.%anki-linux%S" -TARBALL="$OUTPUT_DIR/anki-linux.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 ac0ab2f09..a48960208 100644 --- a/qt/launcher/mac/Info.plist +++ b/qt/launcher/mac/Info.plist @@ -5,7 +5,7 @@ CFBundleDisplayName Anki CFBundleShortVersionString - 1.0 + ANKI_VERSION LSMinimumSystemVersion 12 LSApplicationCategoryType diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index 470b5cd25..d521e155b 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -31,7 +31,8 @@ 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/" 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/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs index 96688f190..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(()) } @@ -235,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 8e52e25af..903cc2b60 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -46,6 +46,8 @@ struct State { 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, + pyproject_modified_by_user: bool, previous_version: Option, resources_dir: std::path::PathBuf, } @@ -56,6 +58,12 @@ pub enum VersionKind { Uv(String), } +#[derive(Debug)] +pub struct Releases { + pub latest: Vec, + pub all: Vec, +} + #[derive(Debug, Clone)] pub enum MainMenuChoice { Latest, @@ -64,7 +72,6 @@ pub enum MainMenuChoice { ToggleBetas, ToggleCache, Uninstall, - Quit, } fn main() { @@ -100,6 +107,8 @@ fn run() -> Result<()> { 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"), + pyproject_modified_by_user: false, // calculated later previous_version: None, resources_dir, }; @@ -119,15 +128,15 @@ fn run() -> Result<()> { &state.user_python_version_path, )?; - let pyproject_has_changed = !state.sync_complete_marker.exists() || { - let pyproject_toml_time = modified_time(&state.user_pyproject_path)?; - let sync_complete_time = modified_time(&state.sync_complete_marker)?; - Ok::(pyproject_toml_time > sync_complete_time) - } - .unwrap_or(true); + let launcher_requested = state.launcher_trigger_file.exists(); - if !pyproject_has_changed { - // If venv is already up to date, launch Anki normally + // 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)?; @@ -137,6 +146,11 @@ fn run() -> Result<()> { // If we weren't in a terminal, respawn ourselves in one ensure_terminal_shown()?; + 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"); @@ -184,6 +198,7 @@ fn extract_aqt_version( ) -> Option { let output = Command::new(uv_path) .current_dir(uv_install_root) + .env("VIRTUAL_ENV", uv_install_root.join(".venv")) .args(["pip", "show", "aqt"]) .output() .ok()?; @@ -230,13 +245,7 @@ fn check_versions(state: &mut State) { } fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Result<()> { - update_pyproject_for_version( - choice.clone(), - state.dist_pyproject_path.clone(), - state.user_pyproject_path.clone(), - state.dist_python_version_path.clone(), - state.user_python_version_path.clone(), - )?; + 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.uv_path, &state.uv_install_root); @@ -255,40 +264,27 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re None }; - // `uv sync` sometimes does not pull in Python automatically - // This might be system/platform specific and/or a uv bug. + // Prepare to sync the venv let mut command = Command::new(&state.uv_path); - command - .current_dir(&state.uv_install_root) - .env("UV_CACHE_DIR", &state.uv_cache_dir) - .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) - .args(["python", "install", "--managed-python"]); + command.current_dir(&state.uv_install_root); - // Add python version if .python-version file exists - if let Some(version) = &python_version_trimmed { - command.args([version]); + // 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); + } } - command.ensure_success().context("Python install failed")?; - - // Sync the venv - let mut command = Command::new(&state.uv_path); command - .current_dir(&state.uv_install_root) .env("UV_CACHE_DIR", &state.uv_cache_dir) .env("UV_PYTHON_INSTALL_DIR", &state.uv_python_install_dir) - .args(["sync", "--upgrade", "--managed-python"]); + .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]); } - // Set UV_PRERELEASE=allow if beta mode is enabled - if state.prerelease_marker.exists() { - command.env("UV_PRERELEASE", "allow"); - } - if state.no_cache_marker.exists() { command.env("UV_NO_CACHE", "1"); } @@ -326,9 +322,11 @@ fn main_menu_loop(state: &State) -> Result<()> { let menu_choice = get_main_menu_choice(state)?; match menu_choice { - MainMenuChoice::Quit => std::process::exit(0), MainMenuChoice::KeepExisting => { - // Skip sync, just launch existing installation + if state.pyproject_modified_by_user { + // User has custom edits, sync them + handle_version_install_or_update(state, MainMenuChoice::KeepExisting)?; + } break; } MainMenuChoice::ToggleBetas => { @@ -385,14 +383,28 @@ fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> { 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 (just press enter)"); + 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); - println!("3) Keep existing version ({normalized_current})"); + + 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); @@ -413,7 +425,6 @@ fn get_main_menu_choice(state: &State) -> Result { ); println!(); println!("7) Uninstall"); - println!("8) Quit"); print!("> "); let _ = stdout().flush(); @@ -453,7 +464,6 @@ fn get_main_menu_choice(state: &State) -> Result { "5" => MainMenuChoice::ToggleBetas, "6" => MainMenuChoice::ToggleCache, "7" => MainMenuChoice::Uninstall, - "8" => MainMenuChoice::Quit, _ => { println!("Invalid input. Please try again."); continue; @@ -465,13 +475,9 @@ fn get_main_menu_choice(state: &State) -> Result { fn get_version_kind(state: &State) -> Result> { println!("Please wait..."); - 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<&String> = latest_patches.iter().take(5).collect(); - let releases_str = latest_releases + let releases = get_releases(state)?; + let releases_str = releases + .latest .iter() .map(|v| v.as_str()) .collect::>() @@ -494,7 +500,7 @@ fn get_version_kind(state: &State) -> Result> { let normalized_input = normalize_version(input); // Check if the version exists in the available versions - let version_exists = all_versions.iter().any(|v| v == &normalized_input); + let version_exists = releases.all.iter().any(|v| v == &normalized_input); match (parse_version_kind(input), version_exists) { (Some(version_kind), true) => { @@ -502,7 +508,7 @@ fn get_version_kind(state: &State) -> Result> { Ok(Some(version_kind)) } (None, true) => { - println!("Versions before 2.1.50 can't be installedn"); + println!("Versions before 2.1.50 can't be installed."); Ok(None) } _ => { @@ -635,19 +641,70 @@ fn fetch_versions(state: &State) -> Result> { Ok(versions) } -fn update_pyproject_for_version( - menu_choice: MainMenuChoice, - dist_pyproject_path: std::path::PathBuf, - user_pyproject_path: std::path::PathBuf, - dist_python_version_path: std::path::PathBuf, - user_python_version_path: std::path::PathBuf, -) -> Result<()> { +fn get_releases(state: &State) -> Result { + 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}"), + ), + }; + write_file(&state.user_pyproject_path, &updated_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 => { - let content = read_file(&dist_pyproject_path)?; - write_file(&user_pyproject_path, &content)?; - let python_version_content = read_file(&dist_python_version_path)?; - write_file(&user_python_version_path, &python_version_content)?; + // 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 @@ -662,46 +719,7 @@ fn update_pyproject_for_version( unreachable!(); } MainMenuChoice::Version(version_kind) => { - let content = read_file(&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}")) - } - }; - write_file(&user_pyproject_path, &updated_content)?; - - // Update .python-version based on version kind - match &version_kind { - VersionKind::PyOxidizer(_) => { - write_file(&user_python_version_path, "3.9")?; - } - VersionKind::Uv(_) => { - copy_file(&dist_python_version_path, &user_python_version_path)?; - } - } - } - MainMenuChoice::Quit => { - std::process::exit(0); + apply_version_kind(&version_kind, state)?; } } Ok(()) @@ -875,11 +893,6 @@ fn build_python_command(state: &State, args: &[String]) -> Result { cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); - // Set UV_PRERELEASE=allow if beta mode is enabled - if state.prerelease_marker.exists() { - cmd.env("UV_PRERELEASE", "allow"); - } - Ok(cmd) } diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 2e55f2b69..5e4bddda3 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -11,21 +11,22 @@ pub fn relaunch_in_terminal() -> Result<()> { // Try terminals in roughly most specific to least specific. // First, try commonly used terminals for riced systems. - // Second, try the minimalist/compatibility terminals. - // Finally, try terminals usually installed by default. + // Second, try common defaults. + // Finally, try x11 compatibility terminals. let terminals = [ // commonly used for riced systems ("alacritty", vec!["-e"]), ("kitty", vec![]), - // minimalistic terminals for constrained systems ("foot", vec![]), - ("urxvt", vec!["-e"]), - ("xterm", vec!["-e"]), + // 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!["--"]), + ("gnome-terminal", vec!["-e"]), ("konsole", vec!["-e"]), + // x11-compatibility terminals + ("urxvt", vec!["-e"]), + ("xterm", vec!["-e"]), ]; for (terminal_cmd, args) in &terminals { diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index 3c060a9de..ebdff6261 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -8,6 +8,7 @@ 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; @@ -18,8 +19,25 @@ 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; +/// 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 + } + } +} + pub fn ensure_terminal_shown() -> Result<()> { unsafe { if !GetConsoleWindow().is_invalid() { @@ -29,6 +47,14 @@ pub fn ensure_terminal_shown() -> Result<()> { } 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(()); diff --git a/qt/launcher/win/anki.template.nsi b/qt/launcher/win/anki.template.nsi index 84dedf9c8..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 diff --git a/qt/pyproject.toml b/qt/pyproject.toml index 6a686dde8..1ff34f59d 100644 --- a/qt/pyproject.toml +++ b/qt/pyproject.toml @@ -40,8 +40,8 @@ qt67 = [ qt = [ "pyqt6==6.9.1", "pyqt6-qt6==6.9.1", - "pyqt6-webengine==6.9.0", - "pyqt6-webengine-qt6==6.9.1", + "pyqt6-webengine==6.8.0", + "pyqt6-webengine-qt6==6.8.2", "pyqt6_sip==13.10.2", ] qt68 = [ diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index eab89b783..bfe0eafaf 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -228,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)) @@ -334,6 +337,7 @@ 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, @@ -352,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) } } } diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 425d8da69..b2640fa3e 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -377,6 +377,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/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 34cc925d6..e032ecaf3 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -10,11 +10,14 @@ 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 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; @@ -129,7 +132,7 @@ impl Collection { fn is_included_card(c: &Card) -> bool { c.queue != CardQueue::Suspended && c.queue != CardQueue::PreviewRepeat - && c.queue != CardQueue::New + && c.ctype != CardType::New } // calculate any missing memory state for c in &mut cards { @@ -143,13 +146,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)? @@ -251,39 +270,34 @@ impl Collection { } 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, + 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/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 -->