From 53686b54c5b011fe518c32085d846b1465307480 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 22 Aug 2024 18:51:53 +0700 Subject: [PATCH] Merge remote-tracking branch 'origin/main' into svelte5 --- .buildkite/linux/docker/Dockerfile.amd64 | 9 +- .dprint.json | 13 +- .gitignore | 1 + CONTRIBUTORS | 2 + Cargo.lock | 4 +- Cargo.toml | 32 +-- cargo/licenses.json | 2 +- ftl/core/browsing.ftl | 1 + ftl/core/deck-config.ftl | 4 +- ftl/core/editing.ftl | 1 + ftl/core/preferences.ftl | 7 +- ftl/core/sync.ftl | 5 + package.json | 2 +- proto/anki/ankihub.proto | 30 +++ proto/anki/config.proto | 2 + pylib/anki/collection.py | 22 +- pylib/anki/config.py | 2 +- pylib/anki/decks.py | 2 +- pylib/anki/httpclient.py | 2 +- pylib/anki/models.py | 2 +- pylib/anki/template.py | 2 +- pyproject.toml | 5 + qt/aqt/addons.py | 38 ++- qt/aqt/ankihub.py | 157 ++++++++++++ qt/aqt/browser/sidebar/item.py | 2 +- qt/aqt/browser/sidebar/tree.py | 2 +- qt/aqt/browser/table/table.py | 4 +- qt/aqt/editor.py | 36 ++- qt/aqt/forms/preferences.ui | 162 +++++++++++- qt/aqt/import_export/exporting.py | 2 +- qt/aqt/importing.py | 2 +- qt/aqt/main.py | 4 +- qt/aqt/mediasrv.py | 1 + qt/aqt/preferences.py | 24 +- qt/aqt/profiles.py | 33 ++- qt/aqt/progress.py | 2 +- qt/aqt/reviewer.py | 8 +- qt/aqt/switch.py | 4 +- qt/aqt/sync.py | 51 ++-- qt/aqt/utils.py | 14 +- qt/aqt/webview.py | 8 +- rslib/proto/python.rs | 1 + rslib/proto/src/lib.rs | 1 + rslib/src/ankihub/http_client/mod.rs | 62 +++++ rslib/src/ankihub/login.rs | 63 +++++ rslib/src/ankihub/mod.rs | 5 + rslib/src/backend/ankihub.rs | 34 +++ rslib/src/backend/config.rs | 1 + rslib/src/backend/mod.rs | 1 + rslib/src/config/bool.rs | 2 + rslib/src/lib.rs | 1 + rslib/src/ops.rs | 2 +- rslib/src/preferences.rs | 3 + rslib/src/scheduler/answering/mod.rs | 53 +++- rslib/src/scheduler/fsrs/simulator.rs | 80 ++++-- rslib/src/scheduler/queue/builder/mod.rs | 12 +- rslib/src/scheduler/queue/mod.rs | 2 + rslib/src/scheduler/queue/undo.rs | 12 + rslib/src/scheduler/states/fuzz.rs | 7 +- rslib/src/scheduler/states/load_balancer.rs | 239 ++++++++++++++++++ rslib/src/scheduler/states/mod.rs | 4 + rslib/src/stats/graphs/future_due.rs | 7 +- rslib/src/storage/card/mod.rs | 26 ++ rust-toolchain.toml | 2 +- .../rich-text-input/RichTextInput.svelte | 2 +- ts/lib/components/Icon.svelte | 10 +- ts/lib/components/types.ts | 3 - ts/routes/deck-options/FsrsOptions.svelte | 178 ++++++++++++- ts/routes/graphs/simulator.ts | 221 ++++++++++++++++ yarn.lock | 2 +- 70 files changed, 1592 insertions(+), 150 deletions(-) create mode 100644 proto/anki/ankihub.proto create mode 100644 qt/aqt/ankihub.py create mode 100644 rslib/src/ankihub/http_client/mod.rs create mode 100644 rslib/src/ankihub/login.rs create mode 100644 rslib/src/ankihub/mod.rs create mode 100644 rslib/src/backend/ankihub.rs create mode 100644 rslib/src/scheduler/states/load_balancer.rs create mode 100644 ts/routes/graphs/simulator.ts diff --git a/.buildkite/linux/docker/Dockerfile.amd64 b/.buildkite/linux/docker/Dockerfile.amd64 index 0223bb2b6..7c99c39e0 100644 --- a/.buildkite/linux/docker/Dockerfile.amd64 +++ b/.buildkite/linux/docker/Dockerfile.amd64 @@ -1,4 +1,4 @@ -FROM debian:10-slim +FROM debian:11-slim ARG DEBIAN_FRONTEND="noninteractive" @@ -53,13 +53,6 @@ RUN mkdir -p /etc/buildkite-agent/hooks && chown -R user /etc/buildkite-agent COPY buildkite.cfg /etc/buildkite-agent/buildkite-agent.cfg COPY environment /etc/buildkite-agent/hooks/environment -# Available in Debian 11 as ninja-build, but we're building with Debian 10 -RUN curl -LO https://github.com/ninja-build/ninja/releases/download/v1.11.1/ninja-linux.zip \ - && unzip ninja-linux.zip \ - && chmod +x ninja \ - && mv ninja /usr/bin \ - && rm ninja-linux.zip - RUN mkdir /state/rust && chown user /state/rust USER user diff --git a/.dprint.json b/.dprint.json index ae42dcd04..b73c6db10 100644 --- a/.dprint.json +++ b/.dprint.json @@ -31,14 +31,15 @@ "target", ".mypy_cache", "extra", - "ts/.svelte-kit" + "ts/.svelte-kit", + "ts/vite.config.ts.timestamp*" ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.85.1.wasm", - "https://plugins.dprint.dev/json-0.17.4.wasm", - "https://plugins.dprint.dev/markdown-0.15.3.wasm", - "https://plugins.dprint.dev/toml-0.5.4.wasm", + "https://plugins.dprint.dev/typescript-0.91.6.wasm", + "https://plugins.dprint.dev/json-0.19.3.wasm", + "https://plugins.dprint.dev/markdown-0.17.6.wasm", + "https://plugins.dprint.dev/toml-0.6.2.wasm", "https://plugins.dprint.dev/prettier-0.46.1.json@e5bd083088a8dfc6e5ce2d3c9bee81489b065bd5345ef55b59f5d96627928b7a", - "https://plugins.dprint.dev/disrupted/css-0.2.2.wasm" + "https://plugins.dprint.dev/disrupted/css-0.2.3.wasm" ] } diff --git a/.gitignore b/.gitignore index 9fa8dab62..bda378fb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +.mypy_cache .DS_Store anki.prof target diff --git a/CONTRIBUTORS b/CONTRIBUTORS index b2f34ebf5..dca1a11c3 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -181,11 +181,13 @@ James Elmore Ian Samir Yep Manzano David Culley <6276049+davidculley@users.noreply.github.com> Rastislav Kish +jake Expertium Christian Donat Asuka Minato Dillon Baldwin Voczi +Ben Nguyen <105088397+bpnguyen107@users.noreply.github.com> ******************** The text of the 3 clause BSD license follows: diff --git a/Cargo.lock b/Cargo.lock index 718f7c1ad..cfa6a2106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "285d9b275f7d5a276f17006e9d92ea67fa9991187ae88760fa96705fba1f97aa" +checksum = "f5b4e9d166a106007cc88e2ec7c01b107cf4999bbef71d74d32142cdf5277802" dependencies = [ "burn", "itertools 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index b119e8c0a..8486f1e03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,27 @@ [workspace.package] version = "0.0.0" authors = ["Ankitects Pty Ltd and contributors "] +edition = "2021" license = "AGPL-3.0-or-later" rust-version = "1.65" -edition = "2021" [workspace] members = [ - "rslib", - "rslib/i18n", - "rslib/linkchecker", - "rslib/proto", - "rslib/io", - "rslib/process", - "rslib/sync", - "pylib/rsbridge", "build/configure", "build/ninja_gen", "build/runner", "ftl", - "tools/minilints", - "qt/bundle/win", + "pylib/rsbridge", "qt/bundle/mac", + "qt/bundle/win", + "rslib", + "rslib/i18n", + "rslib/io", + "rslib/linkchecker", + "rslib/process", + "rslib/proto", + "rslib/sync", + "tools/minilints", ] exclude = ["qt/bundle"] resolver = "2" @@ -35,7 +35,7 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "1.1.4" +version = "1.1.5" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" # rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941" # path = "../open-spaced-repetition/fsrs-rs" @@ -45,8 +45,8 @@ version = "1.1.4" anki = { path = "rslib" } anki_i18n = { path = "rslib/i18n" } anki_io = { path = "rslib/io" } -anki_proto = { path = "rslib/proto" } anki_process = { path = "rslib/process" } +anki_proto = { path = "rslib/proto" } anki_proto_gen = { path = "rslib/proto_gen" } ninja_gen = { "path" = "build/ninja_gen" } @@ -74,6 +74,9 @@ criterion = { version = "0.5.1" } csv = "1.3.0" data-encoding = "2.6.0" difflib = "0.4.0" +dirs = "5.0.1" +dunce = "1.0.4" +envy = "0.4.2" flate2 = "1.0.30" fluent = "0.16.1" fluent-bundle = "0.15.3" @@ -145,9 +148,6 @@ wiremock = "0.5.22" xz2 = "0.1.7" zip = { version = "0.6.6", default-features = false, features = ["deflate", "time"] } zstd = { version = "0.13.2", features = ["zstdmt"] } -envy = "0.4.2" -dirs = "5.0.1" -dunce = "1.0.4" # Apply mild optimizations to our dependencies in dev mode, which among other things # improves sha2 performance by about 21x. Opt 1 chosen due to diff --git a/cargo/licenses.json b/cargo/licenses.json index 01e615b76..88c33dda4 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1252,7 +1252,7 @@ }, { "name": "fsrs", - "version": "1.1.4", + "version": "1.1.5", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 9b85460b9..644d158ac 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -82,6 +82,7 @@ browsing-reschedule = Reschedule browsing-search-bar-hint = Search cards/notes (type text, then press Enter) browsing-search-in = Search in: browsing-search-within-formatting-slow = Search within formatting (slow) +browsing-select-deck = Select Deck browsing-selected-notes-only = Selected notes only browsing-shift-position-of-existing-cards = Shift position of existing cards browsing-sidebar = Sidebar diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index ca89dbd82..113afb34b 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -160,7 +160,7 @@ deck-config-new-gather-priority-random-notes = Random notes deck-config-new-gather-priority-random-cards = Random cards deck-config-new-card-sort-order = New card sort order deck-config-new-card-sort-order-tooltip-2 = - `Card type`: Displays cards in order of card type number. If you have sibling burying + `Card type, then order gathered`: Displays cards in order of card type number. If you have sibling burying disabled, this will ensure all front→back cards are seen before any back→front cards. This is useful to have all cards of the same note shown in the same session, but not too close to one another. @@ -180,7 +180,7 @@ deck-config-new-card-sort-order-tooltip-2 = deck-config-sort-order-card-template-then-random = Card type, then random deck-config-sort-order-random-note-then-template = Random note, then card type deck-config-sort-order-random = Random -deck-config-sort-order-template-then-gather = Card type +deck-config-sort-order-template-then-gather = Card type, then order gathered deck-config-sort-order-gather = Order gathered deck-config-new-review-priority = New/review order deck-config-new-review-priority-tooltip = When to show new cards in relation to review cards. diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 692a9216c..5c9b766f5 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -10,6 +10,7 @@ editing-center = Center editing-change-color = Change color editing-cloze-deletion = Cloze deletion (new card) editing-cloze-deletion-repeat = Cloze deletion (same card) +editing-copy-image = Copy image editing-couldnt-record-audio-have-you-installed = Couldn't record audio. Have you installed 'lame'? editing-customize-card-templates = Customize Card Templates editing-customize-fields = Customize Fields diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index 1da294b5a..69a9300a0 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -27,7 +27,6 @@ preferences-show-remaining-card-count = Show remaining card count preferences-some-settings-will-take-effect-after = Some settings will take effect after you restart Anki. preferences-tab-synchronisation = Synchronization preferences-synchronize-audio-and-images-too = Synchronize audio and images too -preferences-not-logged-in = Not currently logged in to AnkiWeb. preferences-login-successful-sync-now = Log-in successful. Save preferences and sync now? preferences-timebox-time-limit = Timebox time limit preferences-user-interface-size = User interface size @@ -78,9 +77,15 @@ preferences-network-timeout = Network timeout preferences-reset-window-sizes = Reset Window Sizes preferences-reset-window-sizes-complete = Window sizes and locations have been reset. preferences-shortcut-placeholder = Enter an unused shortcut key, or leave empty to disable. +preferences-third-party-services = Third-Party Services +preferences-ankihub-not-logged-in = Not currently logged in to AnkiHub. +preferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your flashcard data in sync across your devices, and provides a way to recover the data if your device breaks or is lost. +preferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features. +preferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment. ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. preferences-basic = Basic preferences-reviewer = Reviewer preferences-media = Media +preferences-not-logged-in = Not currently logged in to AnkiWeb. diff --git a/ftl/core/sync.ftl b/ftl/core/sync.ftl index 4684b6682..76293c87d 100644 --- a/ftl/core/sync.ftl +++ b/ftl/core/sync.ftl @@ -54,6 +54,11 @@ sync-upload-too-large = Your collection file is too large to send to AnkiWeb. You can reduce its size by removing any unwanted decks (optionally exporting them first), and then using Check Database to shrink the file size down. ({ $details }) +sync-sign-in = Sign in +sync-ankihub-dialog-heading = AnkiHub Login +sync-ankihub-username-label = Username or Email: +sync-ankihub-login-failed = Unable to log in to AnkiHub with the provided credentials. +sync-ankihub-addon-installation = AnkiHub Add-on Installation ## Buttons diff --git a/package.json b/package.json index e27a12565..56276f0b6 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "caniuse-lite": "^1.0.30001431", "cross-env": "^7.0.2", "diff": "^5.0.0", - "dprint": "=0.47.2", + "dprint": "^0.47.2", "esbuild": "^0.18.10", "esbuild-sass-plugin": "^2", "esbuild-svelte": "^0.8.1", diff --git a/proto/anki/ankihub.proto b/proto/anki/ankihub.proto new file mode 100644 index 000000000..bcbdc1778 --- /dev/null +++ b/proto/anki/ankihub.proto @@ -0,0 +1,30 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +syntax = "proto3"; + +option java_multiple_files = true; + +import "anki/generic.proto"; + +package anki.ankihub; + +service AnkiHubService {} + +service BackendAnkiHubService { + rpc AnkihubLogin(LoginRequest) returns (LoginResponse); + rpc AnkihubLogout(LogoutRequest) returns (generic.Empty); +} + +message LoginResponse { + string token = 1; +} + +message LoginRequest { + string id = 1; + string password = 2; +} + +message LogoutRequest { + string token = 1; +} diff --git a/proto/anki/config.proto b/proto/anki/config.proto index 2cee6172b..9924ab90f 100644 --- a/proto/anki/config.proto +++ b/proto/anki/config.proto @@ -54,6 +54,7 @@ message ConfigKey { RANDOM_ORDER_REPOSITION = 23; SHIFT_POSITION_OF_EXISTING_CARDS = 24; RENDER_LATEX = 25; + LOAD_BALANCER_ENABLED = 26; } enum String { SET_DUE_BROWSER = 0; @@ -115,6 +116,7 @@ message Preferences { bool show_remaining_due_counts = 3; bool show_intervals_on_buttons = 4; uint32 time_limit_secs = 5; + bool load_balancer_enabled = 6; } message Editing { bool adding_defaults_to_current_deck = 1; diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index e2fa0e1d5..b3bad9922 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -897,7 +897,7 @@ class Collection(DeprecatedNamesMixin): # Config ########################################################################## - def get_config(self, key: str, default: Any = None) -> Any: + def get_config(self, key: str, default: Any | None = None) -> Any: try: return self.conf.get_immutable(key) except KeyError: @@ -939,7 +939,7 @@ class Collection(DeprecatedNamesMixin): return self._backend.set_config_string(key=key, value=value, undoable=undoable) def get_aux_notetype_config( - self, id: NotetypeId, key: str, default: Any = None + self, id: NotetypeId, key: str, default: Any | None = None ) -> Any: key = self._backend.get_aux_notetype_config_key(id=id, key=key) return self.get_config(key, default=default) @@ -951,7 +951,7 @@ class Collection(DeprecatedNamesMixin): return self.set_config(key, value, undoable=undoable) def get_aux_template_config( - self, id: NotetypeId, card_ordinal: int, key: str, default: Any = None + self, id: NotetypeId, card_ordinal: int, key: str, default: Any | None = None ) -> Any: key = self._backend.get_aux_template_config_key( notetype_id=id, card_ordinal=card_ordinal, key=key @@ -972,6 +972,16 @@ class Collection(DeprecatedNamesMixin): ) return self.set_config(key, value, undoable=undoable) + def _get_enable_load_balancer(self) -> bool: + return self.get_config_bool(Config.Bool.LOAD_BALANCER_ENABLED) + + def _set_enable_load_balancer(self, value: bool) -> None: + self.set_config_bool(Config.Bool.LOAD_BALANCER_ENABLED, value) + + load_balancer_enabled = property( + fget=_get_enable_load_balancer, fset=_set_enable_load_balancer + ) + # Stats ########################################################################## @@ -1121,6 +1131,12 @@ class Collection(DeprecatedNamesMixin): "This will throw if the sync failed with an error." return self._backend.media_sync_status() + def ankihub_login(self, id: str, password: str) -> str: + return self._backend.ankihub_login(id=id, password=password) + + def ankihub_logout(self, token: str) -> None: + self._backend.ankihub_logout(token=token) + def get_preferences(self) -> Preferences: return self._backend.get_preferences() diff --git a/pylib/anki/config.py b/pylib/anki/config.py index 834af5004..775557c84 100644 --- a/pylib/anki/config.py +++ b/pylib/anki/config.py @@ -76,7 +76,7 @@ class ConfigManager: def __setitem__(self, key: str, value: Any) -> None: self.set(key, value) - def get(self, key: str, default: Any = None) -> Any: + def get(self, key: str, default: Any | None = None) -> Any: try: return self[key] except KeyError: diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index e61721dd8..c53275ae3 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -85,7 +85,7 @@ class DeckManager(DeprecatedNamesMixin): self.col = col.weakref() self.decks = DecksDictProxy(col) - def save(self, deck_or_config: DeckDict | DeckConfigDict = None) -> None: + def save(self, deck_or_config: DeckDict | DeckConfigDict | None = None) -> None: "Can be called with either a deck or a deck configuration." if not deck_or_config: print("col.decks.save() should be passed the changed deck") diff --git a/pylib/anki/httpclient.py b/pylib/anki/httpclient.py index 186f90623..d0a9fdd65 100644 --- a/pylib/anki/httpclient.py +++ b/pylib/anki/httpclient.py @@ -57,7 +57,7 @@ class HttpClient(DeprecatedNamesMixin): verify=self.verify, ) # pytype: disable=wrong-arg-types - def get(self, url: str, headers: dict[str, str] = None) -> Response: + def get(self, url: str, headers: dict[str, str] | None = None) -> Response: if headers is None: headers = {} headers["User-Agent"] = self._agent_name() diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 4dfdb0211..fb08fd6f3 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -563,7 +563,7 @@ and notes.mid = ? and cards.ord = ?""", self._mutate_after_write(notetype) # @deprecated(replaced_by=update_dict) - def save(self, notetype: NotetypeDict = None, **legacy_kwargs: bool) -> None: + def save(self, notetype: NotetypeDict | None = None, **legacy_kwargs: bool) -> None: "Save changes made to provided note type." if not notetype: print_deprecation_warning( diff --git a/pylib/anki/template.py b/pylib/anki/template.py index deda8c819..2a145c9e5 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -147,7 +147,7 @@ class TemplateRenderContext: card: anki.cards.Card, note: anki.notes.Note, browser: bool = False, - notetype: NotetypeDict = None, + notetype: NotetypeDict | None = None, template: dict | None = None, fill_empty: bool = False, ) -> None: diff --git a/pyproject.toml b/pyproject.toml index 56f5c4aeb..d88101a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,8 @@ [tool.black] target-version = ["py39", "py310", "py311", "py312"] extend-exclude = "qt/bundle" + +[tool.pyright] +include = ["pylib/anki", "qt/aqt"] +stubPath = "" +pythonVersion = "3.9" diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index 890c10c2a..b4f3868e7 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -409,7 +409,9 @@ class AddonManager: all_conflicts[other_dir].append(addon.dir_name) return all_conflicts - def _disableConflicting(self, module: str, conflicts: list[str] = None) -> set[str]: + def _disableConflicting( + self, module: str, conflicts: list[str] | None = None + ) -> set[str]: if not self.isEnabled(module): # disabled add-ons should not trigger conflict handling return set() @@ -1253,7 +1255,9 @@ class DownloaderInstaller(QObject): self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log)) -def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None: +def show_log_to_user( + parent: QWidget, log: list[DownloadLogEntry], title: str = "Anki" +) -> None: have_problem = download_encountered_problem(log) if have_problem: @@ -1263,9 +1267,9 @@ def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None: text += f"

