diff --git a/.buildkite/linux/entrypoint b/.buildkite/linux/entrypoint index 9a971266c..a519cfc3d 100755 --- a/.buildkite/linux/entrypoint +++ b/.buildkite/linux/entrypoint @@ -22,7 +22,7 @@ echo "--- Ensure libs importable" SKIP_RUN=1 ./run echo "--- Check Rust libs" -cargo install cargo-deny --version 0.14.12 +cargo install cargo-deny --version 0.14.24 cargo deny check echo "--- Cleanup" diff --git a/.deny.toml b/.deny.toml index ccf9b8aa6..c1dabe383 100644 --- a/.deny.toml +++ b/.deny.toml @@ -5,8 +5,8 @@ db-path = "~/.cargo/advisory-db" db-urls = ["https://github.com/rustsec/advisory-db"] ignore = [ - # safemem only used by makeapp - "RUSTSEC-2023-0081", + # gix only used by burn-train + "RUSTSEC-2024-0350", ] [licenses] @@ -23,6 +23,7 @@ allow = [ "CC0-1.0", "Unlicense", "Zlib", + "Unicode-3.0", ] confidence-threshold = 0.8 # eg { allow = ["Zlib"], name = "adler32", version = "*" }, diff --git a/.isort.cfg b/.isort.cfg index bb5a3841f..109f5c21e 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,10 +1,5 @@ [settings] -ensure_newline_before_comments=true -force_grid_wrap=0 -include_trailing_comma=True +py_version=39 known_first_party=anki,aqt,tests -line_length=88 -multi_line_output=3 profile=black -skip= -use_parentheses=True +extend_skip=qt/bundle diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 000000000..498ecbdac --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,2 @@ +target-version = "py39" +extend-exclude = ["qt/bundle"] diff --git a/CONTRIBUTORS b/CONTRIBUTORS index d1db7eebc..b2f34ebf5 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -148,6 +148,7 @@ user1823 <92206575+user1823@users.noreply.github.com> Gustaf Carefall virinci snowtimeglass +brishtibheja Ben Olson Akash Reddy Lucio Sauer @@ -177,7 +178,14 @@ RRomeroJr <117.rromero@gmail.com> Xidorn Quan Alexander Bocken James Elmore - +Ian Samir Yep Manzano +David Culley <6276049+davidculley@users.noreply.github.com> +Rastislav Kish +Expertium +Christian Donat +Asuka Minato +Dillon Baldwin +Voczi ******************** The text of the 3 clause BSD license follows: diff --git a/Cargo.lock b/Cargo.lock index 93470d74b..718f7c1ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "0.6.4" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70cec685337af48789e58cea6ef59ee9f01289d1083428b03fe14e76b98c817c" +checksum = "285d9b275f7d5a276f17006e9d92ea67fa9991187ae88760fa96705fba1f97aa" dependencies = [ "burn", "itertools 0.12.1", @@ -3859,9 +3859,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ "bitflags 2.6.0", "cfg-if", @@ -3891,9 +3891,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.102" +version = "0.9.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 27eda27d6..b119e8c0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "0.6.4" +version = "1.1.4" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" # rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941" # path = "../open-spaced-repetition/fsrs-rs" diff --git a/build/ninja_gen/src/build.rs b/build/ninja_gen/src/build.rs index 6c4ef3a15..402074e99 100644 --- a/build/ninja_gen/src/build.rs +++ b/build/ninja_gen/src/build.rs @@ -368,8 +368,8 @@ pub trait FilesHandle { /// different variables. This is a shortcut for calling .expand_inputs() /// and then .add_inputs_vec() /// - If the variable name is non-empty, a variable of the same name will be - /// created so the file list can be accessed in the command. By convention, - /// this is often `in`. + /// created so the file list can be accessed in the command. By + /// convention, this is often `in`. fn add_inputs(&mut self, variable: &'static str, inputs: impl AsRef); fn add_inputs_vec(&mut self, variable: &'static str, inputs: Vec); fn add_order_only_inputs(&mut self, variable: &'static str, inputs: impl AsRef); @@ -392,14 +392,14 @@ pub trait FilesHandle { /// Add outputs to the build statement. Can be called multiple times with /// different variables. /// - Each output automatically has $builddir/ prefixed to it if it does not - /// already start with it. + /// already start with it. /// - If the variable name is non-empty, a variable of the same name will be - /// created so the file list can be accessed in the command. By convention, - /// this is often `out`. - /// - If subgroup is true, the files are also placed in a subgroup. Eg - /// if a rule `foo` exists and subgroup `bar` is provided, the files are - /// accessible via `:foo:bar`. The variable name must not be empty, or - /// called `out`. + /// created so the file list can be accessed in the command. By + /// convention, this is often `out`. + /// - If subgroup is true, the files are also placed in a subgroup. Eg if a + /// rule `foo` exists and subgroup `bar` is provided, the files are + /// accessible via `:foo:bar`. The variable name must not be empty, or + /// called `out`. fn add_outputs_ext( &mut self, variable: impl Into, diff --git a/build/ninja_gen/src/node.rs b/build/ninja_gen/src/node.rs index 853e6f24b..be121bc2f 100644 --- a/build/ninja_gen/src/node.rs +++ b/build/ninja_gen/src/node.rs @@ -223,18 +223,6 @@ impl BuildAction for SvelteCheck { build.add_inputs("yarn", inputs![":yarn:bin"]); build.add_inputs("", &self.inputs); build.add_inputs("", inputs!["yarn.lock"]); - build.add_variable( - "compiler_warnings", - [ - "a11y-click-events-have-key-events", - "a11y-no-noninteractive-tabindex", - "a11y-no-static-element-interactions", - ] - .iter() - .map(|warning| format!("{}$:ignore", warning)) - .collect::>() - .join(","), - ); let hash = simple_hash(&self.tsconfig); build.add_output_stamp(format!("tests/svelte-check.{hash}")); } diff --git a/build/ninja_gen/src/python.rs b/build/ninja_gen/src/python.rs index 8d8d3b444..d3e04845c 100644 --- a/build/ninja_gen/src/python.rs +++ b/build/ninja_gen/src/python.rs @@ -251,7 +251,7 @@ impl BuildAction for PythonTest { build.add_variable("folder", self.folder); build.add_variable( "pythonpath", - &self.python_path.join(if cfg!(windows) { ";" } else { ":" }), + self.python_path.join(if cfg!(windows) { ";" } else { ":" }), ); build.add_env_var("PYTHONPATH", "$pythonpath"); build.add_env_var("ANKI_TEST_MODE", "1"); diff --git a/cargo/licenses.json b/cargo/licenses.json index 5615cb0bc..01e615b76 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1252,7 +1252,7 @@ }, { "name": "fsrs", - "version": "0.6.4", + "version": "1.1.4", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", @@ -2521,7 +2521,7 @@ }, { "name": "openssl", - "version": "0.10.64", + "version": "0.10.66", "authors": "Steven Fackler ", "repository": "https://github.com/sfackler/rust-openssl", "license": "Apache-2.0", @@ -2548,7 +2548,7 @@ }, { "name": "openssl-sys", - "version": "0.9.102", + "version": "0.9.103", "authors": "Alex Crichton |Steven Fackler ", "repository": "https://github.com/sfackler/rust-openssl", "license": "MIT", diff --git a/docs/contributing.md b/docs/contributing.md index 1c57df0d6..3ba1a1d98 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -3,32 +3,29 @@ For info on contributing things other than code, such as translations, decks and add-ons, please see https://docs.ankiweb.net/contrib -With most users now on 2.1, the past 2 years have been focused on paying down some -of the technical debt that Anki's codebase has built up over the years, and making -changes that will make future maintenance and refactoring easier. A lot of Anki's -"business logic" has been migrated to Rust, which AnkiMobile and AnkiDroid -can also take advantage of - previously a lot of effort was wasted writing the same -code for each platform, and then debugging differences in the implementations. -Considerable effort has also been put into improving the Python side of things, -with type hints added to the majority of the codebase, automatic linting/formatting, -and refactoring of parts of the code. - -The import/export code remains to be done, and this will likely -take a number of months to work through. Until that is complete, new features -will not be the top priority, unless they are easy wins as part of the refactoring -process. - -If you are planning to contribute any non-trivial changes, please reach out -on the support site before you begin work. Some areas (primarily pylib/) are -likely to change/conflict with other work, and larger changes will likely need -to wait until the refactoring process is done. - ## Help wanted If you'd like to contribute but don't know what to work on, please take a look at the [issues tab](https://github.com/ankitects/anki/issues) of the Anki repo on GitHub. +## Larger changes + +Before starting work on larger changes, especially ones that aren't listed on the +issue tracker, please reach out on the forums before you begin work, so we can let +you know whether they're likely to be accepted or not. When you spent a bunch of time +on a PR that ends up getting rejected, it's no fun for either you or us. + +## Refactoring + +Please avoid PRs that focus on refactoring. Every PR has a cost to review, and a chance +of introducing accidental regressions, and often these costs are not worth it for +slightly more elegant code. + +That's not to say there's no value in refactoring. But such changes are usually better done +in a PR that happens to be working in the same area - for example, making small changes +to the code as part of fixing a bug, or a larger refactor when introducing a new feature. + ## Type hints Most of Anki's Python code now has type hints, which improve code completion, diff --git a/docs/syncserver/Dockerfile b/docs/syncserver/Dockerfile index 89fcc008f..b45eb2e68 100644 --- a/docs/syncserver/Dockerfile +++ b/docs/syncserver/Dockerfile @@ -1,14 +1,14 @@ -FROM rust:1.76-alpine3.19 AS builder +FROM rust:1.79-alpine3.20 AS builder ARG ANKI_VERSION RUN apk update && apk add --no-cache build-base protobuf && rm -rf /var/cache/apk/* RUN cargo install --git https://github.com/ankitects/anki.git \ - --tag ${ANKI_VERSION} \ - --root /anki-server \ - anki-sync-server +--tag ${ANKI_VERSION} \ +--root /anki-server \ +anki-sync-server -FROM alpine:3.19.1 +FROM alpine:3.20 RUN adduser -D -h /home/anki anki @@ -25,8 +25,9 @@ EXPOSE ${SYNC_PORT} CMD ["anki-sync-server"] -# TODO - consider exposing endpoint /health to check on health cause currently it will return 404 error -# HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ -# CMD wget -qO- http://localhost:${SYNC_PORT} || exit 1 +# This health check will work for Anki versions 24.06.3 and newer. +# For older versions, it may incorrectly report an unhealthy status, which should not be the case. +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:${SYNC_PORT}/health || exit 1 LABEL maintainer="Jean Khawand " \ No newline at end of file diff --git a/ftl/core-repo b/ftl/core-repo index 173f04387..ababc05c4 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit 173f043875bb38607d7d31bfcb0eb9874b8e4f3a +Subproject commit ababc05c46db93309ac50e7f80c10d6559314aa1 diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index e346c9d77..ca89dbd82 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -61,7 +61,7 @@ deck-config-today-only = Today only deck-config-learning-steps = Learning steps # Please don't translate `1m`, `2d` --deck-config-delay-hint = Delays are typically minutes (eg `1m`) or days (eg `2d`), but hours (eg `1h`) and seconds (eg `30s`) are also supported. +-deck-config-delay-hint = Delays are typically minutes (e.g. `1m`) or days (e.g. `2d`), but hours (e.g. `1h`) and seconds (e.g. `30s`) are also supported. deck-config-learning-steps-tooltip = One or more delays, separated by spaces. The first delay will be used when you press the `Again` button on a new card, and is 1 minute by default. @@ -100,7 +100,7 @@ deck-config-leech-threshold-tooltip = think of a mnemonic to help you remember it. # See actions-suspend-card and scheduling-tag-only for the wording deck-config-leech-action-tooltip = - `Tag Only`: Add a "leech" tag to the note, and display a pop-up. + `Tag Only`: Add a 'leech' tag to the note, and display a pop-up. `Suspend Card`: In addition to tagging the note, hide the card until it is manually unsuspended. @@ -148,8 +148,8 @@ deck-config-new-gather-priority-tooltip-2 = the latest-added first. `Random notes`: gathers cards of randomly selected notes. When sibling burying is - disabled, this allows all cards of a note to be seen in a session (eg. both a front->back - and back->front card) + disabled, this allows all cards of a note to be seen in a session (e.g. both a front→back + and back→front card). `Random cards`: gathers cards completely randomly. deck-config-new-gather-priority-deck = Deck @@ -234,7 +234,7 @@ deck-config-stop-timer-on-answer-tooltip = ## Auto Advance section deck-config-seconds-to-show-question = Seconds to show question for -deck-config-seconds-to-show-question-tooltip-2 = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. +deck-config-seconds-to-show-question-tooltip-3 = When auto advance is activated, the number of seconds to wait before applying the question action. Set to 0 to disable. deck-config-seconds-to-show-answer = Seconds to show answer for deck-config-seconds-to-show-answer-tooltip-2 = When auto advance is activated, the number of seconds to wait before applying the answer action. Set to 0 to disable. deck-config-question-action-show-answer = Show Answer @@ -242,8 +242,8 @@ deck-config-question-action-show-reminder = Show Reminder deck-config-question-action = Question action deck-config-question-action-tool-tip = The action to perform after the question is shown, and time has elapsed. deck-config-answer-action = Answer action -deck-config-answer-action-tooltip = The action to perform on the current card before automatically advancing to the next one. -deck-config-wait-for-audio-tooltip = Wait for audio to finish before automatically revealing answer or next question. +deck-config-answer-action-tooltip-2 = The action to perform after the answer is shown, and time has elapsed. +deck-config-wait-for-audio-tooltip-2 = Wait for audio to finish before automatically applying the question action or answer action. ## Audio section @@ -251,7 +251,7 @@ deck-config-audio-title = Audio deck-config-disable-autoplay = Don't play audio automatically deck-config-disable-autoplay-tooltip = When enabled, Anki will not play audio automatically. - It can be played manually by clicking/tapping on an audio icon, or by using the replay audio action. + It can be played manually by clicking/tapping on an audio icon, or by using the Replay action. deck-config-skip-question-when-replaying = Skip question when replaying answer deck-config-always-include-question-audio-tooltip = Whether the question audio should be included when the Replay action is @@ -350,7 +350,7 @@ deck-config-compute-optimal-weights = Optimize FSRS parameters deck-config-compute-minimum-recommended-retention = Minimum recommended retention deck-config-optimize-button = Optimize deck-config-compute-button = Compute -deck-config-ignore-before = Ignore reviews before +deck-config-ignore-before = Ignore cards reviewed before deck-config-optimize-all-tip = You can optimize all presets at once by using the dropdown button next to "Save". deck-config-evaluate-button = Evaluate deck-config-desired-retention = Desired retention @@ -368,7 +368,7 @@ deck-config-reschedule-cards-on-change = Reschedule cards on change deck-config-fsrs-tooltip = Affects the entire collection. - The Free Spaced Repetition Scheduler (FSRS) is an alternative to Anki's legacy SuperMemo 2 (SM2) scheduler. + The Free Spaced Repetition Scheduler (FSRS) is an alternative to Anki's legacy SuperMemo 2 (SM-2) scheduler. By more accurately determining when you are likely to forget, it can help you remember more material in the same amount of time. This setting is shared by all deck presets. @@ -388,11 +388,11 @@ deck-config-historical-retention-tooltip = the missing reviews. Your review history may be incomplete for two reasons: - 1. Because you've used the 'ignore reviews before' option. + 1. Because you're using the 'ignore cards reviewed before' option. 2. Because you previously deleted review logs to free up space, or imported material from a different SRS program. - The latter is quite rare, so unless you've used the former option, you probably don't need to adjust + The latter is quite rare, so unless you're using the former option, you probably don't need to adjust this setting. deck-config-weights-tooltip2 = FSRS parameters affect how cards are scheduled. Anki will start with default parameters. You can use @@ -406,12 +406,12 @@ deck-config-reschedule-cards-on-change-tooltip = will be changed. deck-config-reschedule-cards-warning = Depending on your desired retention, this can result in a large number of cards becoming - due, so is not recommended when first switching from SM2. + due, so is not recommended when first switching from SM-2. Use this option sparingly, as it will add a review entry to each of your cards, and increase the size of your collection. -deck-config-ignore-before-tooltip = - If set, reviews before the provided date will be ignored when optimizing & evaluating FSRS parameters. +deck-config-ignore-before-tooltip-2 = + If set, cards reviewed before the provided date will be ignored when optimizing FSRS parameters. This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons. deck-config-compute-optimal-weights-tooltip2 = When you click the Optimize button, FSRS will analyze your review history, and generate parameters that are @@ -469,6 +469,12 @@ deck-config-bury-tooltip = When using the V3 scheduler, interday learning cards can also be buried. Interday learning cards are cards with a current learning step of one or more days. +deck-config-seconds-to-show-question-tooltip = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. +deck-config-answer-action-tooltip = The action to perform on the current card before automatically advancing to the next one. +deck-config-wait-for-audio-tooltip = Wait for audio to finish before automatically revealing answer or next question. +deck-config-ignore-before-tooltip = + If set, reviews before the provided date will be ignored when optimizing & evaluating FSRS parameters. + This can be useful if you imported someone else's scheduling data, or have changed the way you use the answer buttons. deck-config-compute-optimal-retention-tooltip = This tool assumes you're starting with 0 cards, and will attempt to calculate the amount of material you'll be able to retain in the given time frame. The estimated retention will greatly depend on your inputs, and @@ -505,3 +511,4 @@ deck-config-compute-optimal-retention-tooltip3 = set your desired retention to. You may wish to choose a higher desired retention, if you’re willing to trade more study time for a greater recall rate. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. +deck-config-seconds-to-show-question-tooltip-2 = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 3e0a2414b..1da294b5a 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -41,6 +41,7 @@ preferences-theme-follow-system = Follow System preferences-theme-light = Light preferences-theme-dark = Dark preferences-v3-scheduler = V3 scheduler +preferences-check-for-updates = Check for program updates preferences-ignore-accents-in-search = Ignore accents in search (slower) preferences-backup-explanation = Anki periodically backs up your collection. After backups are more than 2 days old, diff --git a/ftl/core/statistics.ftl b/ftl/core/statistics.ftl index bbafcabd8..3ab464fa8 100644 --- a/ftl/core/statistics.ftl +++ b/ftl/core/statistics.ftl @@ -152,7 +152,7 @@ statistics-cards-due = } statistics-backlog-checkbox = Backlog statistics-intervals-title = Review Intervals -statistics-intervals-subtitle = Delays until reviews are shown again. +statistics-intervals-subtitle = Delays until review cards are shown again. statistics-intervals-day-range = { $cards -> [one] { $cards } card with a { $daysStart }~{ $daysEnd } day interval diff --git a/ftl/qt-repo b/ftl/qt-repo index 1e8db8f33..8cc8e3447 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit 1e8db8f33c0da127e67312aeb77f1c3aa901af77 +Subproject commit 8cc8e3447cca79a7a8720e118388f6383c830eee diff --git a/package.json b/package.json index c86449e88..b8e0f6601 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "dev": "cd ts && vite dev", "build": "cd ts && vite build", "preview": "cd ts && vite preview", - "svelte-check:once": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning --compiler-warnings a11y-click-events-have-key-events:ignore,a11y-no-noninteractive-tabindex:ignore,a11y-no-static-element-interactions:ignore", - "svelte-check": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch --compiler-warnings a11y-click-events-have-key-events:ignore,a11y-no-noninteractive-tabindex:ignore,a11y-no-static-element-interactions:ignore", + "svelte-check:once": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings --threshold warning", + "svelte-check": "cd ts && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "vitest:once": "cd ts && vitest run", "vitest": "cd ts && vitest" }, diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index bc78100e2..1bacd0d1f 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -402,27 +402,26 @@ message ComputeOptimalRetentionResponse { float optimal_retention = 1; } -message OptimalRetentionParameters { - double recall_secs_hard = 1; - double recall_secs_good = 2; - double recall_secs_easy = 3; - double forget_secs = 4; - double learn_secs = 5; - double first_rating_probability_again = 6; - double first_rating_probability_hard = 7; - double first_rating_probability_good = 8; - double first_rating_probability_easy = 9; - double review_rating_probability_hard = 10; - double review_rating_probability_good = 11; - double review_rating_probability_easy = 12; -} - message GetOptimalRetentionParametersRequest { string search = 1; } message GetOptimalRetentionParametersResponse { - OptimalRetentionParameters params = 1; + uint32 deck_size = 1; + uint32 learn_span = 2; + float max_cost_perday = 3; + float max_ivl = 4; + repeated float learn_costs = 5; + repeated float review_costs = 6; + repeated float first_rating_prob = 7; + repeated float review_rating_prob = 8; + repeated float first_rating_offsets = 9; + repeated float first_session_lens = 10; + float forget_rating_offset = 11; + float forget_session_len = 12; + float loss_aversion = 13; + uint32 learn_limit = 14; + uint32 review_limit = 15; } message EvaluateWeightsRequest { diff --git a/pylib/anki/_backend.py b/pylib/anki/_backend.py index 70e294e77..c3ac2a114 100644 --- a/pylib/anki/_backend.py +++ b/pylib/anki/_backend.py @@ -6,8 +6,9 @@ from __future__ import annotations import sys import time import traceback +from collections.abc import Iterable, Sequence from threading import current_thread, main_thread -from typing import TYPE_CHECKING, Any, Iterable, Sequence +from typing import TYPE_CHECKING, Any from weakref import ref from markdown import markdown diff --git a/pylib/anki/_legacy.py b/pylib/anki/_legacy.py index 39a445900..24456aa14 100644 --- a/pylib/anki/_legacy.py +++ b/pylib/anki/_legacy.py @@ -8,7 +8,8 @@ import os import pathlib import sys import traceback -from typing import TYPE_CHECKING, Any, Callable, Union +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Union from anki._vendor import stringcase # type: ignore diff --git a/pylib/anki/_rsbridge.pyi b/pylib/anki/_rsbridge.pyi index 6f7675b0c..fe3072ed8 100644 --- a/pylib/anki/_rsbridge.pyi +++ b/pylib/anki/_rsbridge.pyi @@ -1,6 +1,5 @@ from typing import Union - class Backend: @classmethod def command(cls, service: int, method: int, data: bytes) -> bytes: ... diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index fc002eadc..8a822a7f1 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -5,6 +5,7 @@ from __future__ import annotations import pprint import time +from typing import NewType import anki # pylint: disable=unused-import import anki.collection diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index a8d5d62b4..e2fa0e1d5 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -3,7 +3,8 @@ from __future__ import annotations -from typing import Any, Generator, Iterable, Literal, Sequence, Union, cast +from collections.abc import Generator, Iterable, Sequence +from typing import Any, Literal, Union, cast from anki import ( ankiweb_pb2, diff --git a/pylib/anki/dbproxy.py b/pylib/anki/dbproxy.py index 331598aa4..f1e114261 100644 --- a/pylib/anki/dbproxy.py +++ b/pylib/anki/dbproxy.py @@ -4,8 +4,9 @@ from __future__ import annotations import re +from collections.abc import Callable, Iterable, Sequence from re import Match -from typing import TYPE_CHECKING, Any, Callable, Iterable, Sequence, Union +from typing import TYPE_CHECKING, Any, Union if TYPE_CHECKING: import anki._backend diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 462806028..e61721dd8 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -4,7 +4,8 @@ from __future__ import annotations import copy -from typing import TYPE_CHECKING, Any, Iterable, NewType, Sequence +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, Any, NewType if TYPE_CHECKING: import anki diff --git a/pylib/anki/exporting.py b/pylib/anki/exporting.py index 1e75adfae..43713d8b2 100644 --- a/pylib/anki/exporting.py +++ b/pylib/anki/exporting.py @@ -13,8 +13,9 @@ import threading import time import unicodedata import zipfile +from collections.abc import Sequence from io import BufferedWriter -from typing import Any, Optional, Sequence +from typing import Any from zipfile import ZipFile from anki import hooks @@ -26,16 +27,16 @@ from anki.utils import ids2str, namedtmp, split_fields, strip_html class Exporter: includeHTML: bool | None = None - ext: Optional[str] = None - includeTags: Optional[bool] = None - includeSched: Optional[bool] = None - includeMedia: Optional[bool] = None + ext: str | None = None + includeTags: bool | None = None + includeSched: bool | None = None + includeMedia: bool | None = None def __init__( self, col: Collection, - did: Optional[DeckId] = None, - cids: Optional[list[CardId]] = None, + did: DeckId | None = None, + cids: list[CardId] | None = None, ) -> None: self.col = col.weakref() self.did = did diff --git a/pylib/anki/foreign_data/__init__.py b/pylib/anki/foreign_data/__init__.py index 50992487b..afcaf685e 100644 --- a/pylib/anki/foreign_data/__init__.py +++ b/pylib/anki/foreign_data/__init__.py @@ -94,8 +94,8 @@ class ForeignCard: class ForeignNote: fields: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list) - notetype: Union[str, NotetypeId] = "" - deck: Union[str, DeckId] = "" + notetype: str | NotetypeId = "" + deck: str | DeckId = "" cards: list[ForeignCard] = field(default_factory=list) @@ -103,7 +103,7 @@ class ForeignNote: class ForeignData: notes: list[ForeignNote] = field(default_factory=list) notetypes: list[ForeignNotetype] = field(default_factory=list) - default_deck: Union[str, DeckId] = "" + default_deck: str | DeckId = "" def serialize(self) -> str: return json.dumps(self, cls=ForeignDataEncoder, separators=(",", ":")) diff --git a/pylib/anki/foreign_data/mnemosyne.py b/pylib/anki/foreign_data/mnemosyne.py index 4ae3d7f5b..078355571 100644 --- a/pylib/anki/foreign_data/mnemosyne.py +++ b/pylib/anki/foreign_data/mnemosyne.py @@ -17,7 +17,6 @@ Notetype | Card Type import re from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Tuple, Type from anki.db import DB from anki.decks import DeckId @@ -38,7 +37,7 @@ def serialize(db_path: str, deck_id: DeckId) -> str: def gather_data(db: DB, deck_id: DeckId) -> ForeignData: facts = gather_facts(db) gather_cards_into_facts(db, facts) - used_fact_views: dict[Type[MnemoFactView], bool] = {} + used_fact_views: dict[type[MnemoFactView], bool] = {} notes = [fact.foreign_note(used_fact_views) for fact in facts.values()] notetypes = [fact_view.foreign_notetype() for fact_view in used_fact_views] return ForeignData(notes, notetypes, deck_id) @@ -54,7 +53,7 @@ def open_mnemosyne_db(db_path: str) -> DB: class MnemoFactView(ABC): notetype: str - field_keys: Tuple[str, ...] + field_keys: tuple[str, ...] @classmethod @abstractmethod @@ -162,7 +161,7 @@ class MnemoFact: cards: list[MnemoCard] = field(default_factory=list) def foreign_note( - self, used_fact_views: dict[Type[MnemoFactView], bool] + self, used_fact_views: dict[type[MnemoFactView], bool] ) -> ForeignNote: fact_view = self.fact_view() used_fact_views[fact_view] = True @@ -173,7 +172,7 @@ class MnemoFact: cards=self.foreign_cards(), ) - def fact_view(self) -> Type[MnemoFactView]: + def fact_view(self) -> type[MnemoFactView]: try: fact_view = self.cards[0].fact_view_id except IndexError as err: @@ -190,7 +189,7 @@ class MnemoFact: raise Exception(f"Fact {id} has unknown fact view: {fact_view}") - def anki_fields(self, fact_view: Type[MnemoFactView]) -> list[str]: + def anki_fields(self, fact_view: type[MnemoFactView]) -> list[str]: return [munge_field(self.fields.get(k, "")) for k in fact_view.field_keys] def anki_tags(self) -> list[str]: diff --git a/pylib/anki/hooks.py b/pylib/anki/hooks.py index 9e360ebfa..fcc3758f4 100644 --- a/pylib/anki/hooks.py +++ b/pylib/anki/hooks.py @@ -14,6 +14,9 @@ modifying it. from __future__ import annotations +from collections.abc import Callable +from typing import Any + import decorator # You can find the definitions in ../tools/genhooks.py @@ -32,7 +35,7 @@ def runHook(hook: str, *args: Any) -> None: for func in hookFuncs: try: func(*args) - except: + except Exception: hookFuncs.remove(func) raise @@ -43,7 +46,7 @@ def runFilter(hook: str, arg: Any, *args: Any) -> Any: for func in hookFuncs: try: arg = func(arg, *args) - except: + except Exception: hookFuncs.remove(func) raise return arg diff --git a/pylib/anki/httpclient.py b/pylib/anki/httpclient.py index 25714edbd..186f90623 100644 --- a/pylib/anki/httpclient.py +++ b/pylib/anki/httpclient.py @@ -9,7 +9,8 @@ from __future__ import annotations import io import os -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import requests from requests import Response diff --git a/pylib/anki/importing/__init__.py b/pylib/anki/importing/__init__.py index 9bd01b0e2..cfc2cac3f 100644 --- a/pylib/anki/importing/__init__.py +++ b/pylib/anki/importing/__init__.py @@ -1,7 +1,8 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from typing import Any, Callable, Sequence, Type, Union +from collections.abc import Callable, Sequence +from typing import Any, Type, Union import anki from anki.collection import Collection diff --git a/pylib/anki/importing/anki2.py b/pylib/anki/importing/anki2.py index 004fff06b..098265c3f 100644 --- a/pylib/anki/importing/anki2.py +++ b/pylib/anki/importing/anki2.py @@ -2,10 +2,11 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # pylint: disable=invalid-name +from __future__ import annotations import os import unicodedata -from typing import Optional +from typing import Any from anki.cards import CardId from anki.collection import Collection @@ -31,7 +32,7 @@ class MediaMapInvalid(Exception): class Anki2Importer(Importer): needMapper = False - deckPrefix: Optional[str] = None + deckPrefix: str | None = None allowUpdate = True src: Collection dst: Collection @@ -409,7 +410,7 @@ insert or ignore into revlog values (?,?,?,?,?,?,?,?,?)""", if fname.startswith("_") and not self.dst.media.have(fname): self._writeDstMedia(fname, self._srcMediaData(fname)) - def _mediaData(self, fname: str, dir: Optional[str] = None) -> bytes: + def _mediaData(self, fname: str, dir: str | None = None) -> bytes: if not dir: dir = self.src.media.dir() path = os.path.join(dir, fname) diff --git a/pylib/anki/importing/apkg.py b/pylib/anki/importing/apkg.py index 31d1cc4fd..ea2325960 100644 --- a/pylib/anki/importing/apkg.py +++ b/pylib/anki/importing/apkg.py @@ -2,12 +2,13 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # pylint: disable=invalid-name +from __future__ import annotations import json import os import unicodedata import zipfile -from typing import Any, Optional +from typing import Any from anki.importing.anki2 import Anki2Importer, MediaMapInvalid from anki.utils import tmpfile @@ -15,7 +16,7 @@ from anki.utils import tmpfile class AnkiPackageImporter(Anki2Importer): nameToNum: dict[str, str] - zip: Optional[zipfile.ZipFile] + zip: zipfile.ZipFile | None def run(self) -> None: # type: ignore # extract the deck from the zip file diff --git a/pylib/anki/importing/base.py b/pylib/anki/importing/base.py index 4834181cd..2ddcaaebf 100644 --- a/pylib/anki/importing/base.py +++ b/pylib/anki/importing/base.py @@ -2,8 +2,9 @@ # 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, Optional +from typing import Any from anki.collection import Collection from anki.utils import max_id @@ -15,7 +16,7 @@ from anki.utils import max_id class Importer: needMapper = False needDelimiter = False - dst: Optional[Collection] + dst: Collection | None def __init__(self, col: Collection, file: str) -> None: self.file = file diff --git a/pylib/anki/importing/csvfile.py b/pylib/anki/importing/csvfile.py index bebbdf5b2..6a2ed347c 100644 --- a/pylib/anki/importing/csvfile.py +++ b/pylib/anki/importing/csvfile.py @@ -7,7 +7,7 @@ from __future__ import annotations import csv import re -from typing import Any, Optional, TextIO +from typing import Any, TextIO from anki.collection import Collection from anki.importing.noteimp import ForeignNote, NoteImporter @@ -20,12 +20,12 @@ class TextImporter(NoteImporter): def __init__(self, col: Collection, file: str) -> None: NoteImporter.__init__(self, col, file) self.lines = None - self.fileobj: Optional[TextIO] = None - self.delimiter: Optional[str] = None + self.fileobj: TextIO | None = None + self.delimiter: str | None = None self.tagsToAdd: list[str] = [] self.numFields = 0 - self.dialect: Optional[Any] - self.data: Optional[str | list[str]] + self.dialect: Any | None + self.data: str | list[str] | None def foreignNotes(self) -> list[ForeignNote]: self.open() @@ -99,15 +99,15 @@ class TextImporter(NoteImporter): if not self.delimiter: try: self.dialect = sniffer.sniff("\n".join(self.data[:10]), self.patterns) - except: + except Exception: try: self.dialect = sniffer.sniff(self.data[0], self.patterns) - except: + except Exception: pass if self.dialect: try: reader = csv.reader(self.data, self.dialect, doublequote=True) - except: + except Exception: err() else: if not self.delimiter: @@ -126,7 +126,7 @@ class TextImporter(NoteImporter): if row: self.numFields = len(row) break - except: + except Exception: err() self.initMapping() diff --git a/pylib/anki/importing/noteimp.py b/pylib/anki/importing/noteimp.py index 688e75eca..f69696ef8 100644 --- a/pylib/anki/importing/noteimp.py +++ b/pylib/anki/importing/noteimp.py @@ -7,7 +7,7 @@ from __future__ import annotations import html import unicodedata -from typing import Optional, Union +from typing import Union from anki.collection import Collection from anki.config import Config @@ -76,8 +76,8 @@ class NoteImporter(Importer): needDelimiter = False allowHTML = False importMode = UPDATE_MODE - mapping: Optional[list[str]] - tagModified: Optional[str] + mapping: list[str] | None + tagModified: str | None def __init__(self, col: Collection, file: str) -> None: Importer.__init__(self, col, file) @@ -268,7 +268,7 @@ class NoteImporter(Importer): def updateData( self, n: ForeignNote, id: NoteId, sflds: list[str] - ) -> Optional[Updates]: + ) -> Updates | None: self._ids.append(id) self.processFields(n, sflds) if self._tagsMapped: @@ -316,9 +316,7 @@ where id = ? and flds != ?""", changes2 = self.col.db.scalar("select total_changes()") self.updateCount = changes2 - changes - def processFields( - self, note: ForeignNote, fields: Optional[list[str]] = None - ) -> None: + def processFields(self, note: ForeignNote, fields: list[str] | None = None) -> None: if not fields: fields = [""] * len(self.model["flds"]) for c, f in enumerate(self.mapping): diff --git a/pylib/anki/importing/supermemo_xml.py b/pylib/anki/importing/supermemo_xml.py index 3e9127877..202592c2e 100644 --- a/pylib/anki/importing/supermemo_xml.py +++ b/pylib/anki/importing/supermemo_xml.py @@ -3,13 +3,13 @@ # 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 typing import Optional, Union from xml.dom import minidom from xml.dom.minidom import Element, Text @@ -329,7 +329,7 @@ class SupermemoXmlImporter(NoteImporter): self.logger("Load done.") # PARSE - def parse(self, node: Optional[Union[Text, Element]] = None) -> None: + def parse(self, node: Text | Element | None = None) -> None: "Parse method - parses document elements" if node is None and self.xmldoc is not None: diff --git a/pylib/anki/lang.py b/pylib/anki/lang.py index d83342f0a..188b884a1 100644 --- a/pylib/anki/lang.py +++ b/pylib/anki/lang.py @@ -180,9 +180,16 @@ def set_lang(lang: str) -> None: tr_legacyglobal.backend = weakref.ref(current_i18n) -def get_def_lang(lang: str | None = None) -> tuple[int, str]: - """Return lang converted to name used on disk and its index, defaulting to system language +def get_def_lang(user_lang: str | None = None) -> tuple[int, str]: + """Return user_lang converted to name used on disk and its index, defaulting to system language or English if not available.""" + + def get_index_of_language(wanted_locale: str) -> int | None: + for i, (_, locale_) in enumerate(langs): + if locale_ == wanted_locale: + return i + return None + try: # 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(): @@ -192,28 +199,30 @@ def get_def_lang(lang: str | None = None) -> tuple[int, str]: # this will return a different format on Windows (e.g. Italian_Italy), resulting in us falling back to en_US # further below (sys_lang, enc) = locale.getlocale() - except: + except Exception: # fails on osx sys_lang = "en_US" - user_lang = lang if user_lang in compatMap: user_lang = compatMap[user_lang] + idx = None lang = None - en_idx = None for preferred_lang in (user_lang, sys_lang): - for lang_idx, (name, code) in enumerate(langs): - if code == "en_US": - en_idx = lang_idx - if code == preferred_lang: - idx = lang_idx - lang = preferred_lang - if idx is not None: + idx = get_index_of_language(preferred_lang) + is_language_supported = idx is not None + if is_language_supported: + assert preferred_lang is not None + lang = preferred_lang break # if the specified language and the system language aren't available, revert to english - if idx is None: - idx = en_idx + is_preferred_language_supported = idx is not None + if not is_preferred_language_supported: lang = "en_US" + idx = get_index_of_language(lang) + is_english_supported = idx is not None + if not is_english_supported: + raise AssertionError("English is supposed to be a supported language.") + assert idx is not None and lang is not None return (idx, lang) diff --git a/pylib/anki/latex.py b/pylib/anki/latex.py index 75c7d9e9f..ed8c9cce6 100644 --- a/pylib/anki/latex.py +++ b/pylib/anki/latex.py @@ -170,7 +170,7 @@ def _err_msg(col: anki.collection.Collection, type: str, texpath: str) -> str: if not log: raise Exception() msg += f"
{html.escape(log)}
" - except: + except Exception: msg += col.tr.media_have_you_installed_latex_and_dvipngdvisvgm() return msg diff --git a/pylib/anki/media.py b/pylib/anki/media.py index 49d2a9d1f..b76d7116c 100644 --- a/pylib/anki/media.py +++ b/pylib/anki/media.py @@ -8,7 +8,7 @@ import pprint import re import sys import time -from typing import Callable, Sequence +from collections.abc import Callable, Sequence from anki import media_pb2 from anki._legacy import DeprecatedNamesMixin, deprecated_keywords diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 9301ca025..4dfdb0211 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -7,7 +7,8 @@ import copy import pprint import sys import time -from typing import Any, NewType, Sequence, Union +from collections.abc import Sequence +from typing import Any, NewType, Union import anki # pylint: disable=unused-import import anki.collection @@ -312,7 +313,7 @@ class ModelManager(DeprecatedNamesMixin): def rename_field( self, notetype: NotetypeDict, field: FieldDict, new_name: str ) -> None: - if not field in notetype["flds"]: + if field not in notetype["flds"]: raise Exception("invalid field") field["name"] = new_name @@ -388,8 +389,8 @@ and notes.mid = ? and cards.ord = ?""", To get defaults, use - input = ChangeNotetypeRequest() - input.ParseFromString(col.models.change_notetype_info(...)) + info = col.models.change_notetype_info(...) + input = info.input input.note_ids.extend([...]) The new_fields and new_templates lists are relative to the new notetype's diff --git a/pylib/anki/notes.py b/pylib/anki/notes.py index e0147ac3a..5de95bfb6 100644 --- a/pylib/anki/notes.py +++ b/pylib/anki/notes.py @@ -4,7 +4,8 @@ from __future__ import annotations import copy -from typing import NewType, Sequence +from collections.abc import Sequence +from typing import NewType import anki # pylint: disable=unused-import import anki.cards @@ -94,21 +95,28 @@ class Note(DeprecatedNamesMixin): self, ord: int = 0, *, - custom_note_type: NotetypeDict = None, - custom_template: TemplateDict = None, + custom_note_type: NotetypeDict | None = None, + custom_template: TemplateDict | None = None, fill_empty: bool = False, ) -> anki.cards.Card: card = anki.cards.Card(self.col) card.ord = ord card.did = anki.decks.DEFAULT_DECK_ID - model = custom_note_type or self.note_type() - template = copy.copy( - custom_template - or ( - model["tmpls"][ord] if model["type"] == MODEL_STD else model["tmpls"][0] - ) - ) + if custom_note_type is None: + model = self.note_type() + else: + model = custom_note_type + if model is None: + raise NotImplementedError + + if custom_template is not None: + template = custom_template + elif model["type"] == MODEL_STD: + template = model["tmpls"][ord] + else: + template = model["tmpls"][0] + template = copy.copy(template) # may differ in cloze case template["ord"] = card.ord @@ -171,10 +179,7 @@ class Note(DeprecatedNamesMixin): return self.col.tags.in_list(tag, self.tags) def remove_tag(self, tag: str) -> None: - rem = [] - for tag_ in self.tags: - if tag_.lower() == tag.lower(): - rem.append(tag_) + rem = [tag_ for tag_ in self.tags if tag_.lower() == tag.lower()] for tag_ in rem: self.tags.remove(tag_) diff --git a/pylib/anki/scheduler/base.py b/pylib/anki/scheduler/base.py index f1498ef7c..83ef9d393 100644 --- a/pylib/anki/scheduler/base.py +++ b/pylib/anki/scheduler/base.py @@ -22,7 +22,8 @@ ScheduleCardsAsNewDefaults = scheduler_pb2.ScheduleCardsAsNewDefaultsResponse FilteredDeckForUpdate = decks_pb2.FilteredDeckForUpdate RepositionDefaults = scheduler_pb2.RepositionDefaultsResponse -from typing import Sequence, overload +from collections.abc import Sequence +from typing import overload from anki import config_pb2 from anki.cards import CardId diff --git a/pylib/anki/scheduler/legacy.py b/pylib/anki/scheduler/legacy.py index a5e90a677..58bed7933 100644 --- a/pylib/anki/scheduler/legacy.py +++ b/pylib/anki/scheduler/legacy.py @@ -5,8 +5,6 @@ from __future__ import annotations -from typing import Optional - from anki._legacy import deprecated from anki.cards import Card, CardId from anki.consts import ( @@ -54,7 +52,7 @@ class SchedulerBaseWithLegacy(SchedulerBase): print("_nextDueMsg() is obsolete") return "" - def rebuildDyn(self, did: Optional[DeckId] = None) -> Optional[int]: + def rebuildDyn(self, did: DeckId | None = None) -> int | None: did = did or self.col.decks.selected() count = self.rebuild_filtered_deck(did).count or None if not count: @@ -63,7 +61,7 @@ class SchedulerBaseWithLegacy(SchedulerBase): self.col.decks.select(did) return count - def emptyDyn(self, did: Optional[DeckId], lim: Optional[str] = None) -> None: + def emptyDyn(self, did: DeckId | None, lim: str | None = None) -> None: if lim is None: self.empty_filtered_deck(did) return diff --git a/pylib/anki/scheduler/v3.py b/pylib/anki/scheduler/v3.py index d7d9e1837..2a18ee021 100644 --- a/pylib/anki/scheduler/v3.py +++ b/pylib/anki/scheduler/v3.py @@ -14,7 +14,8 @@ as '2' internally. from __future__ import annotations -from typing import Literal, Optional, Sequence +from collections.abc import Sequence +from typing import Any, Literal from anki import frontend_pb2, scheduler_pb2 from anki._legacy import deprecated @@ -109,7 +110,7 @@ class Scheduler(SchedulerBaseWithLegacy): # backend automatically resets queues as operations are performed pass - def getCard(self) -> Optional[Card]: + def getCard(self) -> Card | None: """Fetch the next card from the queue. None if finished.""" try: queued_card = self.get_queued_cards().cards[0] @@ -125,7 +126,7 @@ class Scheduler(SchedulerBaseWithLegacy): "Don't use this, it is a stop-gap until this code is refactored." return not self.get_queued_cards().cards - def counts(self, card: Optional[Card] = None) -> tuple[int, int, int]: + def counts(self, card: Card | None = None) -> tuple[int, int, int]: info = self.get_queued_cards() return (info.new_count, info.learning_count, info.review_count) diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py index dc8997606..2f7de2e04 100644 --- a/pylib/anki/stats.py +++ b/pylib/anki/stats.py @@ -8,7 +8,8 @@ from __future__ import annotations import json import random import time -from typing import Sequence +from collections.abc import Sequence +from typing import Any import anki.cards import anki.collection @@ -721,7 +722,7 @@ select count(), avg(ivl), max(ivl) from cards where did in %s and queue = {QUEUE tot = bad + good try: pct = good / float(tot) * 100 - except: + except Exception: pct = 0 i.append( "Correct: %(pct)0.2f%%
(%(good)d of %(tot)d)" @@ -973,7 +974,7 @@ from cards where did in %s""" else: conf["legend"] = {"container": "#%sLegend" % id, "noColumns": 10} conf["series"] = dict(stack=True) - if not "yaxis" in conf: + if "yaxis" not in conf: conf["yaxis"] = {} conf["yaxis"]["labelWidth"] = 40 if "xaxis" not in conf: diff --git a/pylib/anki/stdmodels.py b/pylib/anki/stdmodels.py index 2ce981165..721b96bc6 100644 --- a/pylib/anki/stdmodels.py +++ b/pylib/anki/stdmodels.py @@ -3,7 +3,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING, Any import anki.collection import anki.models diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 3f75ca793..a54aa7901 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -13,7 +13,8 @@ from __future__ import annotations import pprint import re -from typing import Collection, Match, Sequence +from collections.abc import Collection, Sequence +from typing import Match import anki # pylint: disable=unused-import import anki.collection diff --git a/pylib/anki/template.py b/pylib/anki/template.py index c4a31c2ef..deda8c819 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -28,8 +28,9 @@ template_legacy.py file, using the legacy addHook() system. from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Sequence, Union +from typing import Any, Union import anki import anki.cards diff --git a/pylib/anki/utils.py b/pylib/anki/utils.py index 150a210fb..b5382e6df 100644 --- a/pylib/anki/utils.py +++ b/pylib/anki/utils.py @@ -13,9 +13,10 @@ import subprocess import sys import tempfile import time +from collections.abc import Callable, Iterable, Iterator from contextlib import contextmanager from hashlib import sha1 -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator +from typing import TYPE_CHECKING, Any from anki._legacy import DeprecatedNamesMixinForModule from anki.dbproxy import DBProxy @@ -28,7 +29,7 @@ try: to_json_bytes: Callable[[Any], bytes] = orjson.dumps from_json_bytes = orjson.loads -except: +except Exception: print("orjson is missing; DB operations will be slower") def to_json_bytes(obj: Any) -> bytes: @@ -214,7 +215,7 @@ def call(argv: list[str], wait: bool = True, **kwargs: Any) -> int: info = subprocess.STARTUPINFO() # type: ignore try: info.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore - except: + except Exception: # pylint: disable=no-member info.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW # type: ignore else: @@ -285,7 +286,7 @@ def plat_desc() -> str: else: theos = system break - except: + except Exception: continue return theos diff --git a/pylib/tests/test_importing.py b/pylib/tests/test_importing.py index ec2460bf7..191de51f4 100644 --- a/pylib/tests/test_importing.py +++ b/pylib/tests/test_importing.py @@ -26,7 +26,7 @@ def clear_tempfile(tf): try: tf.close() os.unlink(tf.name) - except: + except Exception: pass diff --git a/pylib/tests/test_schedv3.py b/pylib/tests/test_schedv3.py index 2a1f79213..442dc0a62 100644 --- a/pylib/tests/test_schedv3.py +++ b/pylib/tests/test_schedv3.py @@ -4,7 +4,8 @@ import copy import os import time -from typing import Callable, Dict +from collections.abc import Callable +from typing import Dict import pytest @@ -385,7 +386,7 @@ def test_reviews(): assert "leech" in c.note().tags -def review_limits_setup() -> tuple[anki.collection.Collection, Dict]: +def review_limits_setup() -> tuple[anki.collection.Collection, dict]: col = getEmptyCol() parent = col.decks.get(col.decks.id("parent")) diff --git a/pylib/tools/genhooks.py b/pylib/tools/genhooks.py index e14f92fc4..e0e4924be 100644 --- a/pylib/tools/genhooks.py +++ b/pylib/tools/genhooks.py @@ -137,7 +137,9 @@ prefix = """\ from __future__ import annotations -from typing import Any, Callable, Sequence +from collections.abc import Callable, Sequence +from typing import Any + import anki import anki.hooks from anki.cards import Card diff --git a/pylib/tools/hookslib.py b/pylib/tools/hookslib.py index 6361c633e..8920cdcfc 100644 --- a/pylib/tools/hookslib.py +++ b/pylib/tools/hookslib.py @@ -5,12 +5,13 @@ Code for generating hooks. """ +from __future__ import annotations + import os import subprocess import sys from dataclasses import dataclass from operator import attrgetter -from typing import Optional sys.path.append("pylib/anki/_vendor") @@ -23,19 +24,19 @@ class Hook: name: str # string of the typed arguments passed to the callback, eg # ["kind: str", "val: int"] - args: list[str] = None + args: list[str] | None = None # string of the return type. if set, hook is a filter. - return_type: Optional[str] = None + return_type: str | None = None # if add-ons may be relying on the legacy hook name, add it here - legacy_hook: Optional[str] = None + legacy_hook: str | None = None # if legacy hook takes no arguments but the new hook does, set this legacy_no_args: bool = False # if the hook replaces a deprecated one, add its name here - replaces: Optional[str] = None + replaces: str | None = None # arguments that the hook being replaced took - replaced_hook_args: Optional[list[str]] = None + replaced_hook_args: list[str] | None = None # docstring to add to hook class - doc: Optional[str] = None + doc: str | None = None def callable(self) -> str: "Convert args into a Callable." @@ -47,7 +48,7 @@ class Hook: types_str = ", ".join(types) return f"Callable[[{types_str}], {self.return_type or 'None'}]" - def arg_names(self, args: Optional[list[str]]) -> list[str]: + def arg_names(self, args: list[str] | None) -> list[str]: names = [] for arg in args or []: if not arg: @@ -126,7 +127,7 @@ class {self.classname()}: for hook in self._hooks: try: hook({", ".join(arg_names)}) - except: + except Exception: # if the hook fails, remove it self._hooks.remove(hook) raise @@ -162,7 +163,7 @@ class {self.classname()}: for filter in self._hooks: try: {arg_names[0]} = filter({", ".join(arg_names)}) - except: + except Exception: # if the hook fails, remove it self._hooks.remove(filter) raise diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..56f5c4aeb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +target-version = ["py39", "py310", "py311", "py312"] +extend-exclude = "qt/bundle" diff --git a/qt/.isort.cfg b/qt/.isort.cfg index 9211d96e8..aa01f87c7 100644 --- a/qt/.isort.cfg +++ b/qt/.isort.cfg @@ -1,9 +1,5 @@ [settings] -ensure_newline_before_comments=true -force_grid_wrap=0 -include_trailing_comma=True +py_version=39 +profile=black known_first_party=anki,aqt -line_length=88 -multi_line_output=3 -skip=aqt/forms,hooks_gen.py -use_parentheses=True +extend_skip=aqt/forms,hooks_gen.py diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index 02b70af1f..1d1faad45 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging import sys +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Union, cast try: import pip_system_certs.wrapt_requests @@ -50,7 +52,6 @@ import os import tempfile import traceback from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Optional, cast import anki.lang from anki._backend import RustBackend @@ -61,6 +62,7 @@ from anki.utils import checksum, is_lin, is_mac from aqt import gui_hooks from aqt.log import setup_logging from aqt.qt import * +from aqt.qt import sip from aqt.utils import TR, tr if TYPE_CHECKING: @@ -92,8 +94,8 @@ appHelpSite = HELP_SITE from aqt.main import AnkiQt # isort:skip from aqt.profiles import ProfileManager, VideoDriver # isort:skip -profiler: Optional[cProfile.Profile] = None -mw: Optional[AnkiQt] = None # set on init +profiler: cProfile.Profile | None = None +mw: AnkiQt | None = None # set on init import aqt.forms @@ -154,7 +156,7 @@ class DialogManager: def allClosed(self) -> bool: return not any(x[1] for x in self._dialogs.values()) - def closeAll(self, onsuccess: Callable[[], None]) -> Optional[bool]: + def closeAll(self, onsuccess: Callable[[], None]) -> bool | None: # can we close immediately? if self.allClosed(): onsuccess() @@ -181,7 +183,7 @@ class DialogManager: return True def register_dialog( - self, name: str, creator: Union[Callable, type], instance: Optional[Any] = None + self, name: str, creator: Callable | type, instance: Any | None = None ) -> None: """Allows add-ons to register a custom dialog to be managed by Anki's dialog manager, which ensures that only one copy of the window is open at once, @@ -219,19 +221,19 @@ dialogs = DialogManager() # A reference to the Qt translator needs to be held to prevent it from # being immediately deallocated. -_qtrans: Optional[QTranslator] = None +_qtrans: QTranslator | None = None def setupLangAndBackend( pm: ProfileManager, app: QApplication, - force: Optional[str] = None, + force: str | None = None, firstTime: bool = False, ) -> RustBackend: global _qtrans try: locale.setlocale(locale.LC_ALL, "") - except: + except Exception: pass # add _ and ngettext globals used by legacy code @@ -288,7 +290,7 @@ def setupLangAndBackend( class NativeEventFilter(QAbstractNativeEventFilter): def nativeEventFilter( self, eventType: Any, message: Any - ) -> tuple[bool, Optional[sip.voidptr]]: + ) -> tuple[bool, sip.voidptr | None]: if eventType == "windows_generic_MSG": import ctypes @@ -563,7 +565,7 @@ def run() -> None: ) -def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiApp]: +def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None: """Start AnkiQt application or reuse an existing instance if one exists. If the function is invoked with exec=False, the AnkiQt will not enter @@ -629,7 +631,7 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp pmLoadResult = pm.setupMeta() Collection.initialize_backend_logging() - except: + except Exception: # will handle below traceback.print_exc() pm = None @@ -671,11 +673,6 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp # we've signaled the primary instance, so we should close return None - setup_logging( - pm.addon_logs(), - level=logging.DEBUG if int(os.getenv("ANKIDEV", "0")) else logging.INFO, - ) - if not pm: if i18n_setup: QMessageBox.critical( @@ -687,6 +684,11 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp QMessageBox.critical(None, "Startup Failed", "Unable to create data folder") return None + setup_logging( + pm.addon_logs(), + level=logging.DEBUG if int(os.getenv("ANKIDEV", "0")) else logging.INFO, + ) + # disable icons on mac; this must be done before window created if is_mac: app.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) @@ -719,7 +721,7 @@ def _run(argv: Optional[list[str]] = None, exec: bool = True) -> Optional[AnkiAp # we must have a usable temp dir try: tempfile.gettempdir() - except: + except Exception: QMessageBox.critical( None, tr.qt_misc_error(), diff --git a/qt/aqt/_macos_helper.py b/qt/aqt/_macos_helper.py index 93c2f9e81..859cb4b0a 100644 --- a/qt/aqt/_macos_helper.py +++ b/qt/aqt/_macos_helper.py @@ -5,8 +5,8 @@ from __future__ import annotations import os import sys +from collections.abc import Callable from ctypes import CDLL, CFUNCTYPE, c_bool, c_char_p -from typing import Callable import aqt import aqt.utils diff --git a/qt/aqt/about.py b/qt/aqt/about.py index 3dc1199ba..1a89771f0 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -2,6 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import platform +from collections.abc import Callable import aqt.forms from anki.lang import without_unicode_isolation @@ -86,6 +87,7 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Christian Krause", "Christian Rusche", "Dave Druelinger", + "David Culley", "David Smith", "Dmitry Mikheev", "Dotan Cohen", @@ -194,6 +196,8 @@ def show(mw: aqt.AnkiQt) -> QDialog: "Marko Sisovic", "Lucas Scharenbroch", "Antoine Q.", + "Ian Samir Yep Manzano", + "Asuka Minato", ) ) diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 34bfb10c5..a8f4f18bb 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Callable, Optional +from collections.abc import Callable import aqt.editor import aqt.forms @@ -54,7 +54,7 @@ class AddCards(QMainWindow): self.setupButtons() self.col.add_image_occlusion_notetype() self.history: list[NoteId] = [] - self._last_added_note: Optional[Note] = None + self._last_added_note: Note | None = None gui_hooks.operation_did_execute.append(self.on_operation_did_execute) restoreGeom(self, "add") gui_hooks.add_cards_did_init(self) @@ -178,7 +178,7 @@ class AddCards(QMainWindow): break # copy non-empty old fields if ( - not old_field_value in copied_field_names + old_field_value not in copied_field_names and old_note.fields[old_idx] ): new_note.fields[new_idx] = old_note.fields[old_idx] @@ -195,7 +195,7 @@ class AddCards(QMainWindow): self, old_note.note_type(), new_note.note_type() ) - def _load_new_note(self, sticky_fields_from: Optional[Note] = None) -> None: + def _load_new_note(self, sticky_fields_from: Note | None = None) -> None: note = self._new_note() if old_note := sticky_fields_from: flds = note.note_type()["flds"] @@ -209,7 +209,7 @@ class AddCards(QMainWindow): self.setAndFocusNote(note) def on_operation_did_execute( - self, changes: OpChanges, handler: Optional[object] + self, changes: OpChanges, handler: object | None ) -> None: if (changes.notetype or changes.deck) and handler is not self.editor: self.on_notetype_change( diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index 70561b9a0..890c10c2a 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -9,13 +9,16 @@ import json import logging import os import re +import sys +import traceback import zipfile from collections import defaultdict +from collections.abc import Callable, Iterable, Sequence from concurrent.futures import Future from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import IO, Any, Callable, Iterable, Sequence, Union +from typing import IO, Any, Union from urllib.parse import parse_qs, urlparse from zipfile import ZipFile @@ -247,7 +250,7 @@ class AddonManager: __import__(addon.dir_name) except AbortAddonImport: pass - except: + except Exception: name = html.escape(addon.human_name()) page = addon.page() if page: @@ -340,7 +343,7 @@ class AddonManager: except json.JSONDecodeError as e: print(f"json error in add-on {module}:\n{e}") return dict() - except: + except Exception: # missing meta file, etc return dict() @@ -643,7 +646,7 @@ class AddonManager: try: with open(path, encoding="utf8") as f: return json.load(f) - except: + except Exception: return None def set_config_help_action(self, module: str, action: Callable[[], str]) -> None: @@ -1083,9 +1086,11 @@ def download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError: data = client.stream_content(resp) - fname = re.match( + match = re.match( "attachment; filename=(.+)", resp.headers["content-disposition"] - ).group(1) + ) + assert match is not None + fname = match.group(1) meta = extract_meta_from_download_url(resp.url) @@ -1113,11 +1118,14 @@ def extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta: urlobj = urlparse(url) query = parse_qs(urlobj.query) + def get_first_element(elements: list[str]) -> int: + return int(elements[0]) + meta = ExtractedDownloadMeta( - mod_time=int(query.get("t")[0]), - min_point_version=int(query.get("minpt")[0]), - max_point_version=int(query.get("maxpt")[0]), - branch_index=int(query.get("bidx")[0]), + mod_time=get_first_element(query["t"]), + min_point_version=get_first_element(query["minpt"]), + max_point_version=get_first_element(query["maxpt"]), + branch_index=get_first_element(query["bidx"]), ) return meta diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index e8dcff326..45924b03c 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -6,7 +6,8 @@ from __future__ import annotations import json import math import re -from typing import Callable, Sequence +from collections.abc import Callable, Sequence +from typing import Any import aqt import aqt.browser diff --git a/qt/aqt/browser/card_info.py b/qt/aqt/browser/card_info.py index ae7f6be55..75403edc0 100644 --- a/qt/aqt/browser/card_info.py +++ b/qt/aqt/browser/card_info.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable import aqt from anki.cards import Card, CardId diff --git a/qt/aqt/browser/find_and_replace.py b/qt/aqt/browser/find_and_replace.py index a68c75dbc..c1d51e43c 100644 --- a/qt/aqt/browser/find_and_replace.py +++ b/qt/aqt/browser/find_and_replace.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence import aqt import aqt.forms diff --git a/qt/aqt/browser/find_duplicates.py b/qt/aqt/browser/find_duplicates.py index 33d0c8c9a..60cd11242 100644 --- a/qt/aqt/browser/find_duplicates.py +++ b/qt/aqt/browser/find_duplicates.py @@ -13,6 +13,7 @@ import aqt.forms from anki.collection import SearchNode from anki.notes import NoteId from aqt.qt import * +from aqt.qt import sip from aqt.webview import AnkiWebViewKind from ..operations import QueryOp diff --git a/qt/aqt/browser/previewer.py b/qt/aqt/browser/previewer.py index 6bd044c35..e35623a6f 100644 --- a/qt/aqt/browser/previewer.py +++ b/qt/aqt/browser/previewer.py @@ -6,7 +6,8 @@ from __future__ import annotations import json import re import time -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import aqt.browser from anki.cards import Card diff --git a/qt/aqt/browser/sidebar/item.py b/qt/aqt/browser/sidebar/item.py index 6a32097da..576d0b455 100644 --- a/qt/aqt/browser/sidebar/item.py +++ b/qt/aqt/browser/sidebar/item.py @@ -2,8 +2,8 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +from collections.abc import Callable, Iterable from enum import Enum, auto -from typing import Callable, Iterable from anki.collection import SearchNode from aqt.theme import ColoredIcon diff --git a/qt/aqt/browser/sidebar/toolbar.py b/qt/aqt/browser/sidebar/toolbar.py index a111fe3e4..78906b580 100644 --- a/qt/aqt/browser/sidebar/toolbar.py +++ b/qt/aqt/browser/sidebar/toolbar.py @@ -3,6 +3,7 @@ from __future__ import annotations +from collections.abc import Callable from enum import Enum, auto import aqt diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index ef03cc91e..6189e925c 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -2,8 +2,9 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +from collections.abc import Callable, Iterable from enum import Enum, auto -from typing import Iterable, cast +from typing import cast import aqt import aqt.browser @@ -43,6 +44,7 @@ from aqt.operations.tag import ( set_tag_collapsed, ) from aqt.qt import * +from aqt.qt import sip from aqt.theme import ColoredIcon, theme_manager from aqt.utils import ( KeyboardModifiersPressed, @@ -147,7 +149,7 @@ class SidebarTreeView(QTreeView): def op_executed( self, changes: OpChanges, handler: object | None, focused: bool ) -> None: - if changes.browser_sidebar and not handler is self: + if changes.browser_sidebar and handler is not self: self._refresh_needed = True if focused: self.refresh_if_needed() @@ -266,7 +268,7 @@ class SidebarTreeView(QTreeView): def update_search( self, - *terms: Union[str, SearchNode], + *terms: str | SearchNode, joiner: SearchJoiner = "AND", ) -> None: """Modify the current search string based on modifier keys, then refresh.""" @@ -524,7 +526,7 @@ class SidebarTreeView(QTreeView): *, root: SidebarItem, name: str, - icon: Union[str, ColoredIcon], + icon: str | ColoredIcon, collapse_key: Config.Bool.V, type: SidebarItemType | None = None, ) -> SidebarItem: diff --git a/qt/aqt/browser/table/__init__.py b/qt/aqt/browser/table/__init__.py index 7be9f1fcb..dc3ed7d5d 100644 --- a/qt/aqt/browser/table/__init__.py +++ b/qt/aqt/browser/table/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import copy import time +from collections.abc import Generator, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Generator, Sequence, Union +from typing import TYPE_CHECKING, Union import aqt import aqt.browser diff --git a/qt/aqt/browser/table/model.py b/qt/aqt/browser/table/model.py index 956c51c19..55f467f53 100644 --- a/qt/aqt/browser/table/model.py +++ b/qt/aqt/browser/table/model.py @@ -3,7 +3,8 @@ from __future__ import annotations import time -from typing import Any, Callable, Sequence +from collections.abc import Callable, Sequence +from typing import Any import aqt import aqt.browser @@ -242,7 +243,7 @@ class DataModel(QAbstractTableModel): self._state = self._state.toggle_state() try: self._search_inner(context) - except: + except Exception: # rollback to prevent inconsistent state self._state = self._state.toggle_state() raise diff --git a/qt/aqt/browser/table/state.py b/qt/aqt/browser/table/state.py index 91f368844..75ee69ccb 100644 --- a/qt/aqt/browser/table/state.py +++ b/qt/aqt/browser/table/state.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import ABC, abstractmethod, abstractproperty -from typing import Sequence, cast +from collections.abc import Sequence +from typing import cast from anki.browser import BrowserConfig from anki.cards import Card, CardId diff --git a/qt/aqt/browser/table/table.py b/qt/aqt/browser/table/table.py index 384a28e67..345386acb 100644 --- a/qt/aqt/browser/table/table.py +++ b/qt/aqt/browser/table/table.py @@ -2,7 +2,8 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations -from typing import Any, Callable, Sequence +from collections.abc import Callable, Sequence +from typing import Any import aqt import aqt.browser diff --git a/qt/aqt/changenotetype.py b/qt/aqt/changenotetype.py index 3f637eabf..0484c2ff8 100644 --- a/qt/aqt/changenotetype.py +++ b/qt/aqt/changenotetype.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence import aqt import aqt.deckconf diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 19fa0fa22..bdd7f02d3 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -5,8 +5,9 @@ from __future__ import annotations import json import re +from collections.abc import Callable from concurrent.futures import Future -from typing import Any, Match, Optional, cast +from typing import Any, Match, cast import aqt import aqt.forms @@ -50,7 +51,7 @@ class CardLayout(QDialog): mw: AnkiQt, note: Note, ord: int = 0, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, fill_empty: bool = False, ) -> None: QDialog.__init__(self, parent or mw, Qt.WindowType.Window) @@ -509,7 +510,7 @@ class CardLayout(QDialog): # Preview ########################################################################## - _previewTimer: Optional[QTimer] = None + _previewTimer: QTimer | None = None def renderPreview(self) -> None: # schedule a preview when timing stops @@ -590,7 +591,7 @@ class CardLayout(QDialog): return res type_filter = r"\[\[type:.+?\]\]" - repl: Union[str, Callable] + repl: str | Callable if type == "q": repl = "" diff --git a/qt/aqt/customstudy.py b/qt/aqt/customstudy.py index d499ae7b3..730f3843b 100644 --- a/qt/aqt/customstudy.py +++ b/qt/aqt/customstudy.py @@ -3,8 +3,6 @@ from __future__ import annotations -from typing import Tuple - import aqt import aqt.forms import aqt.operations @@ -37,12 +35,12 @@ class CustomStudy(QDialog): def fetch_data_and_show(mw: aqt.AnkiQt) -> None: def fetch_data( col: Collection, - ) -> Tuple[DeckId, CustomStudyDefaults]: + ) -> tuple[DeckId, CustomStudyDefaults]: deck_id = mw.col.decks.get_current_id() defaults = col.sched.custom_study_defaults(deck_id) return (deck_id, defaults) - def show_dialog(data: Tuple[DeckId, CustomStudyDefaults]) -> None: + def show_dialog(data: tuple[DeckId, CustomStudyDefaults]) -> None: deck_id, defaults = data CustomStudy(mw=mw, deck_id=deck_id, defaults=defaults) diff --git a/qt/aqt/debug_console.py b/qt/aqt/debug_console.py index a24ade4fc..76fead38b 100644 --- a/qt/aqt/debug_console.py +++ b/qt/aqt/debug_console.py @@ -4,6 +4,8 @@ from __future__ import annotations import os +import sys +from collections.abc import Callable from dataclasses import dataclass from functools import partial from pathlib import Path @@ -292,7 +294,7 @@ class DebugConsole(QDialog): try: # pylint: disable=exec-used exec(text, vars) - except: + except Exception: self._output += traceback.format_exc() self._captureOutput(False) buf = "" diff --git a/qt/aqt/deckchooser.py b/qt/aqt/deckchooser.py index 6b089d8b2..72894ed82 100644 --- a/qt/aqt/deckchooser.py +++ b/qt/aqt/deckchooser.py @@ -3,10 +3,13 @@ from __future__ import annotations +from collections.abc import Callable + from anki.collection import OpChanges from anki.decks import DEFAULT_DECK_ID, DeckId from aqt import AnkiQt, gui_hooks from aqt.qt import * +from aqt.qt import sip from aqt.utils import HelpPage, shortcut, tr diff --git a/qt/aqt/deckconf.py b/qt/aqt/deckconf.py index b28813196..11dddd3ca 100644 --- a/qt/aqt/deckconf.py +++ b/qt/aqt/deckconf.py @@ -173,8 +173,8 @@ class DeckConf(QDialog): # Loading ################################################## - def listToUser(self, l: list[Union[int, float]]) -> str: - def num_to_user(n: Union[int, float]) -> str: + def listToUser(self, l: list[int | float]) -> str: + def num_to_user(n: int | float) -> str: if n == round(n): return str(int(n)) else: @@ -267,7 +267,7 @@ class DeckConf(QDialog): if i == int(i): i = int(i) ret.append(i) - except: + except Exception: # invalid, don't update showWarning(tr.scheduling_steps_must_be_numbers()) return diff --git a/qt/aqt/editcurrent.py b/qt/aqt/editcurrent.py index 48140d789..744bce949 100644 --- a/qt/aqt/editcurrent.py +++ b/qt/aqt/editcurrent.py @@ -1,6 +1,8 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -from typing import Optional +from __future__ import annotations + +from collections.abc import Callable import aqt.editor from anki.collection import OpChanges @@ -37,7 +39,7 @@ class EditCurrent(QMainWindow): self.show() def on_operation_did_execute( - self, changes: OpChanges, handler: Optional[object] + self, changes: OpChanges, handler: object | None ) -> None: if changes.note_text and handler is not self.editor: # reload note diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index b6c28c308..b87f9c62a 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -9,14 +9,16 @@ import html import itertools import json import mimetypes +import os import re import urllib.error import urllib.parse import urllib.request import warnings +from collections.abc import Callable from enum import Enum from random import randrange -from typing import Any, Callable, Match, cast +from typing import Any, Match, cast import bs4 import requests @@ -59,7 +61,7 @@ from aqt.utils import ( ) from aqt.webview import AnkiWebView, AnkiWebViewKind -pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico", "avif") +pics = ("jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif") audio = ( "3gp", "aac", @@ -294,6 +296,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too disables: bool = True, rightside: bool = True, ) -> str: + title_attribute = tip + if icon: if icon.startswith("qrc:/"): iconstr = icon @@ -301,47 +305,35 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too iconstr = self.resourceToData(icon) else: iconstr = f"/_anki/imgs/{icon}.png" - imgelm = f"""""" + image_element = f'' else: - imgelm = "" - if label or not imgelm: - labelelm = label or cmd + image_element = "" + + if not label and icon: + label_element = "" + elif label: + label_element = label else: - labelelm = "" - if id: - idstr = f"id={id}" - else: - idstr = "" - if toggleable: - toggleScript = "toggleEditorButton(this);" - else: - toggleScript = "" - tip = shortcut(tip) - if rightside: - class_ = "linkb" - else: - class_ = "rounded" + label_element = cmd + + title_attribute = shortcut(title_attribute) + cmd_to_toggle_button = "toggleEditorButton(this);" if toggleable else "" + id_attribute_assignment = f"id={id}" if id else "" + class_attribute = "linkb" if rightside else "rounded" if not disables: - class_ += " perm" - return """""".format( - imgelm=imgelm, - cmd=cmd, - tip=tip, - labelelm=labelelm, - id=idstr, - togglesc=toggleScript, - class_=class_, - ) + {image_element} + {label_element} + """ def setupShortcuts(self) -> None: # if a third element is provided, enable shortcut even when no field selected diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py index 41701d372..9ebda65bc 100644 --- a/qt/aqt/errors.py +++ b/qt/aqt/errors.py @@ -3,15 +3,19 @@ from __future__ import annotations +import os import re +import sys import time -from typing import TYPE_CHECKING, Optional, TextIO, cast +import traceback +from typing import TYPE_CHECKING, TextIO, cast from markdown import markdown import aqt from anki.collection import HelpPage from anki.errors import BackendError, Interrupted +from anki.utils import is_win from aqt.addons import AddonManager, AddonMeta from aqt.qt import * from aqt.utils import openHelp, showWarning, supportText, tooltip, tr @@ -169,7 +173,7 @@ class ErrorHandler(QObject): def __init__(self, mw: AnkiQt) -> None: QObject.__init__(self, mw) self.mw = mw - self.timer: Optional[QTimer] = None + self.timer: QTimer | None = None qconnect(self.errorTimer, self._setTimer) self.pool = "" self._oldstderr = sys.stderr diff --git a/qt/aqt/exporting.py b/qt/aqt/exporting.py index 149d486e6..ee41030f7 100644 --- a/qt/aqt/exporting.py +++ b/qt/aqt/exporting.py @@ -7,7 +7,6 @@ import os import re import time from concurrent.futures import Future -from typing import Optional import aqt import aqt.forms @@ -35,7 +34,7 @@ class ExportDialog(QDialog): mw: aqt.main.AnkiQt, did: DeckId | None = None, cids: list[CardId] | None = None, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, ): QDialog.__init__(self, parent or mw, Qt.WindowType.Window) self.mw = mw diff --git a/qt/aqt/fields.py b/qt/aqt/fields.py index 7044e0e85..da81c9ff7 100644 --- a/qt/aqt/fields.py +++ b/qt/aqt/fields.py @@ -3,8 +3,6 @@ from __future__ import annotations -from typing import Optional - import aqt import aqt.forms import aqt.operations @@ -32,7 +30,7 @@ class FieldDialog(QDialog): self, mw: AnkiQt, nt: NotetypeDict, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, open_at: int = 0, ) -> None: QDialog.__init__(self, parent or mw) @@ -62,7 +60,7 @@ class FieldDialog(QDialog): self.form.buttonBox.button(QDialogButtonBox.StandardButton.Save).setAutoDefault( False ) - self.currentIdx: Optional[int] = None + self.currentIdx: int | None = None self.fillFields() self.setupSignals() self.form.fieldList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) @@ -125,8 +123,8 @@ class FieldDialog(QDialog): self.loadField(idx) def _uniqueName( - self, prompt: str, ignoreOrd: Optional[int] = None, old: str = "" - ) -> Optional[str]: + self, prompt: str, ignoreOrd: int | None = None, old: str = "" + ) -> str | None: txt = getOnlyText(prompt, default=old).replace('"', "").strip() if not txt: return None diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 871c07f9b..319ff47cb 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -44,6 +44,9 @@ preferences_language + + lang + @@ -64,6 +67,9 @@ preferences_video_driver + + video_driver + @@ -76,6 +82,19 @@ + + + + + 0 + 0 + + + + preferences_check_for_updates + + + @@ -116,6 +135,9 @@ preferences_style + + styleComboBox + @@ -123,6 +145,9 @@ preferences_theme + + theme + @@ -130,6 +155,9 @@ preferences_user_interface_size + + uiScale + @@ -264,33 +292,25 @@ - 60 + 16777215 16777215 + + preferences_mins + 999 - - - - preferences_mins - - - preferences_timebox_time_limit - - - - - - preferences_hours_past_midnight + + timeLimit @@ -298,10 +318,13 @@ - 60 + 16777215 16777215 + + preferences_hours_past_midnight + 23 @@ -312,6 +335,9 @@ preferences_learn_ahead_limit + + lrnCutoff + @@ -319,17 +345,16 @@ preferences_next_day_starts_at - - - - - - preferences_mins + + dayOffset + + preferences_mins + 9999 @@ -513,6 +538,9 @@ preferences_default_deck + + useCurrent + @@ -553,6 +581,9 @@ preferences_default_search_text + + default_search_text + @@ -705,10 +736,16 @@ preferences_network_timeout + + network_timeout + + + scheduling_seconds + 30 @@ -717,26 +754,6 @@ - - - - scheduling_seconds - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - @@ -853,6 +870,9 @@ preferences_custom_sync_url + + custom_sync_url + @@ -954,6 +974,9 @@ preferences_daily_backups + + daily_backups + @@ -968,6 +991,9 @@ preferences_monthly_backups + + monthly_backups + @@ -975,6 +1001,9 @@ preferences_weekly_backups + + weekly_backups + @@ -982,6 +1011,9 @@ preferences_minutes_between_backups + + minutes_between_backups + diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py index f06d7a47a..a33395f98 100644 --- a/qt/aqt/import_export/exporting.py +++ b/qt/aqt/import_export/exporting.py @@ -7,8 +7,8 @@ import os import re import time from abc import ABC, abstractmethod +from collections.abc import Sequence from dataclasses import dataclass -from typing import Optional, Sequence, Type import aqt.forms import aqt.main @@ -42,7 +42,7 @@ class ExportDialog(QDialog): mw: aqt.main.AnkiQt, did: DeckId | None = None, nids: Sequence[NoteId] | None = None, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, ): QDialog.__init__(self, parent or mw, Qt.WindowType.Window) self.mw = mw @@ -56,7 +56,7 @@ class ExportDialog(QDialog): self.open() def setup(self, did: DeckId | None) -> None: - self.exporter_classes: list[Type[Exporter]] = [ + self.exporter_classes: list[type[Exporter]] = [ ApkgExporter, ColpkgExporter, NoteCsvExporter, diff --git a/qt/aqt/import_export/importing.py b/qt/aqt/import_export/importing.py index 05a37aece..938824035 100644 --- a/qt/aqt/import_export/importing.py +++ b/qt/aqt/import_export/importing.py @@ -3,10 +3,11 @@ from __future__ import annotations +import os import re from abc import ABC, abstractmethod +from collections.abc import Callable from itertools import chain -from typing import Type import aqt.main from anki.collection import Collection, Progress @@ -124,7 +125,7 @@ class JsonImporter(Importer): ImportDialog(mw, JsonFileArgs(path=path)) -IMPORTERS: list[Type[Importer]] = [ +IMPORTERS: list[type[Importer]] = [ ColpkgImporter, ApkgImporter, MnemosyneImporter, diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index 8ba173f91..b00d0b69b 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -1,11 +1,15 @@ # 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 os import re +import sys import traceback import zipfile +from collections.abc import Callable from concurrent.futures import Future -from typing import Any, Optional +from typing import Any import anki.importing as importing import aqt.deckchooser @@ -54,7 +58,7 @@ class ChangeMap(QDialog): self.frm.fields.setCurrentRow(n) else: self.frm.fields.setCurrentRow(n + 1) - self.field: Optional[str] = None + self.field: str | None = None def getField(self) -> str: self.exec() @@ -230,13 +234,13 @@ class ImportDialog(QDialog): self.frm.mappingArea.setWidget(self.frame) self.mapbox = QVBoxLayout(self.frame) self.mapbox.setContentsMargins(0, 0, 0, 0) - self.mapwidget: Optional[QWidget] = None + self.mapwidget: QWidget | None = None def hideMapping(self) -> None: self.frm.mappingGroup.hide() def showMapping( - self, keepMapping: bool = False, hook: Optional[Callable] = None + self, keepMapping: bool = False, hook: Callable | None = None ) -> None: if hook: hook() diff --git a/qt/aqt/main.py b/qt/aqt/main.py index e7159977d..9508cc10d 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -8,10 +8,13 @@ import gc import os import re import signal +import sys +import traceback import weakref from argparse import Namespace +from collections.abc import Callable, Sequence from concurrent.futures import Future -from typing import Any, Literal, Sequence, TypeVar, cast +from typing import Any, Literal, TypeVar, cast import anki import anki.cards @@ -199,7 +202,7 @@ class AnkiQt(QMainWindow): self.setupUI() self.setupAddons(args) self.finish_ui_setup() - except: + except Exception: showInfo(tr.qt_misc_error_during_startup(val=traceback.format_exc())) sys.exit(1) # must call this after ui set up @@ -350,7 +353,7 @@ class AnkiQt(QMainWindow): f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) - except: + except Exception: idx = 0 f.profiles.setCurrentRow(idx) @@ -680,7 +683,7 @@ class AnkiQt(QMainWindow): self.maybeOptimize() if not dev_mode: corrupt = self.col.db.scalar("pragma quick_check") != "ok" - except: + except Exception: corrupt = True try: @@ -692,7 +695,7 @@ class AnkiQt(QMainWindow): force=False, wait_for_completion=False, ) - except: + except Exception: print("backup on close failed") self.col.close(downgrade=False) except Exception as e: @@ -1428,7 +1431,8 @@ title="{}" {}>{}""".format( def setup_auto_update(self, _log: list[DownloadLogEntry]) -> None: from aqt.update import check_for_update - check_for_update() + if aqt.mw.pm.check_for_updates(): + check_for_update() # Timers ########################################################################## diff --git a/qt/aqt/mediacheck.py b/qt/aqt/mediacheck.py index d9691c500..ec47550d8 100644 --- a/qt/aqt/mediacheck.py +++ b/qt/aqt/mediacheck.py @@ -5,8 +5,9 @@ from __future__ import annotations import itertools import time +from collections.abc import Iterable, Sequence from concurrent.futures import Future -from typing import Iterable, Sequence, TypeVar +from typing import TypeVar import aqt import aqt.progress diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index b3ab7f042..a613a4795 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -11,10 +11,10 @@ import re import sys import threading import traceback +from collections.abc import Callable from dataclasses import dataclass from errno import EPROTOTYPE from http import HTTPStatus -from typing import Callable import flask import flask_cors diff --git a/qt/aqt/mediasync.py b/qt/aqt/mediasync.py index 4fafd6b13..96054832c 100644 --- a/qt/aqt/mediasync.py +++ b/qt/aqt/mediasync.py @@ -4,9 +4,10 @@ from __future__ import annotations import time +from collections.abc import Callable from concurrent.futures import Future from datetime import datetime -from typing import Any, Callable +from typing import Any import aqt import aqt.forms diff --git a/qt/aqt/modelchooser.py b/qt/aqt/modelchooser.py index f886c65af..cdc75cd06 100644 --- a/qt/aqt/modelchooser.py +++ b/qt/aqt/modelchooser.py @@ -1,7 +1,8 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations -from typing import Optional +from collections.abc import Callable from aqt import AnkiQt, gui_hooks from aqt.qt import * @@ -16,7 +17,7 @@ class ModelChooser(QHBoxLayout): mw: AnkiQt, widget: QWidget, label: bool = True, - on_activated: Optional[Callable[[], None]] = None, + on_activated: Callable[[], None] | None = None, ) -> None: """If provided, on_activated() will be called when the button is clicked, and the caller can call .onModelChange() to pull up the dialog when they diff --git a/qt/aqt/models.py b/qt/aqt/models.py index 2ad890616..8fe055cfc 100644 --- a/qt/aqt/models.py +++ b/qt/aqt/models.py @@ -3,9 +3,10 @@ from __future__ import annotations +from collections.abc import Callable, Sequence from concurrent.futures import Future from operator import itemgetter -from typing import Any, Optional, Sequence +from typing import Any import aqt.clayout from anki import stdmodels @@ -40,9 +41,9 @@ class Models(QDialog): def __init__( self, mw: AnkiQt, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, fromMain: bool = False, - selected_notetype_id: Optional[NotetypeId] = None, + selected_notetype_id: NotetypeId | None = None, ): self.mw = mw parent = parent or mw @@ -61,6 +62,13 @@ class Models(QDialog): self.models: Sequence[NotetypeNameIdUseCount] = [] self.setupModels() restoreGeom(self, "models") + + self.setWindowFlags( + self.windowFlags() + | Qt.WindowType.WindowMaximizeButtonHint + | Qt.WindowType.WindowMinimizeButtonHint + ) + self.show() # Models @@ -231,13 +239,13 @@ class Models(QDialog): class AddModel(QDialog): - model: Optional[NotetypeDict] + model: NotetypeDict | None def __init__( self, mw: AnkiQt, on_success: Callable[[NotetypeDict], None], - parent: Optional[QWidget] = None, + parent: QWidget | None = None, ) -> None: self.parent_ = parent or mw self.mw = mw @@ -249,9 +257,7 @@ class AddModel(QDialog): self.setWindowModality(Qt.WindowModality.ApplicationModal) disable_help_button(self) # standard models - self.notetypes: list[ - Union[NotetypeDict, Callable[[Collection], NotetypeDict]] - ] = [] + self.notetypes: list[NotetypeDict | Callable[[Collection], NotetypeDict]] = [] for name, func in stdmodels.get_stock_notetypes(self.col): item = QListWidgetItem(tr.notetypes_add(val=name)) self.dialog.models.addItem(item) diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index e3def463b..a3156fa79 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -25,6 +25,7 @@ # ------------------------------------------------------------------------------ # pylint: disable=raise-missing-from +from __future__ import annotations import inspect import json @@ -38,7 +39,6 @@ import threading import time from queue import Empty, Full, Queue from shutil import which -from typing import Optional from anki.utils import is_win @@ -77,7 +77,7 @@ class MPVBase: """ executable = which("mpv") - popenEnv: Optional[dict[str, str]] = None + popenEnv: dict[str, str] | None = None default_argv = [ "--idle", diff --git a/qt/aqt/notetypechooser.py b/qt/aqt/notetypechooser.py index e8d614ac3..395af193b 100644 --- a/qt/aqt/notetypechooser.py +++ b/qt/aqt/notetypechooser.py @@ -3,6 +3,8 @@ from __future__ import annotations +from collections.abc import Callable + from anki.collection import OpChanges from anki.models import NotetypeId from aqt import AnkiQt, gui_hooks diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py index 865c8a03f..937f5b9ed 100644 --- a/qt/aqt/operations/__init__.py +++ b/qt/aqt/operations/__init__.py @@ -3,8 +3,9 @@ from __future__ import annotations +from collections.abc import Callable from concurrent.futures._base import Future -from typing import Any, Callable, Generic, Protocol, TypeVar, Union +from typing import Any, Generic, Protocol, TypeVar, Union import aqt import aqt.gui_hooks diff --git a/qt/aqt/operations/card.py b/qt/aqt/operations/card.py index 6071336ee..e8b4b23a6 100644 --- a/qt/aqt/operations/card.py +++ b/qt/aqt/operations/card.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from anki.cards import CardId from anki.collection import OpChangesWithCount diff --git a/qt/aqt/operations/deck.py b/qt/aqt/operations/deck.py index bddf07fc2..327f9ab52 100644 --- a/qt/aqt/operations/deck.py +++ b/qt/aqt/operations/deck.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId from anki.decks import DeckCollapseScope, DeckDict, DeckId, UpdateDeckConfigs diff --git a/qt/aqt/operations/note.py b/qt/aqt/operations/note.py index 42b2bc950..4a27c1e21 100644 --- a/qt/aqt/operations/note.py +++ b/qt/aqt/operations/note.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount from anki.decks import DeckId diff --git a/qt/aqt/operations/scheduling.py b/qt/aqt/operations/scheduling.py index c30fdecb8..61d7cc9d4 100644 --- a/qt/aqt/operations/scheduling.py +++ b/qt/aqt/operations/scheduling.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence import aqt import aqt.forms diff --git a/qt/aqt/operations/tag.py b/qt/aqt/operations/tag.py index bf3d25945..2ac4504d0 100644 --- a/qt/aqt/operations/tag.py +++ b/qt/aqt/operations/tag.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from anki.collection import OpChanges, OpChangesWithCount from anki.notes import NoteId diff --git a/qt/aqt/overview.py b/qt/aqt/overview.py index d5ca1945e..20d276dbb 100644 --- a/qt/aqt/overview.py +++ b/qt/aqt/overview.py @@ -2,8 +2,9 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import Any import aqt import aqt.operations diff --git a/qt/aqt/package.py b/qt/aqt/package.py index ace75ca23..3f89366a2 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -44,7 +44,7 @@ def _patch_pkgutil() -> None: reader = module.__loader__.get_resource_reader(package) # type: ignore[attr-defined] with reader.open_resource(resource) as f: return f.read() - except: + except Exception: return None pkgutil.get_data = get_data_custom diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index b0af7cce2..d0f7b07f4 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -5,12 +5,14 @@ from __future__ import annotations import functools import re +from collections.abc import Callable import anki.lang import aqt import aqt.forms import aqt.operations from anki.collection import OpChanges +from anki.utils import is_mac from aqt import AnkiQt from aqt.operations.collection import set_preferences from aqt.profiles import VideoDriver @@ -36,6 +38,13 @@ class Preferences(QDialog): self.prof = self.mw.pm.profile self.form = aqt.forms.preferences.Ui_Preferences() self.form.setupUi(self) + for spinbox in ( + self.form.lrnCutoff, + self.form.dayOffset, + self.form.timeLimit, + self.form.network_timeout, + ): + spinbox.setSuffix(f" {spinbox.suffix()}") disable_help_button(self) self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help).setAutoDefault( False @@ -198,6 +207,9 @@ class Preferences(QDialog): self.form.custom_sync_url.setText(self.mw.pm.custom_sync_url()) self.form.network_timeout.setValue(self.mw.pm.network_timeout()) + self.form.check_for_updates.setChecked(self.mw.pm.check_for_updates()) + qconnect(self.form.check_for_updates.stateChanged, self.mw.pm.set_update_check) + self.update_login_status() qconnect(self.form.syncLogout.clicked, self.sync_logout) qconnect(self.form.syncLogin.clicked, self.sync_login) @@ -376,7 +388,7 @@ class Preferences(QDialog): lang = lang.replace("-", "_") try: return codes.index(lang) - except: + except Exception: return codes.index("en_US") def on_language_index_changed(self, idx: int) -> None: diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 185fe4fd3..ceecf50f2 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -4,13 +4,14 @@ from __future__ import annotations import io +import os import pickle import random import shutil import traceback from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any import anki.lang import aqt.forms @@ -23,6 +24,7 @@ from anki.sync import SyncAuth from anki.utils import int_time, int_version, is_mac, is_win from aqt import appHelpSite, gui_hooks from aqt.qt import * +from aqt.qt import sip from aqt.theme import Theme, WidgetStyle, theme_manager from aqt.toolbar import HideMode from aqt.utils import disable_help_button, send_to_trash, showWarning, tr @@ -216,7 +218,7 @@ class ProfileManager: self.name = name try: self.profile = self._unpickle(data) - except: + except Exception: print(traceback.format_exc()) QMessageBox.warning( None, @@ -285,7 +287,7 @@ class ProfileManager: showWarning(tr.profiles_anki_could_not_rename_your_profile()) else: raise - except: + except BaseException: self.db.rollback() raise else: @@ -386,7 +388,7 @@ class ProfileManager: if self.db: try: self.db.close() - except: + except Exception: pass for suffix in ("", "-journal"): fpath = path + suffix @@ -406,7 +408,7 @@ create table if not exists profiles data = self.db.scalar( "select cast(data as blob) from profiles where name = '_global'" ) - except: + except Exception: traceback.print_stack() if result.loadError: # already failed, prevent infinite loop @@ -420,7 +422,7 @@ create table if not exists profiles try: self.meta = self._unpickle(data) return result - except: + except Exception: traceback.print_stack() print("resetting corrupt _global") result.loadError = True @@ -544,7 +546,7 @@ create table if not exists profiles def set_spacebar_rates_card(self, on: bool) -> None: self.meta["spacebar_rates_card"] = on - def get_answer_key(self, ease: int) -> Optional[str]: + def get_answer_key(self, ease: int) -> str | None: return self.meta.setdefault("answer_keys", self.default_answer_keys).get(ease) def set_answer_key(self, ease: int, key: str): @@ -650,6 +652,12 @@ create table if not exists profiles def set_host_number(self, val: int | None) -> None: self.profile["hostNum"] = val or 0 + def check_for_updates(self) -> bool: + return self.meta.get("check_for_updates", True) + + def set_update_check(self, on: bool) -> None: + self.meta["check_for_updates"] = on + def media_syncing_enabled(self) -> bool: return self.profile.get("syncMedia", True) diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index 692268a79..dd8fb26e1 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from collections.abc import Callable from concurrent.futures import Future from dataclasses import dataclass @@ -10,6 +11,7 @@ import aqt.forms from anki._legacy import print_deprecation_warning from anki.collection import Progress from aqt.qt import * +from aqt.qt import sip from aqt.utils import disable_help_button, tr # Progress info diff --git a/qt/aqt/qt/__init__.py b/qt/aqt/qt/__init__.py index 2d4762f5d..ea1b4bd46 100644 --- a/qt/aqt/qt/__init__.py +++ b/qt/aqt/qt/__init__.py @@ -3,15 +3,17 @@ # make sure not to optimize imports on this file # pylint: disable=unused-import +from __future__ import annotations import os import sys import traceback -from typing import Callable, TypeVar, Union +from collections.abc import Callable +from typing import TypeVar, Union try: import PyQt6 -except: +except Exception: from .qt5 import * # type: ignore else: if os.getenv("ENABLE_QT5_COMPAT"): @@ -54,9 +56,7 @@ if qtmajor < 5 or (qtmajor == 5 and qtminor < 14): raise Exception("Anki does not support your Qt version.") -def qconnect( - signal: Union[Callable, pyqtSignal, pyqtBoundSignal], func: Callable -) -> None: +def qconnect(signal: Callable | pyqtSignal | pyqtBoundSignal, func: Callable) -> None: """Helper to work around type checking not working with signal.connect(func).""" signal.connect(func) # type: ignore diff --git a/qt/aqt/qt/qt5_audio.py b/qt/aqt/qt/qt5_audio.py index ffab7477e..cc8426a6e 100644 --- a/qt/aqt/qt/qt5_audio.py +++ b/qt/aqt/qt/qt5_audio.py @@ -8,6 +8,7 @@ PyQt5-only audio code """ import wave +from collections.abc import Callable from concurrent.futures import Future from typing import cast diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 70afc54cf..680d323b1 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -7,9 +7,10 @@ import functools import json import random import re +from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Literal, Match, Sequence, cast +from typing import Any, Literal, Match, Union, cast import aqt import aqt.browser @@ -26,6 +27,7 @@ from anki.scheduler.v3 import ( ) from anki.tags import MARKED_TAG from anki.types import assert_exhaustive +from anki.utils import is_mac from aqt import AnkiQt, gui_hooks from aqt.browser.card_info import PreviousReviewerCardInfo, ReviewerCardInfo from aqt.deckoptions import confirm_deck_then_display_options @@ -568,7 +570,7 @@ class Reviewer: def korean_shortcuts( self, - ) -> Sequence[Union[tuple[str, Callable], tuple[Qt.Key, Callable]]]: + ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: return [ ("ㄷ", self.mw.onEditCurrent), ("ㅡ", self.showContextMenu), @@ -588,7 +590,7 @@ class Reviewer: def _shortcutKeys( self, - ) -> Sequence[Union[tuple[str, Callable], tuple[Qt.Key, Callable]]]: + ) -> Sequence[tuple[str, Callable] | tuple[Qt.Key, Callable]]: return [ ("e", self.mw.onEditCurrent), (" ", self.onEnterKey), @@ -839,7 +841,7 @@ timerStopped = false; if not self.mw.col.conf["dueCounts"]: return "" - counts: list[Union[int, str]] + counts: list[int | str] idx, counts_ = self._v3.counts() counts = cast(list[Union[int, str]], counts_) counts[idx] = f"{counts[idx]}" diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 7c144aca4..20b608e10 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -9,12 +9,14 @@ import re import subprocess import sys import time +import traceback import wave from abc import ABC, abstractmethod +from collections.abc import Callable from concurrent.futures import Future from operator import itemgetter from pathlib import Path -from typing import Any, Callable, cast +from typing import Any, cast from markdown import markdown diff --git a/qt/aqt/stats.py b/qt/aqt/stats.py index 388f2d89e..206205349 100644 --- a/qt/aqt/stats.py +++ b/qt/aqt/stats.py @@ -3,12 +3,14 @@ from __future__ import annotations import time +from collections.abc import Callable from typing import Any import aqt import aqt.forms import aqt.main from anki.decks import DeckId +from anki.utils import is_mac from aqt import gui_hooks from aqt.operations.deck import set_current_deck from aqt.qt import * diff --git a/qt/aqt/studydeck.py b/qt/aqt/studydeck.py index a1c99c929..537c7d151 100644 --- a/qt/aqt/studydeck.py +++ b/qt/aqt/studydeck.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable import aqt import aqt.forms diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py index ad1801c91..1366ee669 100644 --- a/qt/aqt/sync.py +++ b/qt/aqt/sync.py @@ -4,8 +4,8 @@ from __future__ import annotations import os +from collections.abc import Callable from concurrent.futures import Future -from typing import Callable import aqt import aqt.main @@ -29,6 +29,7 @@ from aqt.qt import ( from aqt.utils import ( ask_user_dialog, disable_help_button, + show_warning, showText, showWarning, tooltip, @@ -70,7 +71,7 @@ def handle_sync_error(mw: aqt.main.AnkiQt, err: Exception) -> None: elif isinstance(err, Interrupted): # no message to show return - showWarning(str(err)) + show_warning(str(err)) def on_normal_sync_timer(mw: aqt.main.AnkiQt) -> None: @@ -355,12 +356,14 @@ def get_id_and_pass_from_user( user = QLineEdit() user.setText(username) g.addWidget(user, 0, 1) + l1.setBuddy(user) l2 = QLabel(tr.sync_password_label()) g.addWidget(l2, 1, 0) passwd = QLineEdit() passwd.setText(password) passwd.setEchoMode(QLineEdit.EchoMode.Password) g.addWidget(passwd, 1, 1) + l2.setBuddy(passwd) vbox.addLayout(g) bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) # type: ignore bb.button(QDialogButtonBox.StandardButton.Ok).setAutoDefault(True) diff --git a/qt/aqt/tagedit.py b/qt/aqt/tagedit.py index 90cbf56ed..5c9686210 100644 --- a/qt/aqt/tagedit.py +++ b/qt/aqt/tagedit.py @@ -4,11 +4,12 @@ from __future__ import annotations import re -from typing import Iterable +from collections.abc import Iterable from anki.collection import Collection from aqt import gui_hooks from aqt.qt import * +from aqt.qt import sip class TagEdit(QLineEdit): diff --git a/qt/aqt/taglimit.py b/qt/aqt/taglimit.py index 5cc172a66..4132deafa 100644 --- a/qt/aqt/taglimit.py +++ b/qt/aqt/taglimit.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Callable, Sequence import aqt import aqt.customstudy diff --git a/qt/aqt/taskman.py b/qt/aqt/taskman.py index 0acbcba6c..8e38d0f97 100644 --- a/qt/aqt/taskman.py +++ b/qt/aqt/taskman.py @@ -9,10 +9,12 @@ See QueryOp() and CollectionOp() for higher-level routines. from __future__ import annotations +import traceback +from collections.abc import Callable from concurrent.futures import Future from concurrent.futures.thread import ThreadPoolExecutor from threading import Lock, current_thread, main_thread -from typing import Any, Callable +from typing import Any import aqt from anki.collection import Progress diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py index 6acd5463d..735f720c4 100644 --- a/qt/aqt/theme.py +++ b/qt/aqt/theme.py @@ -7,8 +7,8 @@ import enum import os import re import subprocess +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, List, Tuple import anki.lang import aqt @@ -386,7 +386,7 @@ def get_linux_dark_mode() -> bool: return dbus_response[-1] == PREFER_DARK - dark_mode_detection_strategies: List[Tuple[str, Callable[[str], bool]]] = [ + dark_mode_detection_strategies: list[tuple[str, Callable[[str], bool]]] = [ ( "dbus-send --session --print-reply=literal --reply-timeout=1000 " "--dest=org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop " diff --git a/qt/aqt/toolbar.py b/qt/aqt/toolbar.py index 34b7fa2d3..7313f2a39 100644 --- a/qt/aqt/toolbar.py +++ b/qt/aqt/toolbar.py @@ -4,7 +4,8 @@ from __future__ import annotations import enum import re -from typing import Any, Callable, Optional, cast +from collections.abc import Callable +from typing import Any, cast import aqt from anki.sync import SyncStatus @@ -86,7 +87,7 @@ class TopWebView(ToolbarWebView): self.show() - def _onHeight(self, qvar: Optional[int]) -> None: + def _onHeight(self, qvar: int | None) -> None: super()._onHeight(qvar) if qvar: self.web_height = int(qvar) diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index f80bcf6bd..cd2884795 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -504,13 +504,13 @@ if is_win: def _voice_to_objects(self, voice: Any) -> list[WindowsVoice]: try: langs = voice.GetAttribute("language") - except: + except Exception: # no associated language; ignore return [] langs = lcid_hex_str_to_lang_codes(langs) try: name = voice.GetAttribute("name") - except: + except Exception: # some voices may not have a name name = "unknown" name = self._tidy_name(name) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index d89c9fbf6..98e4cee10 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -9,9 +9,10 @@ import re import shutil import subprocess import sys +from collections.abc import Callable, Sequence from functools import partial, wraps from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Literal, Sequence, Union +from typing import TYPE_CHECKING, Any, Literal, Union from send2trash import send2trash diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 71cb585bc..890c4dc88 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -5,10 +5,12 @@ from __future__ import annotations import dataclasses import json +import os import re import sys +from collections.abc import Callable, Sequence from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, cast +from typing import TYPE_CHECKING, Any, cast import anki import anki.lang @@ -17,6 +19,7 @@ from anki.lang import is_rtl from anki.utils import hmr_mode, is_lin, is_mac, is_win from aqt import colors, gui_hooks from aqt.qt import * +from aqt.qt import sip from aqt.theme import theme_manager from aqt.utils import askUser, is_gesture_or_zoom_event, openLink, showInfo, tr @@ -524,10 +527,10 @@ html {{ {font} }} def stdHtml( self, body: str, - css: Optional[list[str]] = None, - js: Optional[list[str]] = None, + css: list[str] | None = None, + js: list[str] | None = None, head: str = "", - context: Optional[Any] = None, + context: Any | None = None, default_css: bool = True, ) -> None: css = (["css/webview.css"] if default_css else []) + ( @@ -705,7 +708,7 @@ html {{ {font} }} def adjustHeightToFit(self) -> None: self.evalWithCallback("document.documentElement.offsetHeight", self._onHeight) - def _onHeight(self, qvar: Optional[int]) -> None: + def _onHeight(self, qvar: int | None) -> None: from aqt import mw if qvar is None: @@ -842,5 +845,5 @@ html {{ {font} }} ) @deprecated(info="use theme_manager.qcolor() instead") - def get_window_bg_color(self, night_mode: Optional[bool] = None) -> QColor: + def get_window_bg_color(self, night_mode: bool | None = None) -> QColor: return theme_manager.qcolor(colors.CANVAS) diff --git a/qt/tools/extract_sass_vars.py b/qt/tools/extract_sass_vars.py index bc2422596..c8da77ce1 100644 --- a/qt/tools/extract_sass_vars.py +++ b/qt/tools/extract_sass_vars.py @@ -38,7 +38,7 @@ for line in re.split(r"[;\{\}]|\*\/", data): if not m: if ( line != "}" - and not ":root" in line + and ":root" not in line and "Copyright" not in line and "License" not in line and "color-scheme" not in line @@ -55,13 +55,13 @@ for line in re.split(r"[;\{\}]|\*\/", data): # remove trailing ms from time props val = re.sub(r"^(\d+)ms$", r"\1", val) - if not var in props: + if var not in props: props.setdefault(var, {})["comment"] = comment props[var]["light"] = val else: props[var]["dark"] = val else: - if not var in colors: + if var not in colors: colors.setdefault(var, {})["comment"] = comment colors[var]["light"] = val else: @@ -80,7 +80,7 @@ with open(colors_py, "w") as buf: buf.write("# This file was automatically generated from _root-vars.scss\n") for color, val in colors.items(): - if not "dark" in val: + if "dark" not in val: val["dark"] = val["light"] buf.write(re.sub(r"\"\n", '",\n', f"{color} = {json.dumps(val, indent=4)}\n")) @@ -91,7 +91,7 @@ with open(props_py, "w") as buf: buf.write("# This file was automatically generated from _root-vars.scss\n") for prop, val in props.items(): - if not "dark" in val: + if "dark" not in val: val["dark"] = val["light"] buf.write(re.sub(r"\"\n", '",\n', f"{prop} = {json.dumps(val, indent=4)}\n")) diff --git a/rslib/proto/python.rs b/rslib/proto/python.rs index db63d612a..b20b850a4 100644 --- a/rslib/proto/python.rs +++ b/rslib/proto/python.rs @@ -97,6 +97,7 @@ fn format_comments(comments: &Option) -> String { /// - it has a single field /// - its name ends in Request /// - it has any optional fields +/// /// ...then destructuring will be skipped, and the method will take the input /// message directly. Returns (params_line, assignment_lines) fn maybe_destructured_input(input: &MessageDescriptor) -> (String, String) { diff --git a/rslib/proto_gen/src/lib.rs b/rslib/proto_gen/src/lib.rs index f27a7828d..f32505236 100644 --- a/rslib/proto_gen/src/lib.rs +++ b/rslib/proto_gen/src/lib.rs @@ -29,12 +29,12 @@ use walkdir::WalkDir; /// expected to exist (but may be empty). /// /// - If a method is listed in BackendExampleService and not in ExampleService, -/// that method is only available with a Backend. +/// that method is only available with a Backend. /// - If a method is listed in both services, you can provide separate -/// implementations for each of the traits. +/// implementations for each of the traits. /// - If a method is listed only in ExampleService, a forwarding method on -/// Backend is automatically implemented. This bypasses the trait and implements -/// directly on Backend. +/// Backend is automatically implemented. This bypasses the trait and +/// implements directly on Backend. /// /// It's important that service and method indices are the same for /// client-generated code, so the client code should use the .index fields diff --git a/rslib/src/cloze.rs b/rslib/src/cloze.rs index 5aab2e83f..1fb97ed85 100644 --- a/rslib/src/cloze.rs +++ b/rslib/src/cloze.rs @@ -158,11 +158,15 @@ fn parse_text_with_clozes(text: &str) -> Vec> { let mut output = vec![]; for token in tokenize(text) { match token { - Token::OpenCloze(ordinal) => open_clozes.push(ExtractedCloze { - ordinal, - nodes: Vec::with_capacity(1), // common case - hint: None, - }), + Token::OpenCloze(ordinal) => { + if open_clozes.len() < 3 { + open_clozes.push(ExtractedCloze { + ordinal, + nodes: Vec::with_capacity(1), // common case + hint: None, + }) + } + } Token::Text(mut text) => { if let Some(cloze) = open_clozes.last_mut() { // extract hint if found diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 38197bca6..72d07a632 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -159,12 +159,19 @@ impl Collection { // add/update provided configs for conf in &mut req.configs { let weight_len = conf.inner.fsrs_weights.len(); - if weight_len == 17 { + if weight_len == 19 { + for i in 0..19 { + if !conf.inner.fsrs_weights[i].is_finite() { + return Err(AnkiError::FsrsWeightsInvalid); + } + } + } else if weight_len == 17 { for i in 0..17 { if !conf.inner.fsrs_weights[i].is_finite() { return Err(AnkiError::FsrsWeightsInvalid); } } + conf.inner.fsrs_weights.extend_from_slice(&[0.0, 0.0]) } else if weight_len != 0 { return Err(AnkiError::FsrsWeightsInvalid); } diff --git a/rslib/src/image_occlusion/imagedata.rs b/rslib/src/image_occlusion/imagedata.rs index f55e8f0d8..60628c147 100644 --- a/rslib/src/image_occlusion/imagedata.rs +++ b/rslib/src/image_occlusion/imagedata.rs @@ -153,9 +153,7 @@ impl Collection { fn is_image_file(&mut self, path: &PathBuf) -> Result { let file_path = Path::new(&path); - let supported_extensions = [ - "jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico", "avif", - ]; + let supported_extensions = ["jpg", "jpeg", "png", "gif", "svg", "webp", "ico", "avif"]; if file_path.exists() { let meta = metadata(file_path)?; diff --git a/rslib/src/import_export/package/apkg/tests.rs b/rslib/src/import_export/package/apkg/tests.rs index 9c0e15eca..af303a609 100644 --- a/rslib/src/import_export/package/apkg/tests.rs +++ b/rslib/src/import_export/package/apkg/tests.rs @@ -160,10 +160,7 @@ impl Collection { (SAMPLE_JS, JS_DATA), ] { // data should have been copied correctly - assert_eq!( - read_file(&self.media_folder.join(fname)).unwrap(), - orig_data - ); + assert_eq!(read_file(self.media_folder.join(fname)).unwrap(), orig_data); // and checksums in media db should be valid assert_eq!(*csums.get(fname).unwrap(), sha1_of_data(orig_data)); } diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 4fe4c5c4a..ed8560a62 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -374,7 +374,7 @@ mod tests { item.starting_state.map(Into::into), Some(FsrsMemoryState { stability: 99.999954, - difficulty: 5.899495, + difficulty: 5.6932373, }), ); let mut card = Card { @@ -385,8 +385,8 @@ mod tests { assert_int_eq( card.memory_state, Some(FsrsMemoryState { - stability: 248.64981, - difficulty: 5.872157, + stability: 248.64305, + difficulty: 5.7909784, }), ); // but if there's only a single revlog entry, we'll fall back on current card @@ -411,7 +411,7 @@ mod tests { card.memory_state, Some(FsrsMemoryState { stability: 122.99994, - difficulty: 7.5038733, + difficulty: 7.334526, }), ); Ok(()) diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index d0c271afa..60506c233 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -2,14 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::scheduler::ComputeOptimalRetentionRequest; -use anki_proto::scheduler::OptimalRetentionParameters; +use fsrs::extract_simulator_config; use fsrs::SimulatorConfig; use fsrs::FSRS; -use itertools::Itertools; use crate::prelude::*; use crate::revlog::RevlogEntry; -use crate::revlog::RevlogReviewKind; use crate::search::SortMode; #[derive(Default, Clone, Copy, Debug)] @@ -42,23 +40,17 @@ impl Collection { &SimulatorConfig { deck_size, learn_span: req.days_to_simulate as usize, - max_cost_perday: f64::MAX, - max_ivl: req.max_interval as f64, - recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy], - forget_cost: p.forget_secs, - learn_cost: p.learn_secs, - first_rating_prob: [ - p.first_rating_probability_again, - p.first_rating_probability_hard, - p.first_rating_probability_good, - p.first_rating_probability_easy, - ], - review_rating_prob: [ - p.review_rating_probability_hard, - p.review_rating_probability_good, - p.review_rating_probability_easy, - ], - loss_aversion: req.loss_aversion, + max_cost_perday: f32::MAX, + max_ivl: req.max_interval as f32, + learn_costs: p.learn_costs, + review_costs: p.review_costs, + first_rating_prob: p.first_rating_prob, + review_rating_prob: p.review_rating_prob, + first_rating_offsets: p.first_rating_offsets, + first_session_lens: p.first_session_lens, + forget_rating_offset: p.forget_rating_offset, + forget_session_len: p.forget_session_len, + loss_aversion: req.loss_aversion as f32, learn_limit, review_limit: usize::MAX, }, @@ -71,173 +63,44 @@ impl Collection { .is_ok() }, )? - .clamp(0.75, 0.95) as f32) + .clamp(0.75, 0.95)) } pub fn get_optimal_retention_parameters( &mut self, revlogs: Vec, - ) -> Result { - let mut first_rating_count = revlogs - .iter() - .group_by(|r| r.cid) - .into_iter() - .map(|(_cid, group)| { - group - .into_iter() - .find(|r| r.review_kind == RevlogReviewKind::Learning && r.button_chosen >= 1) - }) - .filter(|r| r.is_some()) - .counts_by(|r| r.unwrap().button_chosen); - for button_chosen in 1..=4 { - first_rating_count.entry(button_chosen).or_insert(0); - } - let total_first = first_rating_count.values().sum::() as f64; - let weight = total_first / (50.0 + total_first); - const DEFAULT_FIRST_RATING_PROB: [f64; 4] = [0.256, 0.084, 0.483, 0.177]; - let first_rating_prob = if total_first > 0.0 { - let mut arr = DEFAULT_FIRST_RATING_PROB; - first_rating_count - .iter() - .for_each(|(&button_chosen, &count)| { - let index = button_chosen as usize - 1; - arr[index] = (count as f64 / total_first) * weight - + DEFAULT_FIRST_RATING_PROB[index] * (1.0 - weight); - }); - arr - } else { - DEFAULT_FIRST_RATING_PROB - }; - - let mut review_rating_count = revlogs - .iter() - .filter(|r| r.review_kind == RevlogReviewKind::Review && r.button_chosen != 1) - .counts_by(|r| r.button_chosen); - for button_chosen in 2..=4 { - review_rating_count.entry(button_chosen).or_insert(0); - } - let total_reviews = review_rating_count.values().sum::() as f64; - let weight = total_reviews / (50.0 + total_reviews); - const DEFAULT_REVIEW_RATING_PROB: [f64; 3] = [0.224, 0.632, 0.144]; - let review_rating_prob = if total_reviews > 0.0 { - let mut arr = DEFAULT_REVIEW_RATING_PROB; - review_rating_count - .iter() - .filter(|(&button_chosen, ..)| button_chosen >= 2) - .for_each(|(&button_chosen, &count)| { - let index = button_chosen as usize - 2; - arr[index] = (count as f64 / total_reviews) * weight - + DEFAULT_REVIEW_RATING_PROB[index] * (1.0 - weight); - }); - arr - } else { - DEFAULT_REVIEW_RATING_PROB - }; - - let recall_costs = { - const DEFAULT: [f64; 4] = [18.0, 11.8, 7.3, 5.7]; - let mut arr = DEFAULT; - revlogs - .iter() - .filter(|r| { - r.review_kind == RevlogReviewKind::Review - && r.button_chosen > 0 - && r.taken_millis > 0 - && r.taken_millis < 1200000 // 20 minutes - }) - .sorted_by(|a, b| a.button_chosen.cmp(&b.button_chosen)) - .group_by(|r| r.button_chosen) - .into_iter() - .for_each(|(button_chosen, group)| { - let group_vec = group.into_iter().map(|r| r.taken_millis).collect_vec(); - let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64); - let index = button_chosen as usize - 1; - arr[index] = median_secs(&group_vec) * weight + DEFAULT[index] * (1.0 - weight); - }); - arr - }; - let learn_cost = { - const DEFAULT: f64 = 22.8; - let revlogs_filter = revlogs - .iter() - .filter(|r| { - r.review_kind == RevlogReviewKind::Learning - && r.button_chosen >= 1 - && r.taken_millis > 0 - && r.taken_millis < 1200000 // 20 minutes - }) - .map(|r| r.taken_millis); - let group_vec = revlogs_filter.collect_vec(); - let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64); - median_secs(&group_vec) * weight + DEFAULT * (1.0 - weight) - }; - - let forget_cost = { - const DEFAULT: f64 = 18.0; - let review_kind_to_total_millis = revlogs - .iter() - .filter(|r| { - r.button_chosen > 0 && r.taken_millis > 0 && r.taken_millis < 1200000 - // 20 minutes - }) - .sorted_by(|a, b| a.cid.cmp(&b.cid).then(a.id.cmp(&b.id))) - .group_by(|r| r.review_kind) - /* - for example: - o x x o o x x x o o x x o x - |<->| |<--->| |<->| |<>| - x means forgotten, there are 4 consecutive sets of internal relearning in this card. - So each group is counted separately, and each group is summed up internally.(following code) - Finally averaging all groups, so sort by cid and id. - */ - .into_iter() - .map(|(review_kind, group)| { - let total_millis: u32 = group.into_iter().map(|r| r.taken_millis).sum(); - (review_kind, total_millis) - }) - .collect_vec(); - let mut group_sec_by_review_kind: [Vec<_>; 5] = Default::default(); - for (review_kind, sec) in review_kind_to_total_millis.into_iter() { - group_sec_by_review_kind[review_kind as usize].push(sec) - } - let recall_cost = - median_secs(&group_sec_by_review_kind[RevlogReviewKind::Review as usize]); - let relearn_group = &group_sec_by_review_kind[RevlogReviewKind::Relearning as usize]; - let weight = relearn_group.len() as f64 / (50.0 + relearn_group.len() as f64); - (median_secs(relearn_group) + recall_cost) * weight + DEFAULT * (1.0 - weight) - }; - - let params = OptimalRetentionParameters { - recall_secs_hard: recall_costs[1], - recall_secs_good: recall_costs[2], - recall_secs_easy: recall_costs[3], - forget_secs: forget_cost, - learn_secs: learn_cost, - first_rating_probability_again: first_rating_prob[0], - first_rating_probability_hard: first_rating_prob[1], - first_rating_probability_good: first_rating_prob[2], - first_rating_probability_easy: first_rating_prob[3], - review_rating_probability_hard: review_rating_prob[0], - review_rating_probability_good: review_rating_prob[1], - review_rating_probability_easy: review_rating_prob[2], - }; + ) -> Result { + let fsrs_revlog: Vec = revlogs.into_iter().map(|r| r.into()).collect(); + let params = + extract_simulator_config(fsrs_revlog, self.timing_today()?.next_day_at.into(), true); Ok(params) } } -fn median_secs(group: &[u32]) -> f64 { - let length = group.len(); - if length > 0 { - let mut group_vec = group.to_vec(); - group_vec.sort_unstable(); - let median_millis = if length % 2 == 0 { - let mid = length / 2; - (group_vec[mid - 1] + group_vec[mid]) as f64 / 2.0 - } else { - group_vec[length / 2] as f64 - }; - median_millis / 1000.0 - } else { - 0.0 +impl From for fsrs::RevlogReviewKind { + fn from(kind: crate::revlog::RevlogReviewKind) -> Self { + match kind { + crate::revlog::RevlogReviewKind::Learning => fsrs::RevlogReviewKind::Learning, + crate::revlog::RevlogReviewKind::Review => fsrs::RevlogReviewKind::Review, + crate::revlog::RevlogReviewKind::Relearning => fsrs::RevlogReviewKind::Relearning, + crate::revlog::RevlogReviewKind::Filtered => fsrs::RevlogReviewKind::Filtered, + crate::revlog::RevlogReviewKind::Manual => fsrs::RevlogReviewKind::Manual, + } + } +} + +impl From for fsrs::RevlogEntry { + fn from(entry: crate::revlog::RevlogEntry) -> Self { + fsrs::RevlogEntry { + id: entry.id.into(), + cid: entry.cid.into(), + usn: entry.usn.into(), + button_chosen: entry.button_chosen, + interval: entry.interval, + last_interval: entry.last_interval, + ease_factor: entry.ease_factor, + taken_millis: entry.taken_millis, + review_kind: entry.review_kind.into(), + } } } diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index fd039f7b9..2396f14ac 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -26,22 +26,16 @@ impl Collection { let config = SimulatorConfig { deck_size: req.deck_size as usize, learn_span: req.days_to_simulate as usize, - max_cost_perday: f64::MAX, - max_ivl: req.max_interval as f64, - recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy], - forget_cost: p.forget_secs, - learn_cost: p.learn_secs, - first_rating_prob: [ - p.first_rating_probability_again, - p.first_rating_probability_hard, - p.first_rating_probability_good, - p.first_rating_probability_easy, - ], - review_rating_prob: [ - p.review_rating_probability_hard, - p.review_rating_probability_good, - p.review_rating_probability_easy, - ], + max_cost_perday: f32::MAX, + max_ivl: req.max_interval as f32, + learn_costs: p.learn_costs, + review_costs: p.review_costs, + first_rating_prob: p.first_rating_prob, + review_rating_prob: p.review_rating_prob, + first_rating_offsets: p.first_rating_offsets, + first_session_lens: p.first_session_lens, + forget_rating_offset: p.forget_rating_offset, + forget_session_len: p.forget_session_len, loss_aversion: 1.0, learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, @@ -54,8 +48,8 @@ impl Collection { daily_time_cost, ) = simulate( &config, - &req.weights.iter().map(|w| *w as f64).collect_vec(), - req.desired_retention as f64, + &req.weights, + req.desired_retention, None, Some( cards @@ -65,13 +59,10 @@ impl Collection { ), ); Ok(SimulateFsrsReviewResponse { - accumulated_knowledge_acquisition: accumulated_knowledge_acquisition - .iter() - .map(|x| *x as f32) - .collect_vec(), + accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_vec(), daily_review_count: daily_review_count.iter().map(|x| *x as u32).collect_vec(), daily_new_count: daily_new_count.iter().map(|x| *x as u32).collect_vec(), - daily_time_cost: daily_time_cost.iter().map(|x| *x as f32).collect_vec(), + daily_time_cost: daily_time_cost.to_vec(), }) } } @@ -83,10 +74,10 @@ impl Card { let due = card.original_or_current_due(); let relative_due = due - days_elapsed; Some(fsrs::Card { - difficulty: state.difficulty as f64, - stability: state.stability as f64, - last_date: (relative_due - card.interval as i32) as f64, - due: relative_due as f64, + difficulty: state.difficulty, + stability: state.stability, + last_date: (relative_due - card.interval as i32) as f32, + due: relative_due as f32, }) } None => None, diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 04f4fd90c..9cce0c44e 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -307,10 +307,24 @@ impl crate::services::SchedulerService for Collection { .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; - self.get_optimal_retention_parameters(revlogs) - .map(|params| GetOptimalRetentionParametersResponse { - params: Some(params), - }) + let simulator_config = self.get_optimal_retention_parameters(revlogs)?; + Ok(GetOptimalRetentionParametersResponse { + deck_size: simulator_config.deck_size as u32, + learn_span: simulator_config.learn_span as u32, + max_cost_perday: simulator_config.max_cost_perday, + max_ivl: simulator_config.max_ivl, + learn_costs: simulator_config.learn_costs.to_vec(), + review_costs: simulator_config.review_costs.to_vec(), + first_rating_prob: simulator_config.first_rating_prob.to_vec(), + review_rating_prob: simulator_config.review_rating_prob.to_vec(), + first_rating_offsets: simulator_config.first_rating_offsets.to_vec(), + first_session_lens: simulator_config.first_session_lens.to_vec(), + forget_rating_offset: simulator_config.forget_rating_offset, + forget_session_len: simulator_config.forget_session_len, + loss_aversion: simulator_config.loss_aversion, + learn_limit: simulator_config.learn_limit as u32, + review_limit: simulator_config.review_limit as u32, + }) } fn compute_memory_state(&mut self, input: cards::CardId) -> Result { diff --git a/rslib/src/scheduler/states/review.rs b/rslib/src/scheduler/states/review.rs index 2f3bdbd30..2fa7d2b16 100644 --- a/rslib/src/scheduler/states/review.rs +++ b/rslib/src/scheduler/states/review.rs @@ -77,11 +77,16 @@ impl ReviewState { ctx: &StateContext, ) -> (u32, Option) { if let Some(states) = &ctx.fsrs_next_states { + // In FSRS, fuzz is applied when the card leaves the relearning + // stage (states.again.interval, Some(states.again.memory.into())) } else { - let interval = (((self.scheduled_days as f32) * ctx.lapse_multiplier) as u32) - .max(ctx.minimum_lapse_interval) - .max(1); + let (minimum, maximum) = ctx.min_and_max_review_intervals(ctx.minimum_lapse_interval); + let interval = ctx.with_review_fuzz( + (self.scheduled_days as f32) * ctx.lapse_multiplier, + minimum, + maximum, + ); (interval, None) } } diff --git a/rslib/src/sync/http_client/io_monitor.rs b/rslib/src/sync/http_client/io_monitor.rs index 13679f664..602b672dd 100644 --- a/rslib/src/sync/http_client/io_monitor.rs +++ b/rslib/src/sync/http_client/io_monitor.rs @@ -33,10 +33,9 @@ use crate::sync::request::header_and_stream::encode_zstd_body_stream; use crate::sync::response::ORIGINAL_SIZE; /// Serves two purposes: -/// - allows us to monitor data sending/receiving and abort if -/// the transfer stalls -/// - allows us to monitor amount of data moving, to provide progress -/// reporting +/// - allows us to monitor data sending/receiving and abort if the transfer +/// stalls +/// - allows us to monitor amount of data moving, to provide progress reporting #[derive(Clone)] pub struct IoMonitor(pub Arc>); diff --git a/rslib/src/sync/http_server/mod.rs b/rslib/src/sync/http_server/mod.rs index 786d75eb6..697839b84 100644 --- a/rslib/src/sync/http_server/mod.rs +++ b/rslib/src/sync/http_server/mod.rs @@ -20,6 +20,7 @@ use std::sync::Mutex; use anki_io::create_dir_all; use axum::extract::DefaultBodyLimit; +use axum::routing::get; use axum::Router; use axum_client_ip::SecureClientIpSource; use pbkdf2::password_hash::PasswordHash; @@ -41,6 +42,7 @@ use crate::sync::error::OrHttpErr; use crate::sync::http_server::logging::with_logging_layer; use crate::sync::http_server::media_manager::ServerMediaManager; use crate::sync::http_server::routes::collection_sync_router; +use crate::sync::http_server::routes::health_check_handler; use crate::sync::http_server::routes::media_sync_router; use crate::sync::http_server::user::User; use crate::sync::login::HostKeyRequest; @@ -240,6 +242,7 @@ impl SimpleServer { Router::new() .nest("/sync", collection_sync_router()) .nest("/msync", media_sync_router()) + .route("/health", get(health_check_handler)) .with_state(server) .layer(DefaultBodyLimit::max(*MAXIMUM_SYNC_PAYLOAD_BYTES)) .layer(config.ip_header.into_extension()), diff --git a/rslib/src/sync/http_server/routes.rs b/rslib/src/sync/http_server/routes.rs index a8fd90342..dd4c0d3bd 100644 --- a/rslib/src/sync/http_server/routes.rs +++ b/rslib/src/sync/http_server/routes.rs @@ -4,6 +4,8 @@ use axum::extract::Path; use axum::extract::Query; use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; use axum::response::Response; use axum::routing::get; use axum::routing::post; @@ -86,6 +88,10 @@ async fn media_begin_post( media_sync_handler(Path(MediaSyncMethod::Begin), server, req.into_output_type()).await } +pub async fn health_check_handler() -> impl IntoResponse { + StatusCode::OK +} + async fn media_sync_handler( Path(method): Path, State(server): State

, diff --git a/rslib/src/tags/mod.rs b/rslib/src/tags/mod.rs index aab335a3d..51b091ab8 100644 --- a/rslib/src/tags/mod.rs +++ b/rslib/src/tags/mod.rs @@ -56,7 +56,7 @@ fn is_tag_separator(c: char) -> bool { } pub(crate) fn immediate_parent_name_unicase(tag_name: UniCase<&str>) -> Option> { - tag_name.rsplit_once('\x1f').map(|t| t.0).map(UniCase::new) + tag_name.rsplit_once("::").map(|t| t.0).map(UniCase::new) } fn immediate_parent_name_str(tag_name: &str) -> Option<&str> { diff --git a/rslib/src/tags/tree.rs b/rslib/src/tags/tree.rs index f989cb39d..32f98d72e 100644 --- a/rslib/src/tags/tree.rs +++ b/rslib/src/tags/tree.rs @@ -58,10 +58,10 @@ fn add_missing_parents(tags: &mut Vec) { } fn tags_to_tree(mut tags: Vec) -> TagTreeNode { + add_missing_parents(&mut tags); for tag in &mut tags { tag.name = tag.name.replace("::", "\x1f"); } - add_missing_parents(&mut tags); tags.sort_unstable_by(|a, b| UniCase::new(&a.name).cmp(&UniCase::new(&b.name))); let mut top = TagTreeNode::default(); let mut it = tags.into_iter().peekable(); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d86fee47f..e6a0120e3 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.79" +channel = "1.80" diff --git a/tools/minilints/src/main.rs b/tools/minilints/src/main.rs index eb1cfe4cd..d3b384256 100644 --- a/tools/minilints/src/main.rs +++ b/tools/minilints/src/main.rs @@ -209,7 +209,7 @@ fn sveltekit_temp_file(path: &str) -> bool { } fn check_cargo_deny() -> Result<()> { - Command::run("cargo install cargo-deny@0.14.12")?; + Command::run("cargo install cargo-deny@0.14.24")?; Command::run("cargo deny check")?; Ok(()) } diff --git a/ts/editor/CollapseLabel.svelte b/ts/editor/CollapseLabel.svelte index 051424ba0..57efba4eb 100644 --- a/ts/editor/CollapseLabel.svelte +++ b/ts/editor/CollapseLabel.svelte @@ -6,6 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { createEventDispatcher } from "svelte"; import CollapseBadge from "./CollapseBadge.svelte"; + import { onEnterOrSpace } from "@tslib/keys"; export let collapsed: boolean; export let tooltip: string; @@ -17,7 +18,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } - + toggle())} + tabindex="-1" + role="button" + aria-expanded={!collapsed} +> diff --git a/ts/editor/EditorField.svelte b/ts/editor/EditorField.svelte index 55992e76e..9408d0f05 100644 --- a/ts/editor/EditorField.svelte +++ b/ts/editor/EditorField.svelte @@ -89,7 +89,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html onDestroy(() => api?.destroy()); -

+