mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Merge remote-tracking branch 'origin/main' into svelte5
This commit is contained in:
parent
e81f0846b2
commit
53686b54c5
70 changed files with 1592 additions and 150 deletions
|
@ -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
|
||||
|
|
13
.dprint.json
13
.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"
|
||||
]
|
||||
}
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
__pycache__
|
||||
.mypy_cache
|
||||
.DS_Store
|
||||
anki.prof
|
||||
target
|
||||
|
|
|
@ -181,11 +181,13 @@ James Elmore <email@jameselmore.org>
|
|||
Ian Samir Yep Manzano <https://github.com/isym444>
|
||||
David Culley <6276049+davidculley@users.noreply.github.com>
|
||||
Rastislav Kish <rastislav.kish@protonmail.com>
|
||||
jake <jake@sharnoth.com>
|
||||
Expertium <https://github.com/Expertium>
|
||||
Christian Donat <https://github.com/cdonat2>
|
||||
Asuka Minato <https://asukaminato.eu.org>
|
||||
Dillon Baldwin <https://github.com/DillBal>
|
||||
Voczi <https://github.com/voczi>
|
||||
Ben Nguyen <105088397+bpnguyen107@users.noreply.github.com>
|
||||
********************
|
||||
|
||||
The text of the 3 clause BSD license follows:
|
||||
|
|
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -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",
|
||||
|
|
32
Cargo.toml
32
Cargo.toml
|
@ -1,27 +1,27 @@
|
|||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
authors = ["Ankitects Pty Ltd and contributors <https://help.ankiweb.net>"]
|
||||
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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
30
proto/anki/ankihub.proto
Normal file
30
proto/anki/ankihub.proto
Normal file
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"<br><br>{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
|
||||
######################################################################
|
||||
|
||||
|
|
157
qt/aqt/ankihub.py
Normal file
157
qt/aqt/ankihub.py
Normal file
|
@ -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"<h1>{tr.sync_ankihub_dialog_heading()}</h1>")
|
||||
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)
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -49,17 +49,14 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="lang">
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="video_driver">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
|
@ -72,17 +69,20 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="video_driver">
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="lang">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="sizeAdjustPolicy">
|
||||
<enum>QComboBox::AdjustToMinimumContentsLengthWithIcon</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="check_for_updates">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
|
@ -806,6 +806,9 @@
|
|||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -856,6 +859,19 @@
|
|||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="1">
|
||||
|
@ -1098,6 +1114,133 @@
|
|||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_3">
|
||||
<attribute name="title">
|
||||
<string>preferences_third_party_services</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<property name="spacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>preferences_third_party_description</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Maximum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>12</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_6">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string notr="true">AnkiHub</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="syncAnkiHubUser">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="syncAnkiHubLogout">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>sync_log_out_button</string>
|
||||
</property>
|
||||
<property name="autoDefault">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="syncAnkiHubLogin">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>sync_log_in_button</string>
|
||||
</property>
|
||||
<property name="autoDefault">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalspacer_13">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
|
@ -1125,6 +1268,7 @@
|
|||
<tabstops>
|
||||
<tabstop>lang</tabstop>
|
||||
<tabstop>video_driver</tabstop>
|
||||
<tabstop>check_for_updates</tabstop>
|
||||
<tabstop>theme</tabstop>
|
||||
<tabstop>styleComboBox</tabstop>
|
||||
<tabstop>uiScale</tabstop>
|
||||
|
@ -1164,6 +1308,8 @@
|
|||
<tabstop>weekly_backups</tabstop>
|
||||
<tabstop>monthly_backups</tabstop>
|
||||
<tabstop>tabWidget</tabstop>
|
||||
<tabstop>syncAnkiHubLogout</tabstop>
|
||||
<tabstop>syncAnkiHubLogin</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()")
|
||||
|
|
|
@ -625,6 +625,7 @@ exposed_backend_list = [
|
|||
"set_wants_abort",
|
||||
"evaluate_weights",
|
||||
"get_optimal_retention_parameters",
|
||||
"simulate_fsrs_review",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()`.
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -36,3 +36,4 @@ protobuf!(search, "search");
|
|||
protobuf!(stats, "stats");
|
||||
protobuf!(sync, "sync");
|
||||
protobuf!(tags, "tags");
|
||||
protobuf!(ankihub, "ankihub");
|
||||
|
|
62
rslib/src/ankihub/http_client/mod.rs
Normal file
62
rslib/src/ankihub/http_client/mod.rs
Normal file
|
@ -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<S: Into<String>>(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<T: Serialize + ?Sized>(&self, method: &str, data: &T) -> Result<Response> {
|
||||
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<Response> {
|
||||
self.request("login/", &data).await
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<Response> {
|
||||
self.request("logout/", "").await
|
||||
}
|
||||
}
|
63
rslib/src/ankihub/login.rs
Normal file
63
rslib/src/ankihub/login.rs
Normal file
|
@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct LoginResponse {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn ankihub_login<S: Into<String>>(
|
||||
id: S,
|
||||
password: S,
|
||||
client: Client,
|
||||
) -> Result<LoginResponse> {
|
||||
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::<LoginResponse>()
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub async fn ankihub_logout<S: Into<String>>(token: S, client: Client) -> Result<()> {
|
||||
let client = HttpAnkiHubClient::new(token, client);
|
||||
client.logout().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
5
rslib/src/ankihub/mod.rs
Normal file
5
rslib/src/ankihub/mod.rs
Normal file
|
@ -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;
|
34
rslib/src/backend/ankihub.rs
Normal file
34
rslib/src/backend/ankihub.rs
Normal file
|
@ -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<LoginResponse> 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<anki_proto::ankihub::LoginResponse> {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -37,6 +37,7 @@ impl From<BoolKeyProto> for BoolKey {
|
|||
BoolKeyProto::RandomOrderReposition => BoolKey::RandomOrderReposition,
|
||||
BoolKeyProto::ShiftPositionOfExistingCards => BoolKey::ShiftPositionOfExistingCards,
|
||||
BoolKeyProto::RenderLatex => BoolKey::RenderLatex,
|
||||
BoolKeyProto::LoadBalancerEnabled => BoolKey::LoadBalancerEnabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
mod adding;
|
||||
mod ankidroid;
|
||||
mod ankihub;
|
||||
mod ankiweb;
|
||||
mod card_rendering;
|
||||
mod collection;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
pub mod adding;
|
||||
pub(crate) mod ankidroid;
|
||||
pub mod ankihub;
|
||||
pub mod backend;
|
||||
pub mod browser_table;
|
||||
pub mod card;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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<LoadBalancerContext<'a>>,
|
||||
) -> 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<SchedulingStates> {
|
||||
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,
|
||||
|
|
|
@ -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<fsrs::Card> {
|
||||
fn convert(card: Card, days_elapsed: i32, day_to_simulate: u32) -> Option<fsrs::Card> {
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<NewCard>,
|
||||
pub(super) review: Vec<DueCard>,
|
||||
pub(super) learning: Vec<DueCard>,
|
||||
pub(super) day_learning: Vec<DueCard>,
|
||||
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::<HashMap<_, _>>();
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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);
|
||||
|
|
239
rslib/src/scheduler/states/load_balancer.rs
Normal file
239
rslib/src/scheduler/states/load_balancer.rs
Normal file
|
@ -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<NoteId>,
|
||||
}
|
||||
|
||||
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<NoteId>,
|
||||
deckconfig_id: DeckConfigId,
|
||||
fuzz_seed: Option<u64>,
|
||||
}
|
||||
|
||||
impl<'a> LoadBalancerContext<'a> {
|
||||
pub fn find_interval(&self, interval: f32, minimum: u32, maximum: u32) -> Option<u32> {
|
||||
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<u64>) -> 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<DeckConfigId, [LoadBalancerDay; LOAD_BALANCE_DAYS]>,
|
||||
}
|
||||
|
||||
impl LoadBalancer {
|
||||
pub fn new(
|
||||
today: u32,
|
||||
did_to_dcid: HashMap<DeckId, DeckConfigId>,
|
||||
storage: &SqliteStorage,
|
||||
) -> Result<LoadBalancer> {
|
||||
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<NoteId>,
|
||||
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<u64>,
|
||||
note_id: Option<NoteId>,
|
||||
) -> Option<u32> {
|
||||
// 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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<LoadBalancerContext<'a>>,
|
||||
|
||||
// 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,
|
||||
|
|
|
@ -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<i32, u32> = 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;
|
||||
}
|
||||
|
|
|
@ -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<Vec<Vec<(CardId, NoteId, DeckId)>>> {
|
||||
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<CongratsInfo> {
|
||||
// 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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
></div>
|
||||
|
||||
{#await Promise.all([richTextPromise, stylesDidLoad]) then _}
|
||||
<div class="rich-text-widgets">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if import.meta.env}
|
||||
<svelte:component this={icon.component} />
|
||||
{#if component}
|
||||
<svelte:component this={component} />
|
||||
{:else}
|
||||
{@html icon.url}
|
||||
{/if}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
|
@ -377,5 +464,94 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</details>
|
||||
</div>
|
||||
|
||||
<div class="m-2">
|
||||
<details>
|
||||
<summary>FSRS simulator (experimental)</summary>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.daysToSimulate}
|
||||
defaultValue={365}
|
||||
min={1}
|
||||
max={3650}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
Days to simulate
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.deckSize}
|
||||
defaultValue={0}
|
||||
min={1}
|
||||
max={100000}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
Additional new cards to simulate
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.newLimit}
|
||||
defaultValue={defaults.newPerDay}
|
||||
min={0}
|
||||
max={1000}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
New cards/day
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.reviewLimit}
|
||||
defaultValue={defaults.reviewsPerDay}
|
||||
min={0}
|
||||
max={1000}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
Maximum reviews/day
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<SpinBoxRow
|
||||
bind:value={simulateFsrsRequest.maxInterval}
|
||||
defaultValue={defaults.maximumReviewInterval}
|
||||
min={1}
|
||||
max={36500}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
|
||||
Maximum interval
|
||||
</SettingTitle>
|
||||
</SpinBoxRow>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => simulateFsrs()}
|
||||
>
|
||||
{"Simulate"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||
disabled={computing}
|
||||
on:click={() => clearSimulation()}
|
||||
>
|
||||
{"Clear last simulation"}
|
||||
</button>
|
||||
<div>{simulateProgressString}</div>
|
||||
|
||||
<Graph {title}>
|
||||
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||
<CumulativeOverlay />
|
||||
<HoverColumns />
|
||||
<AxisTicks {bounds} />
|
||||
<NoDataOverlay {bounds} />
|
||||
</svg>
|
||||
|
||||
<TableData {tableData} />
|
||||
</Graph>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
221
ts/routes/graphs/simulator.ts
Normal file
221
ts/routes/graphs/simulator.ts
Normal file
|
@ -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<SVGGElement>(".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<SVGGElement>(".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)}<br>`;
|
||||
for (const [key, value] of Object.entries(groupData)) {
|
||||
tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes<br>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
|
@ -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==
|
||||
|
|
Loading…
Reference in a new issue