{download_log_to_html(log)}" if have_problem: - showWarning(text, textFormat="rich", parent=parent) + showWarning(text, textFormat="rich", parent=parent, title=title) else: - showInfo(text, parent=parent) + showInfo(text, parent=parent, title=title) def download_addons( @@ -1550,6 +1554,32 @@ def prompt_to_update( ChooseAddonsToUpdateDialog(parent, mgr, updated_addons).ask(after_choosing) +def install_or_update_addon( + parent: QWidget, + mgr: AddonManager, + addon_id: int, + on_done: Callable[[list[DownloadLogEntry]], None], +) -> None: + def check() -> list[AddonInfo]: + return fetch_update_info([addon_id]) + + def update_info_received(future: Future) -> None: + try: + items = future.result() + updated_addons = mgr.get_updated_addons(items) + if not updated_addons: + on_done([]) + return + client = HttpClient() + download_addons( + parent, mgr, [addon.id for addon in updated_addons], on_done, client + ) + except Exception as exc: + on_done([(addon_id, DownloadError(exception=exc))]) + + mgr.mw.taskman.run_in_background(check, update_info_received) + + # Editing config ###################################################################### diff --git a/qt/aqt/ankihub.py b/qt/aqt/ankihub.py new file mode 100644 index 000000000..4d3b00c8a --- /dev/null +++ b/qt/aqt/ankihub.py @@ -0,0 +1,157 @@ +# 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 functools +from concurrent.futures import Future +from typing import Callable + +import aqt +import aqt.main +from aqt.addons import ( + AddonManager, + DownloadLogEntry, + install_or_update_addon, + show_log_to_user, +) +from aqt.qt import ( + QDialog, + QDialogButtonBox, + QGridLayout, + QLabel, + QLineEdit, + QPushButton, + Qt, + QVBoxLayout, + QWidget, + qconnect, +) +from aqt.utils import disable_help_button, showWarning, tr + + +def ankihub_login( + mw: aqt.main.AnkiQt, + on_success: Callable[[], None], + username: str = "", + password: str = "", +) -> None: + + def on_future_done(fut: Future[str], username: str, password: str) -> None: + try: + token = fut.result() + except Exception as exc: + showWarning(str(exc)) + return + + if not token: + showWarning(tr.sync_ankihub_login_failed(), parent=mw) + ankihub_login(mw, on_success, username, password) + return + mw.pm.set_ankihub_token(token) + mw.pm.set_ankihub_username(username) + install_ankihub_addon(mw, mw.addonManager) + on_success() + + def callback(username: str, password: str) -> None: + if not username and not password: + return + if username and password: + mw.taskman.with_progress( + lambda: mw.col.ankihub_login(id=username, password=password), + functools.partial(on_future_done, username=username, password=password), + parent=mw, + ) + else: + ankihub_login(mw, on_success, username, password) + + get_id_and_pass_from_user(mw, callback, username, password) + + +def ankihub_logout( + mw: aqt.main.AnkiQt, + on_success: Callable[[], None], + token: str, +) -> None: + + def logout() -> None: + mw.pm.set_ankihub_username(None) + mw.pm.set_ankihub_token(None) + mw.col.ankihub_logout(token=token) + + mw.taskman.with_progress( + logout, + # We don't need to wait for the response + lambda _: on_success(), + parent=mw, + ) + + +def get_id_and_pass_from_user( + mw: aqt.main.AnkiQt, + callback: Callable[[str, str], None], + username: str = "", + password: str = "", +) -> None: + diag = QDialog(mw) + diag.setWindowTitle("Anki") + disable_help_button(diag) + diag.setWindowModality(Qt.WindowModality.WindowModal) + diag.setMinimumWidth(600) + vbox = QVBoxLayout() + info_label = QLabel(f"

{tr.sync_ankihub_dialog_heading()}

") + info_label.setOpenExternalLinks(True) + info_label.setWordWrap(True) + vbox.addWidget(info_label) + vbox.addSpacing(20) + g = QGridLayout() + l1 = QLabel(tr.sync_ankihub_username_label()) + g.addWidget(l1, 0, 0) + user = QLineEdit() + user.setText(username) + g.addWidget(user, 0, 1) + 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) + vbox.addLayout(g) + + vbox.addSpacing(20) + bb = QDialogButtonBox() # type: ignore + sign_in_button = QPushButton(tr.sync_sign_in()) + sign_in_button.setAutoDefault(True) + bb.addButton( + QPushButton(tr.actions_cancel()), + QDialogButtonBox.ButtonRole.RejectRole, + ) + bb.addButton( + sign_in_button, + QDialogButtonBox.ButtonRole.AcceptRole, + ) + qconnect(bb.accepted, diag.accept) + qconnect(bb.rejected, diag.reject) + vbox.addWidget(bb) + + diag.setLayout(vbox) + diag.adjustSize() + diag.show() + user.setFocus() + + def on_finished(result: int) -> None: + if result == QDialog.DialogCode.Rejected: + callback("", "") + else: + callback(user.text().strip(), passwd.text()) + + qconnect(diag.finished, on_finished) + diag.open() + + +def install_ankihub_addon(parent: QWidget, mgr: AddonManager) -> None: + def on_done(log: list[DownloadLogEntry]) -> None: + if log: + show_log_to_user(parent, log, title=tr.sync_ankihub_addon_installation()) + + install_or_update_addon(parent, mgr, 1322529746, on_done) diff --git a/qt/aqt/browser/sidebar/item.py b/qt/aqt/browser/sidebar/item.py index 576d0b455..8da2f3df6 100644 --- a/qt/aqt/browser/sidebar/item.py +++ b/qt/aqt/browser/sidebar/item.py @@ -62,7 +62,7 @@ class SidebarItem: name: str, icon: str | ColoredIcon, search_node: SearchNode | None = None, - on_expanded: Callable[[bool], None] = None, + on_expanded: Callable[[bool], None] | None = None, expanded: bool = False, item_type: SidebarItemType = SidebarItemType.CUSTOM, id: int = 0, diff --git a/qt/aqt/browser/sidebar/tree.py b/qt/aqt/browser/sidebar/tree.py index 6189e925c..89600f231 100644 --- a/qt/aqt/browser/sidebar/tree.py +++ b/qt/aqt/browser/sidebar/tree.py @@ -159,7 +159,7 @@ class SidebarTreeView(QTreeView): self.refresh() self._refresh_needed = False - def refresh(self, new_current: SidebarItem = None) -> None: + def refresh(self, new_current: SidebarItem | None = None) -> None: "Refresh list. No-op if sidebar is not visible." if not self.isVisible(): return diff --git a/qt/aqt/browser/table/table.py b/qt/aqt/browser/table/table.py index 345386acb..54631ebfa 100644 --- a/qt/aqt/browser/table/table.py +++ b/qt/aqt/browser/table/table.py @@ -600,7 +600,9 @@ class Table: self._view.verticalScrollBar().setValue(vertical) def _move_current( - self, direction: QAbstractItemView.CursorAction, index: QModelIndex = None + self, + direction: QAbstractItemView.CursorAction, + index: QModelIndex | None = None, ) -> None: if not self.has_current(): return diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index b87f9c62a..74cc6e55b 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -233,9 +233,9 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too func: Callable[[Editor], None], tip: str = "", label: str = "", - id: str = None, + id: str | None = None, toggleable: bool = False, - keys: str = None, + keys: str | None = None, disables: bool = True, rightside: bool = True, ) -> str: @@ -1433,6 +1433,16 @@ class EditorWebView(AnkiWebView): def onCopy(self) -> None: self.triggerPageAction(QWebEnginePage.WebAction.Copy) + def on_copy_image(self) -> None: + self.triggerPageAction(QWebEnginePage.WebAction.CopyImageToClipboard) + + def _opened_context_menu_on_image(self) -> bool: + context_menu_request = self.lastContextMenuRequest() + return ( + context_menu_request.mediaType() + == context_menu_request.MediaType.MediaTypeImage + ) + def _wantsExtendedPaste(self) -> bool: strip_html = self.editor.mw.col.get_config_bool( Config.Bool.PASTE_STRIPS_FORMATTING @@ -1575,15 +1585,29 @@ class EditorWebView(AnkiWebView): def contextMenuEvent(self, evt: QContextMenuEvent) -> None: m = QMenu(self) - a = m.addAction(tr.editing_cut()) - qconnect(a.triggered, self.onCut) - a = m.addAction(tr.actions_copy()) - qconnect(a.triggered, self.onCopy) + self._maybe_add_cut_action(m) + self._maybe_add_copy_action(m) a = m.addAction(tr.editing_paste()) qconnect(a.triggered, self.onPaste) + self._maybe_add_copy_image_action(m) gui_hooks.editor_will_show_context_menu(self, m) m.popup(QCursor.pos()) + def _maybe_add_cut_action(self, menu: QMenu) -> None: + if self.hasSelection(): + a = menu.addAction(tr.editing_cut()) + qconnect(a.triggered, self.onCut) + + def _maybe_add_copy_action(self, menu: QMenu) -> None: + if self.hasSelection(): + a = menu.addAction(tr.actions_copy()) + qconnect(a.triggered, self.onCopy) + + def _maybe_add_copy_image_action(self, menu: QMenu) -> None: + if self._opened_context_menu_on_image(): + a = menu.addAction(tr.editing_copy_image()) + qconnect(a.triggered, self.on_copy_image) + # QFont returns "Kozuka Gothic Pro L" but WebEngine expects "Kozuka Gothic Pro Light" # - there may be other cases like a trailing 'Bold' that need fixing, but will diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 319ff47cb..58db104c7 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -49,17 +49,14 @@ - - + + 0 0 - - QComboBox::AdjustToMinimumContentsLengthWithIcon - @@ -72,17 +69,20 @@ - - + + 0 0 + + QComboBox::AdjustToMinimumContentsLengthWithIcon + - + @@ -806,6 +806,9 @@ + + true + @@ -856,6 +859,19 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -1098,6 +1114,133 @@ + + + preferences_third_party_services + + + + 12 + + + 12 + + + 12 + + + 12 + + + 12 + + + + + + 0 + 0 + + + + preferences_third_party_description + + + + + + + Qt::Vertical + + + QSizePolicy::Maximum + + + + 20 + 12 + + + + + + + + + 0 + 0 + + + + AnkiHub + + + + + + + 0 + 0 + + + + + + + true + + + + + + + + 0 + 0 + + + + sync_log_out_button + + + false + + + + + + + + 0 + 0 + + + + sync_log_in_button + + + false + + + + + + + + + + Qt::Vertical + + + + 40 + 20 + + + + + + @@ -1125,6 +1268,7 @@ lang video_driver + check_for_updates theme styleComboBox uiScale @@ -1164,6 +1308,8 @@ weekly_backups monthly_backups tabWidget + syncAnkiHubLogout + syncAnkiHubLogin diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py index a33395f98..ad7fc4ef6 100644 --- a/qt/aqt/import_export/exporting.py +++ b/qt/aqt/import_export/exporting.py @@ -137,7 +137,7 @@ class ExportDialog(QDialog): return path def options(self, out_path: str) -> ExportOptions: - limit: ExportLimit = None + limit: ExportLimit | None = None if self.nids: limit = NoteIdsLimit(self.nids) elif current_deck_id := self.current_deck_id(): diff --git a/qt/aqt/importing.py b/qt/aqt/importing.py index b00d0b69b..ffc173a8b 100644 --- a/qt/aqt/importing.py +++ b/qt/aqt/importing.py @@ -120,7 +120,7 @@ class ImportDialog(QDialog): ) self.deck = aqt.deckchooser.DeckChooser(self.mw, self.frm.deckArea, label=False) - def modelChanged(self, unused: Any = None) -> None: + def modelChanged(self, unused: Any | None = None) -> None: self.importer.model = self.mw.col.models.current() self.importer.initMapping() self.showMapping() diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 9508cc10d..a27ec8abd 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -862,8 +862,8 @@ class AnkiQt(QMainWindow): def requireReset( self, modal: bool = False, - reason: Any = None, - context: Any = None, + reason: Any | None = None, + context: Any | None = None, ) -> None: traceback.print_stack(file=sys.stdout) print("requireReset() is obsolete; please use CollectionOp()") diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index a613a4795..c866ae423 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -625,6 +625,7 @@ exposed_backend_list = [ "set_wants_abort", "evaluate_weights", "get_optimal_retention_parameters", + "simulate_fsrs_review", ] diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index d0f7b07f4..3676fe169 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -14,6 +14,7 @@ import aqt.operations from anki.collection import OpChanges from anki.utils import is_mac from aqt import AnkiQt +from aqt.ankihub import ankihub_login, ankihub_logout from aqt.operations.collection import set_preferences from aqt.profiles import VideoDriver from aqt.qt import * @@ -213,10 +214,12 @@ class Preferences(QDialog): self.update_login_status() qconnect(self.form.syncLogout.clicked, self.sync_logout) qconnect(self.form.syncLogin.clicked, self.sync_login) + qconnect(self.form.syncAnkiHubLogout.clicked, self.ankihub_sync_logout) + qconnect(self.form.syncAnkiHubLogin.clicked, self.ankihub_sync_login) def update_login_status(self) -> None: if not self.prof.get("syncKey"): - self.form.syncUser.setText(tr.preferences_not_logged_in()) + self.form.syncUser.setText(tr.preferences_ankiweb_intro()) self.form.syncLogin.setVisible(True) self.form.syncLogout.setVisible(False) else: @@ -224,6 +227,15 @@ class Preferences(QDialog): self.form.syncLogin.setVisible(False) self.form.syncLogout.setVisible(True) + if not self.mw.pm.ankihub_token(): + self.form.syncAnkiHubUser.setText(tr.preferences_ankihub_intro()) + self.form.syncAnkiHubLogin.setVisible(True) + self.form.syncAnkiHubLogout.setVisible(False) + else: + self.form.syncAnkiHubUser.setText(self.mw.pm.ankihub_username()) + self.form.syncAnkiHubLogin.setVisible(False) + self.form.syncAnkiHubLogout.setVisible(True) + def on_media_log(self) -> None: self.mw.media_syncer.show_sync_log() @@ -243,6 +255,16 @@ class Preferences(QDialog): self.mw.col.media.force_resync() self.update_login_status() + def ankihub_sync_login(self) -> None: + def on_success(): + if self.mw.pm.ankihub_token(): + self.update_login_status() + + ankihub_login(self.mw, on_success) + + def ankihub_sync_logout(self) -> None: + ankihub_logout(self.mw, self.update_login_status, self.mw.pm.ankihub_token()) + def confirm_sync_after_login(self) -> None: from aqt import mw diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index ceecf50f2..469908c1b 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -213,7 +213,8 @@ class ProfileManager: if name == "_global": raise Exception("_global is not a valid name") data = self.db.scalar( - "select cast(data as blob) from profiles where name = ?", name + "select cast(data as blob) from profiles where name = ? collate nocase", + name, ) self.name = name try: @@ -232,22 +233,26 @@ class ProfileManager: return True def save(self) -> None: - sql = "update profiles set data = ? where name = ?" + sql = "update profiles set data = ? where name = ? collate nocase" self.db.execute(sql, self._pickle(self.profile), self.name) self.db.execute(sql, self._pickle(self.meta), "_global") self.db.commit() def create(self, name: str) -> None: prof = profileConf.copy() + if self.db.scalar("select 1 from profiles where name = ? collate nocase", name): + return self.db.execute( - "insert or ignore into profiles values (?, ?)", name, self._pickle(prof) + "insert or ignore into profiles values (?, ?)", + name, + self._pickle(prof), ) self.db.commit() def remove(self, name: str) -> None: path = self.profileFolder(create=False) send_to_trash(Path(path)) - self.db.execute("delete from profiles where name = ?", name) + self.db.execute("delete from profiles where name = ? collate nocase", name) self.db.commit() def trashCollection(self) -> None: @@ -277,7 +282,9 @@ class ProfileManager: return # update name - self.db.execute("update profiles set name = ? where name = ?", name, oldName) + self.db.execute( + "update profiles set name = ? where name = ? collate nocase", name, oldName + ) # rename folder try: os.rename(oldFolder, newFolder) @@ -403,7 +410,7 @@ class ProfileManager: self.db.execute( """ create table if not exists profiles -(name text primary key, data blob not null);""" +(name text primary key collate nocase, data blob not null);""" ) data = self.db.scalar( "select cast(data as blob) from profiles where name = '_global'" @@ -485,7 +492,7 @@ create table if not exists profiles def setLang(self, code: str) -> None: self.meta["defaultLang"] = code - sql = "update profiles set data = ? where name = ?" + sql = "update profiles set data = ? where name = ? collate nocase" self.db.execute(sql, self._pickle(self.meta), "_global") self.db.commit() anki.lang.set_lang(code) @@ -716,3 +723,15 @@ create table if not exists profiles def network_timeout(self) -> int: return self.profile.get("networkTimeout") or 60 + + def set_ankihub_token(self, val: str | None) -> None: + self.profile["thirdPartyAnkiHubToken"] = val + + def ankihub_token(self) -> str | None: + return self.profile.get("thirdPartyAnkiHubToken") + + def set_ankihub_username(self, val: str | None) -> None: + self.profile["thirdPartyAnkiHubUsername"] = val + + def ankihub_username(self) -> str | None: + return self.profile.get("thirdPartyAnkiHubUsername") diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index dd8fb26e1..84d23a22e 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -42,7 +42,7 @@ class ProgressManager: repeat: bool, requiresCollection: bool = True, *, - parent: QObject = None, + parent: QObject | None = None, ) -> QTimer: """Create and start a standard Anki timer. For an alternative see `single_shot()`. diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 680d323b1..0913d049d 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -152,8 +152,8 @@ class Reviewer: self.previous_card: Card | None = None self._answeredIds: list[CardId] = [] self._recordedAudio: str | None = None - self.typeCorrect: str = None # web init happens before this is set - self.state: Literal["question", "answer", "transition", None] = None + self.typeCorrect: str | None = None # web init happens before this is set + self.state: Literal["question", "answer", "transition"] | None = None self._refresh_needed: RefreshNeeded | None = None self._v3: V3CardInfo | None = None self._state_mutation_key = str(random.randint(0, 2**64 - 1)) @@ -162,7 +162,7 @@ class Reviewer: self._previous_card_info = PreviousReviewerCardInfo(self.mw) self._states_mutated = True self._state_mutation_js = None - self._reps: int = None + self._reps: int | None = None self._show_question_timer: QTimer | None = None self._show_answer_timer: QTimer | None = None self.auto_advance_enabled = False @@ -369,7 +369,7 @@ class Reviewer: def _showQuestion(self) -> None: self._reps += 1 self.state = "question" - self.typedAnswer: str = None + self.typedAnswer: str | None = None c = self.card # grab the question and play audio q = c.question() diff --git a/qt/aqt/switch.py b/qt/aqt/switch.py index 15f3c0890..fb3c2da6c 100644 --- a/qt/aqt/switch.py +++ b/qt/aqt/switch.py @@ -1,5 +1,7 @@ # 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 cast from aqt import colors, props @@ -21,7 +23,7 @@ class Switch(QAbstractButton): right_label: str = "", left_color: dict[str, str] = colors.ACCENT_CARD | {}, right_color: dict[str, str] = colors.ACCENT_NOTE | {}, - parent: QWidget = None, + parent: QWidget | None = None, ) -> None: super().__init__(parent=parent) self.setCheckable(True) diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py index 1366ee669..cea64fadc 100644 --- a/qt/aqt/sync.py +++ b/qt/aqt/sync.py @@ -3,6 +3,7 @@ from __future__ import annotations +import functools import os from collections.abc import Callable from concurrent.futures import Future @@ -298,14 +299,8 @@ def sync_login( username: str = "", password: str = "", ) -> None: - while True: - (username, password) = get_id_and_pass_from_user(mw, username, password) - if not username and not password: - return - if username and password: - break - def on_future_done(fut: Future[SyncAuth]) -> None: + def on_future_done(fut: Future[SyncAuth], username: str, password: str) -> None: try: auth = fut.result() except SyncError as e: @@ -324,18 +319,29 @@ def sync_login( on_success() - mw.taskman.with_progress( - lambda: mw.col.sync_login( - username=username, password=password, endpoint=mw.pm.sync_endpoint() - ), - on_future_done, - parent=mw, - ) + def callback(username: str, password: str) -> None: + if not username and not password: + return + if username and password: + mw.taskman.with_progress( + lambda: mw.col.sync_login( + username=username, password=password, endpoint=mw.pm.sync_endpoint() + ), + functools.partial(on_future_done, username=username, password=password), + parent=mw, + ) + else: + sync_login(mw, on_success, username, password) + + get_id_and_pass_from_user(mw, callback, username, password) def get_id_and_pass_from_user( - mw: aqt.main.AnkiQt, username: str = "", password: str = "" -) -> tuple[str, str]: + mw: aqt.main.AnkiQt, + callback: Callable[[str, str], None], + username: str = "", + password: str = "", +) -> None: diag = QDialog(mw) diag.setWindowTitle("Anki") disable_help_button(diag) @@ -371,13 +377,18 @@ def get_id_and_pass_from_user( qconnect(bb.rejected, diag.reject) vbox.addWidget(bb) diag.setLayout(vbox) + diag.adjustSize() diag.show() user.setFocus() - accepted = diag.exec() - if not accepted: - return ("", "") - return (user.text().strip(), passwd.text()) + def on_finished(result: int) -> None: + if result == QDialog.DialogCode.Rejected: + callback("", "") + else: + callback(user.text().strip(), passwd.text()) + + qconnect(diag.finished, on_finished) + diag.open() # export platform version to syncing code diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 98e4cee10..8d7774555 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -393,8 +393,8 @@ def showText( def askUser( text: str, - parent: QWidget = None, - help: HelpPageArgument = None, + parent: QWidget | None = None, + help: HelpPageArgument | None = None, defaultno: bool = False, msgfunc: Callable | None = None, title: str = "Anki", @@ -426,7 +426,7 @@ class ButtonedDialog(QMessageBox): text: str, buttons: list[str], parent: QWidget | None = None, - help: HelpPageArgument = None, + help: HelpPageArgument | None = None, title: str = "Anki", ): QMessageBox.__init__(self, parent) @@ -459,7 +459,7 @@ def askUserDialog( text: str, buttons: list[str], parent: QWidget | None = None, - help: HelpPageArgument = None, + help: HelpPageArgument | None = None, title: str = "Anki", ) -> ButtonedDialog: if not parent: @@ -473,7 +473,7 @@ class GetTextDialog(QDialog): self, parent: QWidget | None, question: str, - help: HelpPageArgument = None, + help: HelpPageArgument | None = None, edit: QLineEdit | None = None, default: str = "", title: str = "Anki", @@ -525,7 +525,7 @@ class GetTextDialog(QDialog): def getText( prompt: str, parent: QWidget | None = None, - help: HelpPageArgument = None, + help: HelpPageArgument | None = None, edit: QLineEdit | None = None, default: str = "", title: str = "Anki", @@ -558,7 +558,7 @@ def getOnlyText(*args: Any, **kwargs: Any) -> str: # fixme: these utilities could be combined into a single base class # unused by Anki, but used by add-ons def chooseList( - prompt: str, choices: list[str], startrow: int = 0, parent: Any = None + prompt: str, choices: list[str], startrow: int = 0, parent: Any | None = None ) -> int: if not parent: parent = aqt.mw.app.activeWindow() diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index 890c4dc88..50c4fdfe7 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -379,11 +379,15 @@ class AnkiWebView(QWebEngineView): def contextMenuEvent(self, evt: QContextMenuEvent) -> None: m = QMenu(self) - a = m.addAction(tr.actions_copy()) - qconnect(a.triggered, self.onCopy) + self._maybe_add_copy_action(m) gui_hooks.webview_will_show_context_menu(self, m) m.popup(QCursor.pos()) + def _maybe_add_copy_action(self, menu: QMenu) -> None: + if self.hasSelection(): + a = menu.addAction(tr.actions_copy()) + qconnect(a.triggered, self.onCopy) + def dropEvent(self, evt: QDropEvent) -> None: if self.allow_drops: super().dropEvent(evt) diff --git a/rslib/proto/python.rs b/rslib/proto/python.rs index b20b850a4..0ca2c15ea 100644 --- a/rslib/proto/python.rs +++ b/rslib/proto/python.rs @@ -249,6 +249,7 @@ import anki.search_pb2 import anki.stats_pb2 import anki.sync_pb2 import anki.tags_pb2 +import anki.ankihub_pb2 class RustBackendGenerated: def _run_command(self, service: int, method: int, input: Any) -> bytes: diff --git a/rslib/proto/src/lib.rs b/rslib/proto/src/lib.rs index 85cd528cf..86d7d1580 100644 --- a/rslib/proto/src/lib.rs +++ b/rslib/proto/src/lib.rs @@ -36,3 +36,4 @@ protobuf!(search, "search"); protobuf!(stats, "stats"); protobuf!(sync, "sync"); protobuf!(tags, "tags"); +protobuf!(ankihub, "ankihub"); diff --git a/rslib/src/ankihub/http_client/mod.rs b/rslib/src/ankihub/http_client/mod.rs new file mode 100644 index 000000000..e5e374463 --- /dev/null +++ b/rslib/src/ankihub/http_client/mod.rs @@ -0,0 +1,62 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::env; + +use reqwest::Client; +use reqwest::Response; +use reqwest::Result; +use reqwest::Url; +use serde::Serialize; + +use crate::ankihub::login::LoginRequest; + +static API_VERSION: &str = "18.0"; +static DEFAULT_API_URL: &str = "https://app.ankihub.net/api/"; + +#[derive(Clone)] +pub struct HttpAnkiHubClient { + pub token: String, + pub endpoint: Url, + client: Client, +} + +impl HttpAnkiHubClient { + pub fn new>(token: S, client: Client) -> HttpAnkiHubClient { + let endpoint = match env::var("ANKIHUB_APP_URL") { + Ok(url) => { + if let Ok(u) = Url::try_from(url.as_str()) { + u.join("api/").unwrap().to_string() + } else { + DEFAULT_API_URL.to_string() + } + } + Err(_) => DEFAULT_API_URL.to_string(), + }; + HttpAnkiHubClient { + token: token.into(), + endpoint: Url::try_from(endpoint.as_str()).unwrap(), + client, + } + } + + async fn request(&self, method: &str, data: &T) -> Result { + let url = self.endpoint.join(method).unwrap(); + let mut builder = self.client.post(url).header( + reqwest::header::ACCEPT, + format!("application/json; version={API_VERSION}"), + ); + if !self.token.is_empty() { + builder = builder.header("Authorization", format!("Token {}", self.token)); + } + builder.json(&data).send().await + } + + pub async fn login(&self, data: LoginRequest) -> Result { + self.request("login/", &data).await + } + + pub async fn logout(&self) -> Result { + self.request("logout/", "").await + } +} diff --git a/rslib/src/ankihub/login.rs b/rslib/src/ankihub/login.rs new file mode 100644 index 000000000..42aacec59 --- /dev/null +++ b/rslib/src/ankihub/login.rs @@ -0,0 +1,63 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use lazy_static::lazy_static; +use regex::Regex; +use reqwest::Client; +use serde; +use serde::Deserialize; +use serde::Serialize; + +use crate::ankihub::http_client::HttpAnkiHubClient; +use crate::prelude::*; + +#[derive(Serialize, Deserialize, Debug)] +pub struct LoginRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + pub password: String, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct LoginResponse { + pub token: Option, +} + +pub async fn ankihub_login>( + id: S, + password: S, + client: Client, +) -> Result { + let client = HttpAnkiHubClient::new("", client); + lazy_static! { + static ref EMAIL_RE: Regex = + Regex::new(r"^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$") + .unwrap(); + } + let mut request = LoginRequest { + username: None, + email: None, + password: password.into(), + }; + let id: String = id.into(); + if EMAIL_RE.is_match(&id) { + request.email = Some(id); + } else { + request.username = Some(id); + } + client + .login(request) + .await? + .json::() + .await + .map_err(|e| e.into()) +} + +pub async fn ankihub_logout>(token: S, client: Client) -> Result<()> { + let client = HttpAnkiHubClient::new(token, client); + client.logout().await?; + + Ok(()) +} diff --git a/rslib/src/ankihub/mod.rs b/rslib/src/ankihub/mod.rs new file mode 100644 index 000000000..0a06c5533 --- /dev/null +++ b/rslib/src/ankihub/mod.rs @@ -0,0 +1,5 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +pub mod http_client; +pub mod login; diff --git a/rslib/src/backend/ankihub.rs b/rslib/src/backend/ankihub.rs new file mode 100644 index 000000000..e48e35e59 --- /dev/null +++ b/rslib/src/backend/ankihub.rs @@ -0,0 +1,34 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::Backend; +use crate::ankihub::login::ankihub_login; +use crate::ankihub::login::ankihub_logout; +use crate::ankihub::login::LoginResponse; +use crate::prelude::*; + +impl From for anki_proto::ankihub::LoginResponse { + fn from(value: LoginResponse) -> Self { + anki_proto::ankihub::LoginResponse { + token: value.token.unwrap_or_default(), + } + } +} + +impl crate::services::BackendAnkiHubService for Backend { + fn ankihub_login( + &self, + input: anki_proto::ankihub::LoginRequest, + ) -> Result { + let rt = self.runtime_handle(); + let fut = ankihub_login(input.id, input.password, self.web_client()); + + rt.block_on(fut).map(|a| a.into()) + } + + fn ankihub_logout(&self, input: anki_proto::ankihub::LogoutRequest) -> Result<()> { + let rt = self.runtime_handle(); + let fut = ankihub_logout(input.token, self.web_client()); + rt.block_on(fut) + } +} diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index 721d84fc9..137124b45 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -37,6 +37,7 @@ impl From for BoolKey { BoolKeyProto::RandomOrderReposition => BoolKey::RandomOrderReposition, BoolKeyProto::ShiftPositionOfExistingCards => BoolKey::ShiftPositionOfExistingCards, BoolKeyProto::RenderLatex => BoolKey::RenderLatex, + BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled, } } } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 8b42d20bc..cff86d7e4 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -3,6 +3,7 @@ mod adding; mod ankidroid; +mod ankihub; mod ankiweb; mod card_rendering; mod collection; diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index b8ae82d54..c946f7c34 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -40,6 +40,7 @@ pub enum BoolKey { WithScheduling, WithDeckConfigs, Fsrs, + LoadBalancerEnabled, #[strum(to_string = "normalize_note_text")] NormalizeNoteText, #[strum(to_string = "dayLearnFirst")] @@ -73,6 +74,7 @@ impl Collection { | BoolKey::CardCountsSeparateInactive | BoolKey::RestorePositionBrowser | BoolKey::RestorePositionReviewer + | BoolKey::LoadBalancerEnabled | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), // other options default to false diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index f023d1273..8d3251f49 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -5,6 +5,7 @@ pub mod adding; pub(crate) mod ankidroid; +pub mod ankihub; pub mod backend; pub mod browser_table; pub mod card; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 541e5d520..4ea1c005a 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -82,7 +82,7 @@ impl Op { Op::BuildFilteredDeck => tr.actions_build_filtered_deck(), Op::RebuildFilteredDeck => tr.actions_build_filtered_deck(), Op::EmptyFilteredDeck => tr.studying_empty(), - Op::SetCurrentDeck => tr.browsing_change_deck(), + Op::SetCurrentDeck => tr.browsing_select_deck(), Op::UpdateDeckConfig => tr.deck_config_title(), Op::AddNotetype => tr.actions_add_notetype(), Op::RemoveNotetype => tr.actions_remove_notetype(), diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index cfeae3fa3..e3b7e6f3c 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -98,6 +98,7 @@ impl Collection { show_intervals_on_buttons: self .get_config_bool(BoolKey::ShowIntervalsAboveAnswerButtons), time_limit_secs: self.get_answer_time_limit_secs(), + load_balancer_enabled: self.get_config_bool(BoolKey::LoadBalancerEnabled), }) } @@ -117,6 +118,8 @@ impl Collection { s.show_intervals_on_buttons, )?; self.set_answer_time_limit_secs(s.time_limit_secs)?; + self.set_config_bool_inner(BoolKey::LoadBalancerEnabled, s.load_balancer_enabled)?; + Ok(()) } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index d23f7996a..5e85dce1f 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -16,6 +16,7 @@ use revlog::RevlogEntryPartial; use super::fsrs::weights::ignore_revlogs_before_ms_from_config; use super::queue::BuryMode; +use super::states::load_balancer::LoadBalancerContext; use super::states::steps::LearningSteps; use super::states::CardState; use super::states::FilteredState; @@ -26,6 +27,7 @@ use super::timespan::answer_button_time_collapsible; use super::timing::SchedTimingToday; use crate::card::CardQueue; use crate::card::CardType; +use crate::config::BoolKey; use crate::deckconfig::DeckConfig; use crate::deckconfig::LeechAction; use crate::decks::Deck; @@ -77,7 +79,10 @@ impl CardStateUpdater { /// Returns information required when transitioning from one card state to /// another with `next_states()`. This separate structure decouples the /// state handling code from the rest of the Anki codebase. - pub(crate) fn state_context(&self) -> StateContext<'_> { + pub(crate) fn state_context<'a>( + &'a self, + load_balancer: Option>, + ) -> StateContext<'a> { StateContext { fuzz_factor: get_fuzz_factor(self.fuzz_seed), steps: self.learn_steps(), @@ -89,6 +94,8 @@ impl CardStateUpdater { interval_multiplier: self.config.inner.interval_multiplier, maximum_review_interval: self.config.inner.maximum_review_interval, leech_threshold: self.config.inner.leech_threshold, + load_balancer: load_balancer + .map(|load_balancer| load_balancer.set_fuzz_seed(self.fuzz_seed)), relearn_steps: self.relearn_steps(), lapse_multiplier: self.config.inner.lapse_multiplier, minimum_lapse_interval: self.config.inner.minimum_lapse_interval, @@ -215,9 +222,36 @@ impl Collection { /// Return the next states that will be applied for each answer button. pub fn get_scheduling_states(&mut self, cid: CardId) -> Result { let card = self.storage.get_card(cid)?.or_not_found(cid)?; + let deck = self.get_deck(card.deck_id)?.or_not_found(card.deck_id)?; + + let note_id = deck + .config_id() + .map(|deck_config_id| self.get_deck_config(deck_config_id, false)) + .transpose()? + .flatten() + .map(|deck_config| deck_config.inner.bury_reviews) + .unwrap_or(false) + .then_some(card.note_id); + let ctx = self.card_state_updater(card)?; let current = ctx.current_card_state(); - let state_ctx = ctx.state_context(); + + let load_balancer = self + .get_config_bool(BoolKey::LoadBalancerEnabled) + .then(|| { + let deckconfig_id = deck.config_id(); + + self.state.card_queues.as_ref().and_then(|card_queues| { + Some( + card_queues + .load_balancer + .review_context(note_id, deckconfig_id?), + ) + }) + }) + .flatten(); + + let state_ctx = ctx.state_context(load_balancer); Ok(current.next_states(&state_ctx)) } @@ -305,11 +339,26 @@ impl Collection { card.custom_data = data; card.validate_custom_data()?; } + self.update_card_inner(&mut card, original, usn)?; if answer.new_state.leeched() { self.add_leech_tag(card.note_id)?; } + if card.queue == CardQueue::Review { + let deck = self.get_deck(card.deck_id)?; + if let Some(card_queues) = self.state.card_queues.as_mut() { + if let Some(deckconfig_id) = deck.and_then(|deck| deck.config_id()) { + card_queues.load_balancer.add_card( + card.id, + card.note_id, + deckconfig_id, + card.interval, + ) + } + } + } + self.update_queues_after_answering_card( &card, timing, diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index 2396f14ac..b41d98d76 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -5,8 +5,10 @@ use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use fsrs::simulate; use fsrs::SimulatorConfig; +use fsrs::DEFAULT_PARAMETERS; use itertools::Itertools; +use crate::card::CardQueue; use crate::prelude::*; use crate::search::SortMode; @@ -22,9 +24,15 @@ impl Collection { .get_revlog_entries_for_searched_cards_in_card_order()?; let cards = guard.col.storage.all_searched_cards()?; drop(guard); + let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; + let converted_cards = cards + .into_iter() + .filter(|c| c.queue != CardQueue::Suspended && c.queue != CardQueue::PreviewRepeat) + .filter_map(|c| Card::convert(c, days_elapsed, req.days_to_simulate)) + .collect_vec(); let p = self.get_optimal_retention_parameters(revlogs)?; let config = SimulatorConfig { - deck_size: req.deck_size as usize, + deck_size: req.deck_size as usize + converted_cards.len(), learn_span: req.days_to_simulate as usize, max_cost_perday: f32::MAX, max_ivl: req.max_interval as f32, @@ -40,7 +48,19 @@ impl Collection { learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, }; - let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; + let parameters = if req.weights.is_empty() { + DEFAULT_PARAMETERS.to_vec() + } else if req.weights.len() != 19 { + if req.weights.len() == 17 { + let mut parameters = req.weights.to_vec(); + parameters.extend_from_slice(&[0.0, 0.0]); + parameters + } else { + return Err(AnkiError::FsrsWeightsInvalid); + } + } else { + req.weights.to_vec() + }; let ( accumulated_knowledge_acquisition, daily_review_count, @@ -48,15 +68,10 @@ impl Collection { daily_time_cost, ) = simulate( &config, - &req.weights, + ¶meters, req.desired_retention, None, - Some( - cards - .into_iter() - .filter_map(|c| Card::convert(c, days_elapsed)) - .collect_vec(), - ), + Some(converted_cards), ); Ok(SimulateFsrsReviewResponse { accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_vec(), @@ -68,19 +83,42 @@ impl Collection { } impl Card { - fn convert(card: Card, days_elapsed: i32) -> Option { + fn convert(card: Card, days_elapsed: i32, day_to_simulate: u32) -> Option { match card.memory_state { - Some(state) => { - let due = card.original_or_current_due(); - let relative_due = due - days_elapsed; - Some(fsrs::Card { - difficulty: state.difficulty, - stability: state.stability, - last_date: (relative_due - card.interval as i32) as f32, - due: relative_due as f32, - }) - } - None => None, + Some(state) => match card.queue { + CardQueue::DayLearn | CardQueue::Review => { + let due = card.original_or_current_due(); + let relative_due = due - days_elapsed; + Some(fsrs::Card { + difficulty: state.difficulty, + stability: state.stability, + last_date: (relative_due - card.interval as i32) as f32, + due: relative_due as f32, + }) + } + CardQueue::New => Some(fsrs::Card { + difficulty: 1e-10, + stability: 1e-10, + last_date: 0.0, + due: day_to_simulate as f32, + }), + CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => { + Some(fsrs::Card { + difficulty: state.difficulty, + stability: state.stability, + last_date: 0.0, + due: 0.0, + }) + } + CardQueue::PreviewRepeat => None, + CardQueue::Suspended => None, + }, + None => Some(fsrs::Card { + difficulty: 1e-10, + stability: 1e-10, + last_date: 0.0, + due: day_to_simulate as f32, + }), } } } diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs index 11ce8d4ba..6567e922b 100644 --- a/rslib/src/scheduler/queue/builder/mod.rs +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -25,6 +25,7 @@ use crate::deckconfig::ReviewCardOrder; use crate::deckconfig::ReviewMix; use crate::decks::limits::LimitTreeMap; use crate::prelude::*; +use crate::scheduler::states::load_balancer::LoadBalancer; use crate::scheduler::timing::SchedTimingToday; /// Temporary holder for review cards that will be built into a queue. @@ -99,13 +100,14 @@ pub(super) struct QueueSortOptions { pub(super) new_review_mix: ReviewMix, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub(super) struct QueueBuilder { pub(super) new: Vec, pub(super) review: Vec, pub(super) learning: Vec, pub(super) day_learning: Vec, limits: LimitTreeMap, + load_balancer: LoadBalancer, context: Context, } @@ -144,12 +146,19 @@ impl QueueBuilder { let sort_options = sort_options(&root_deck, &config_map); let deck_map = col.storage.get_decks_map()?; + let did_to_dcid = deck_map + .values() + .filter_map(|deck| Some((deck.id, deck.config_id()?))) + .collect::>(); + let load_balancer = LoadBalancer::new(timing.days_elapsed, did_to_dcid, &col.storage)?; + Ok(QueueBuilder { new: Vec::new(), review: Vec::new(), learning: Vec::new(), day_learning: Vec::new(), limits, + load_balancer, context: Context { timing, config_map, @@ -201,6 +210,7 @@ impl QueueBuilder { learn_ahead_secs, current_day: self.context.timing.days_elapsed, build_time: TimestampMillis::now(), + load_balancer: self.load_balancer, current_learning_cutoff: now, } } diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs index 47b25d791..e8bc7e4b2 100644 --- a/rslib/src/scheduler/queue/mod.rs +++ b/rslib/src/scheduler/queue/mod.rs @@ -23,6 +23,7 @@ use self::undo::QueueUpdate; use super::states::SchedulingStates; use super::timing::SchedTimingToday; use crate::prelude::*; +use crate::scheduler::states::load_balancer::LoadBalancer; use crate::timestamp::TimestampSecs; #[derive(Debug)] @@ -37,6 +38,7 @@ pub(crate) struct CardQueues { /// counts are zero. Ensures we don't show a newly-due learning card after a /// user returns from editing a review card. current_learning_cutoff: TimestampSecs, + pub(crate) load_balancer: LoadBalancer, } #[derive(Debug, Copy, Clone)] diff --git a/rslib/src/scheduler/queue/undo.rs b/rslib/src/scheduler/queue/undo.rs index ef7cbb68c..bf7f53f0e 100644 --- a/rslib/src/scheduler/queue/undo.rs +++ b/rslib/src/scheduler/queue/undo.rs @@ -38,6 +38,18 @@ impl Collection { } queues.push_undo_entry(update.entry); } + + if let Some(card_queues) = self.state.card_queues.as_mut() { + match &update.entry { + QueueEntry::IntradayLearning(entry) => { + card_queues.load_balancer.remove_card(entry.id); + } + QueueEntry::Main(entry) => { + card_queues.load_balancer.remove_card(entry.id); + } + } + } + self.save_undo(UndoableQueueChange::CardAnswerUndone(update)); Ok(()) diff --git a/rslib/src/scheduler/states/fuzz.rs b/rslib/src/scheduler/states/fuzz.rs index bb5a15e54..5c7e90809 100644 --- a/rslib/src/scheduler/states/fuzz.rs +++ b/rslib/src/scheduler/states/fuzz.rs @@ -34,7 +34,10 @@ static FUZZ_RANGES: [FuzzRange; 3] = [ impl<'a> StateContext<'a> { /// Apply fuzz, respecting the passed bounds. pub(crate) fn with_review_fuzz(&self, interval: f32, minimum: u32, maximum: u32) -> u32 { - with_review_fuzz(self.fuzz_factor, interval, minimum, maximum) + self.load_balancer + .as_ref() + .and_then(|load_balancer| load_balancer.find_interval(interval, minimum, maximum)) + .unwrap_or_else(|| with_review_fuzz(self.fuzz_factor, interval, minimum, maximum)) } } @@ -74,7 +77,7 @@ pub(crate) fn with_review_fuzz( /// Return the bounds of the fuzz range, respecting `minimum` and `maximum`. /// Ensure the upper bound is larger than the lower bound, if `maximum` allows /// it and it is larger than 1. -fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u32) { +pub(crate) fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u32) { let minimum = minimum.min(maximum); let interval = interval.clamp(minimum as f32, maximum as f32); let (mut lower, mut upper) = fuzz_bounds(interval); diff --git a/rslib/src/scheduler/states/load_balancer.rs b/rslib/src/scheduler/states/load_balancer.rs new file mode 100644 index 000000000..99fa9b39b --- /dev/null +++ b/rslib/src/scheduler/states/load_balancer.rs @@ -0,0 +1,239 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::collections::HashMap; +use std::collections::HashSet; + +use rand::distributions::Distribution; +use rand::distributions::WeightedIndex; +use rand::rngs::StdRng; +use rand::SeedableRng; + +use super::fuzz::constrained_fuzz_bounds; +use crate::card::CardId; +use crate::deckconfig::DeckConfigId; +use crate::notes::NoteId; +use crate::prelude::*; +use crate::storage::SqliteStorage; + +const MAX_LOAD_BALANCE_INTERVAL: usize = 90; +// due to the nature of load balancing, we may schedule things in the future and +// so need to keep more than just the `MAX_LOAD_BALANCE_INTERVAL` days in our +// cache. a flat 10% increase over the max interval should be enough to not have +// problems +const LOAD_BALANCE_DAYS: usize = (MAX_LOAD_BALANCE_INTERVAL as f32 * 1.1) as usize; +const SIBLING_PENALTY: f32 = 0.001; + +#[derive(Debug, Default)] +struct LoadBalancerDay { + cards: Vec<(CardId, NoteId)>, + notes: HashSet, +} + +impl LoadBalancerDay { + fn add(&mut self, cid: CardId, nid: NoteId) { + self.cards.push((cid, nid)); + self.notes.insert(nid); + } + + fn remove(&mut self, cid: CardId) { + if let Some(index) = self.cards.iter().position(|c| c.0 == cid) { + let (_, rnid) = self.cards.swap_remove(index); + + // if all cards of a note are removed, remove note + if !self.cards.iter().any(|(_cid, nid)| *nid == rnid) { + self.notes.remove(&rnid); + } + } + } + + fn has_sibling(&self, nid: &NoteId) -> bool { + self.notes.contains(nid) + } +} + +pub struct LoadBalancerContext<'a> { + load_balancer: &'a LoadBalancer, + note_id: Option, + deckconfig_id: DeckConfigId, + fuzz_seed: Option, +} + +impl<'a> LoadBalancerContext<'a> { + pub fn find_interval(&self, interval: f32, minimum: u32, maximum: u32) -> Option { + self.load_balancer.find_interval( + interval, + minimum, + maximum, + self.deckconfig_id, + self.fuzz_seed, + self.note_id, + ) + } + + pub fn set_fuzz_seed(mut self, fuzz_seed: Option) -> Self { + self.fuzz_seed = fuzz_seed; + self + } +} + +#[derive(Debug)] +pub struct LoadBalancer { + /// Load balancer operates at the preset level, it only counts + /// cards in the same preset as the card being balanced. + days_by_preset: HashMap, +} + +impl LoadBalancer { + pub fn new( + today: u32, + did_to_dcid: HashMap, + storage: &SqliteStorage, + ) -> Result { + let cards_on_each_day = + storage.get_all_cards_due_in_range(today, today + LOAD_BALANCE_DAYS as u32)?; + let days_by_preset = cards_on_each_day + .into_iter() + // for each day, group all cards on each day by their deck config id + .map(|cards_on_day| { + cards_on_day + .into_iter() + .filter_map(|(cid, nid, did)| Some((cid, nid, did_to_dcid.get(&did)?))) + .fold( + HashMap::<_, Vec<_>>::new(), + |mut day_group_by_dcid, (cid, nid, dcid)| { + day_group_by_dcid.entry(dcid).or_default().push((cid, nid)); + + day_group_by_dcid + }, + ) + }) + .enumerate() + // consolidate card by day groups into groups of [LoadBalancerDay; LOAD_BALANCE_DAYS]s + .fold( + HashMap::new(), + |mut deckconfig_group, (day_index, days_grouped_by_dcid)| { + for (group, cards) in days_grouped_by_dcid.into_iter() { + let day = deckconfig_group + .entry(*group) + .or_insert_with(|| std::array::from_fn(|_| LoadBalancerDay::default())); + + for (cid, nid) in cards { + day[day_index].add(cid, nid); + } + } + + deckconfig_group + }, + ); + + Ok(LoadBalancer { days_by_preset }) + } + + pub fn review_context( + &self, + note_id: Option, + deckconfig_id: DeckConfigId, + ) -> LoadBalancerContext { + LoadBalancerContext { + load_balancer: self, + note_id, + deckconfig_id, + fuzz_seed: None, + } + } + + /// The main load balancing function + /// Given an interval and min/max range it does its best to find the best + /// day within the standard fuzz range to schedule a card that leads to + /// a consistent workload. + /// + /// It works by using a weighted random, assigning a weight between 0.0 and + /// 1.0 to each day in the fuzz range for an interval. + /// the weight takes into account the number of cards due on a day as well + /// as the interval itself. + /// `weight = (1 / (cards_due))**2 * (1 / target_interval)` + /// + /// By including the target_interval in the calculation, the interval is + /// slightly biased to be due earlier. Without this, the load balancer + /// ends up being very biased towards later days, especially around + /// graduating intervals. + /// + /// if a note_id is provided, it attempts to avoid placing a card on a day + /// that already has that note_id (aka avoid siblings) + fn find_interval( + &self, + interval: f32, + minimum: u32, + maximum: u32, + deckconfig_id: DeckConfigId, + fuzz_seed: Option, + note_id: Option, + ) -> Option { + // if we're sending a card far out into the future, the need to balance is low + if interval as usize > MAX_LOAD_BALANCE_INTERVAL + || minimum as usize > MAX_LOAD_BALANCE_INTERVAL + { + return None; + } + + let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum); + + let days = self.days_by_preset.get(&deckconfig_id)?; + let interval_days = &days[before_days as usize..=after_days as usize]; + + // calculate weights for each day + let intervals_and_weights = interval_days + .iter() + .enumerate() + .map(|(interval_index, interval_day)| { + let target_interval = interval_index as u32 + before_days; + + // if there is a sibling on this day, give it a very low weight + let sibling_multiplier = note_id + .and_then(|note_id| { + interval_day + .has_sibling(¬e_id) + .then_some(SIBLING_PENALTY) + }) + .unwrap_or(1.0); + + let weight = match interval_day.cards.len() { + 0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight + card_count => { + let card_count_weight = (1.0 / card_count as f32).powi(2); + let card_interval_weight = 1.0 / target_interval as f32; + + card_count_weight * card_interval_weight * sibling_multiplier + } + }; + + (target_interval, weight) + }) + .collect::>(); + + let mut rng = StdRng::seed_from_u64(fuzz_seed?); + + let weighted_intervals = + WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?; + + let selected_interval_index = weighted_intervals.sample(&mut rng); + Some(intervals_and_weights[selected_interval_index].0) + } + + pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) { + if let Some(days) = self.days_by_preset.get_mut(&dcid) { + if let Some(day) = days.get_mut(interval as usize) { + day.add(cid, nid); + } + } + } + + pub fn remove_card(&mut self, cid: CardId) { + for (_, days) in self.days_by_preset.iter_mut() { + for day in days.iter_mut() { + day.remove(cid); + } + } + } +} diff --git a/rslib/src/scheduler/states/mod.rs b/rslib/src/scheduler/states/mod.rs index 23eb9a261..b721e1da2 100644 --- a/rslib/src/scheduler/states/mod.rs +++ b/rslib/src/scheduler/states/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod filtered; pub(crate) mod fuzz; pub(crate) mod interval_kind; pub(crate) mod learning; +pub(crate) mod load_balancer; pub(crate) mod new; pub(crate) mod normal; pub(crate) mod preview_filter; @@ -17,6 +18,7 @@ pub use filtered::FilteredState; use fsrs::NextStates; pub(crate) use interval_kind::IntervalKind; pub use learning::LearnState; +use load_balancer::LoadBalancerContext; pub use new::NewState; pub use normal::NormalState; pub use preview_filter::PreviewState; @@ -99,6 +101,7 @@ pub(crate) struct StateContext<'a> { pub interval_multiplier: f32, pub maximum_review_interval: u32, pub leech_threshold: u32, + pub load_balancer: Option>, // relearning pub relearn_steps: LearningSteps<'a>, @@ -133,6 +136,7 @@ impl<'a> StateContext<'a> { interval_multiplier: 1.0, maximum_review_interval: 36500, leech_threshold: 8, + load_balancer: None, relearn_steps: LearningSteps::new(&[10.0]), lapse_multiplier: 0.0, minimum_lapse_interval: 1, diff --git a/rslib/src/stats/graphs/future_due.rs b/rslib/src/stats/graphs/future_due.rs index ef7da2ad3..4671766e5 100644 --- a/rslib/src/stats/graphs/future_due.rs +++ b/rslib/src/stats/graphs/future_due.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use anki_proto::stats::graphs_response::FutureDue; use super::GraphsContext; +use crate::card::CardQueue; use crate::scheduler::timing::is_unix_epoch_timestamp; impl GraphsContext { @@ -13,7 +14,7 @@ impl GraphsContext { let mut have_backlog = false; let mut due_by_day: HashMap = Default::default(); for c in &self.cards { - if c.queue as i8 <= 0 { + if matches!(c.queue, CardQueue::New | CardQueue::Suspended) { continue; } let due = c.original_or_current_due(); @@ -24,6 +25,10 @@ impl GraphsContext { due - (self.days_elapsed as i32) }; + // still want to filtered out buried cards that are due today + if due_day == 0 && matches!(c.queue, CardQueue::UserBuried | CardQueue::SchedBuried) { + continue; + } have_backlog |= due_day < 0; *due_by_day.entry(due_day).or_default() += 1; } diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index d51236696..f290a7f71 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -581,6 +581,32 @@ impl super::SqliteStorage { Ok(()) } + pub(crate) fn get_all_cards_due_in_range( + &self, + min_day: u32, + max_day: u32, + ) -> Result>> { + Ok(self + .db + .prepare_cached("select id, nid, did, due from cards where due >= ?1 and due < ?2 ")? + .query_and_then([min_day, max_day], |row: &Row| { + Ok::<_, rusqlite::Error>(( + row.get::<_, CardId>(0)?, + row.get::<_, NoteId>(1)?, + row.get::<_, DeckId>(2)?, + row.get::<_, i32>(3)?, + )) + })? + .flatten() + .fold( + vec![Vec::new(); (max_day - min_day) as usize], + |mut acc, (card_id, note_id, deck_id, due)| { + acc[due as usize - min_day as usize].push((card_id, note_id, deck_id)); + acc + }, + )) + } + pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result { // NOTE: this line is obsolete in v3 as it's run on queue build, but kept to // prevent errors for v1/v2 users before they upgrade diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e6a0120e3..1ccbe9f82 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.80" +channel = "1.80.1" diff --git a/ts/editor/rich-text-input/RichTextInput.svelte b/ts/editor/rich-text-input/RichTextInput.svelte index dc75e1573..8cacc8ac7 100644 --- a/ts/editor/rich-text-input/RichTextInput.svelte +++ b/ts/editor/rich-text-input/RichTextInput.svelte @@ -242,7 +242,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use:attachContentEditable={{ stylesDidLoad }} on:focusin on:focusout - /> + > {#await Promise.all([richTextPromise, stylesDidLoad]) then _}
diff --git a/ts/lib/components/Icon.svelte b/ts/lib/components/Icon.svelte index 5b5608611..89aacf525 100644 --- a/ts/lib/components/Icon.svelte +++ b/ts/lib/components/Icon.svelte @@ -6,10 +6,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { IconData } from "./types"; export let icon: IconData; + + let component: any = null; + if (import.meta.env) { + // @ts-expect-error internal property + component = icon.component; + } -{#if import.meta.env} - +{#if component} + {:else} {@html icon.url} {/if} diff --git a/ts/lib/components/types.ts b/ts/lib/components/types.ts index 9a13d5ebe..9a2105d9e 100644 --- a/ts/lib/components/types.ts +++ b/ts/lib/components/types.ts @@ -1,8 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import type { ComponentType } from "svelte"; - export type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; @@ -20,5 +18,4 @@ export enum HelpItemScheduler { export type IconData = { url: string; - component: ComponentType; }; diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index e836e1dc8..569dee9dd 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -7,10 +7,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ComputeRetentionProgress, type ComputeWeightsProgress, } from "@generated/anki/collection_pb"; - import { ComputeOptimalRetentionRequest } from "@generated/anki/scheduler_pb"; + import { + ComputeOptimalRetentionRequest, + SimulateFsrsReviewRequest, + type SimulateFsrsReviewResponse, + } from "@generated/anki/scheduler_pb"; import { computeFsrsWeights, computeOptimalRetention, + simulateFsrsReview, evaluateWeights, setWantsAbort, } from "@generated/backend"; @@ -28,6 +33,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import Warning from "./Warning.svelte"; import WeightsInputRow from "./WeightsInputRow.svelte"; import WeightsSearchRow from "./WeightsSearchRow.svelte"; + import { renderSimulationChart, type Point } from "../graphs/simulator"; + import Graph from "../graphs/Graph.svelte"; + import HoverColumns from "../graphs/HoverColumns.svelte"; + import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte"; + import AxisTicks from "../graphs/AxisTicks.svelte"; + import NoDataOverlay from "../graphs/NoDataOverlay.svelte"; + import TableData from "../graphs/TableData.svelte"; + import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -68,6 +81,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html optimalRetentionRequest.daysToSimulate = 3650; } + const simulateFsrsRequest = new SimulateFsrsReviewRequest({ + weights: $config.fsrsWeights, + desiredRetention: $config.desiredRetention, + deckSize: 0, + daysToSimulate: 365, + newLimit: $config.newPerDay, + reviewLimit: $config.reviewsPerDay, + maxInterval: $config.maximumReviewInterval, + search: `preset:"${state.getCurrentName()}" -is:suspended`, + }); + function getRetentionWarning(retention: number): string { const decay = -0.5; const factor = 0.9 ** (1 / decay) - 1; @@ -256,6 +280,69 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) }); } + + let tableData: TableDatum[] = [] as any; + const bounds = defaultGraphBounds(); + let svg = null as HTMLElement | SVGElement | null; + const title = tr.statisticsReviewsTitle(); + let simulationNumber = 0; + + let points: Point[] = []; + + function movingAverage(y: number[], windowSize: number): number[] { + const result: number[] = []; + for (let i = 0; i < y.length; i++) { + let sum = 0; + let count = 0; + for (let j = Math.max(0, i - windowSize + 1); j <= i; j++) { + sum += y[j]; + count++; + } + result.push(sum / count); + } + return result; + } + + $: simulateProgressString = ""; + + async function simulateFsrs(): Promise { + let resp: SimulateFsrsReviewResponse | undefined; + simulationNumber += 1; + try { + await runWithBackendProgress( + async () => { + simulateFsrsRequest.weights = $config.fsrsWeights; + simulateFsrsRequest.desiredRetention = $config.desiredRetention; + simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`; + simulateProgressString = "processing..."; + resp = await simulateFsrsReview(simulateFsrsRequest); + }, + () => {}, + ); + } finally { + if (resp) { + simulateProgressString = ""; + const dailyTimeCost = movingAverage( + resp.dailyTimeCost, + Math.round(simulateFsrsRequest.daysToSimulate / 50), + ); + points = points.concat( + dailyTimeCost.map((v, i) => ({ + x: i, + y: v, + label: simulationNumber, + })), + ); + tableData = renderSimulationChart(svg as SVGElement, bounds, points); + } + } + } + + function clearSimulation(): void { + points = points.filter((p) => p.label !== simulationNumber); + simulationNumber = Math.max(0, simulationNumber - 1); + tableData = renderSimulationChart(svg as SVGElement, bounds, points); + }
+
+
+ FSRS simulator (experimental) + + + openHelpModal("simulateFsrsReview")}> + Days to simulate + + + + + openHelpModal("simulateFsrsReview")}> + Additional new cards to simulate + + + + + openHelpModal("simulateFsrsReview")}> + New cards/day + + + + + openHelpModal("simulateFsrsReview")}> + Maximum reviews/day + + + + + openHelpModal("simulateFsrsReview")}> + Maximum interval + + + + + + +
{simulateProgressString}
+ + + + + + + + + + + +
+
+ diff --git a/ts/routes/graphs/simulator.ts b/ts/routes/graphs/simulator.ts new file mode 100644 index 000000000..e47ea8a27 --- /dev/null +++ b/ts/routes/graphs/simulator.ts @@ -0,0 +1,221 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import { localizedNumber } from "@tslib/i18n"; +import { + axisBottom, + axisLeft, + bisector, + line, + max, + pointer, + rollup, + scaleLinear, + scaleTime, + schemeCategory10, + select, + timeFormat, +} from "d3"; + +import type { GraphBounds, TableDatum } from "./graph-helpers"; +import { setDataAvailable } from "./graph-helpers"; +import { hideTooltip, showTooltip } from "./tooltip-utils.svelte"; + +export interface Point { + x: number; + y: number; + label: number; +} + +export function renderSimulationChart( + svgElem: SVGElement, + bounds: GraphBounds, + data: Point[], +): TableDatum[] { + const svg = select(svgElem); + svg.selectAll(".lines").remove(); + svg.selectAll(".hover-columns").remove(); + svg.selectAll(".focus-line").remove(); + svg.selectAll(".legend").remove(); + if (data.length == 0) { + setDataAvailable(svg, false); + return []; + } + const trans = svg.transition().duration(600) as any; + + // Prepare data + const today = new Date(); + const convertedData = data.map(d => ({ + ...d, + date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000), + yMinutes: d.y / 60, + })); + const xMin = today; + const xMax = max(convertedData, d => d.date); + + const x = scaleTime() + .domain([xMin, xMax!]) + .range([bounds.marginLeft, bounds.width - bounds.marginRight]); + const formatDate = timeFormat("%Y-%m-%d"); + + svg.select(".x-ticks") + .call((selection) => + selection.transition(trans).call( + axisBottom(x) + .ticks(7) + .tickFormat((d: any) => formatDate(d)) + .tickSizeOuter(0), + ) + ) + .attr("direction", "ltr"); + // y scale + + const yTickFormat = (n: number): string => { + if (Math.round(n) != n) { + return ""; + } else { + return localizedNumber(n); + } + }; + + const yMax = max(convertedData, d => d.yMinutes)!; + const y = scaleLinear() + .range([bounds.height - bounds.marginBottom, bounds.marginTop]) + .domain([0, yMax]) + .nice(); + svg.select(".y-ticks") + .call((selection) => + selection.transition(trans).call( + axisLeft(y) + .ticks(bounds.height / 50) + .tickSizeOuter(0) + .tickFormat(yTickFormat as any), + ) + ) + .attr("direction", "ltr"); + + svg.select(".y-ticks") + .append("text") + .attr("class", "y-axis-title") + .attr("transform", "rotate(-90)") + .attr("y", 0 - bounds.marginLeft) + .attr("x", 0 - (bounds.height / 2)) + .attr("dy", "1em") + .attr("fill", "currentColor") + .style("text-anchor", "middle") + .text("Review Time per day (minutes)"); + + // x lines + const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]); + const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]); + + const color = schemeCategory10; + + svg.append("g") + .attr("class", "lines") + .attr("fill", "none") + .attr("stroke-width", 1.5) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .selectAll("path") + .data(Array.from(groups.entries())) + .join("path") + .style("mix-blend-mode", "multiply") + .attr("stroke", (d, i) => color[i % color.length]) + .attr("d", d => line()(d[1].map(p => [p[0], p[1]]))) + .attr("data-group", d => d[0]); + + const focusLine = svg.append("line") + .attr("class", "focus-line") + .attr("y1", bounds.marginTop) + .attr("y2", bounds.height - bounds.marginBottom) + .attr("stroke", "black") + .attr("stroke-width", 1) + .style("opacity", 0); + + const LongestGroupData = Array.from(groups.values()).reduce((a, b) => a.length > b.length ? a : b); + const barWidth = bounds.width / LongestGroupData.length; + + // hover/tooltip + svg.append("g") + .attr("class", "hover-columns") + .selectAll("rect") + .data(LongestGroupData) + .join("rect") + .attr("x", d => d[0] - barWidth / 2) + .attr("y", bounds.marginTop) + .attr("width", barWidth) + .attr("height", bounds.height - bounds.marginTop - bounds.marginBottom) + .attr("fill", "transparent") + .on("mousemove", mousemove) + .on("mouseout", hideTooltip); + + function mousemove(event: MouseEvent, d: any): void { + pointer(event, document.body); + const date = x.invert(d[0]); + + const groupData: { [key: string]: number } = {}; + + groups.forEach((groupPoints, key) => { + const bisect = bisector((d: number[]) => x.invert(d[0])).left; + const index = bisect(groupPoints, date); + const dataPoint = groupPoints[index - 1] || groupPoints[index]; + + if (dataPoint) { + groupData[key] = y.invert(dataPoint[1]); + } + }); + + focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1); + + let tooltipContent = `Date: ${timeFormat("%Y-%m-%d")(date)}
`; + for (const [key, value] of Object.entries(groupData)) { + tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes
`; + } + + showTooltip(tooltipContent, event.pageX, event.pageY); + } + + const legend = svg.append("g") + .attr("class", "legend") + .attr("font-family", "sans-serif") + .attr("font-size", 10) + .attr("text-anchor", "start") + .selectAll("g") + .data(Array.from(groups.keys())) + .join("g") + .attr("transform", (d, i) => `translate(0,${i * 20})`) + .attr("cursor", "pointer") + .on("click", (event, d) => toggleGroup(event, d)); + + legend.append("rect") + .attr("x", bounds.width - bounds.marginRight + 10) + .attr("width", 19) + .attr("height", 19) + .attr("fill", (d, i) => color[i % color.length]); + + legend.append("text") + .attr("x", bounds.width - bounds.marginRight + 34) + .attr("y", 9.5) + .attr("dy", "0.32em") + .text(d => `Simulation ${d}`); + + const toggleGroup = (event: MouseEvent, d: number) => { + const group = d; + const path = svg.select(`path[data-group="${group}"]`); + const hidden = path.classed("hidden"); + const target = event.currentTarget as HTMLElement; + + path.classed("hidden", !hidden); + path.style("display", () => hidden ? null : "none"); + + select(target).select("rect") + .style("opacity", hidden ? 1 : 0.5); + }; + + setDataAvailable(svg, true); + + const tableData: TableDatum[] = []; + + return tableData; +} diff --git a/yarn.lock b/yarn.lock index 097dfc33f..b38e9bc2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2010,7 +2010,7 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" -dprint@=0.47.2: +dprint@^0.47.2: version "0.47.2" resolved "https://registry.yarnpkg.com/dprint/-/dprint-0.47.2.tgz#f3aca518324b9948066652c87e4c4a3bc509869d" integrity sha512-geUcVIIrmLaY+YtuOl4gD7J/QCjsXZa5gUqre9sO6cgH0X/Fa9heBN3l/AWVII6rKPw45ATuCSDWz1pyO+HkPQ==