Merge branch 'main' into Fix/remove-the-lower-limit-of-interval-when-set-due-date

This commit is contained in:
Jarrett Ye 2025-06-06 20:12:02 +08:00 committed by GitHub
commit 1f51591c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 728 additions and 214 deletions

View file

@ -1 +1 @@
25.05 25.06

View file

@ -230,7 +230,7 @@ KolbyML <https://github.com/KolbyML>
Adnane Taghi <dev@soleuniverse.me> Adnane Taghi <dev@soleuniverse.me>
Spiritual Father <https://github.com/spiritualfather> Spiritual Father <https://github.com/spiritualfather>
Emmanuel Ferdman <https://github.com/emmanuel-ferdman> Emmanuel Ferdman <https://github.com/emmanuel-ferdman>
Marvin Kopf <marvinkopf@outlook.com>
******************** ********************
The text of the 3 clause BSD license follows: The text of the 3 clause BSD license follows:

14
Cargo.lock generated
View file

@ -2295,7 +2295,7 @@ dependencies = [
[[package]] [[package]]
name = "fsrs" name = "fsrs"
version = "4.0.0" version = "4.0.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=33ec3ee4d5d73e704633469cf5bf1a42e620a524#33ec3ee4d5d73e704633469cf5bf1a42e620a524" source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=a7f7efc10f0a26b14ee348cc7402155685f2a24f#a7f7efc10f0a26b14ee348cc7402155685f2a24f"
dependencies = [ dependencies = [
"burn", "burn",
"itertools 0.14.0", "itertools 0.14.0",
@ -5010,9 +5010,9 @@ dependencies = [
[[package]] [[package]]
name = "priority-queue" name = "priority-queue"
version = "2.3.1" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef08705fa1589a1a59aa924ad77d14722cb0cd97b67dd5004ed5f4a4873fce8d" checksum = "5676d703dda103cbb035b653a9f11448c0a7216c7926bd35fcb5865475d0c970"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"equivalent", "equivalent",
@ -6290,18 +6290,18 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]] [[package]]
name = "snafu" name = "snafu"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" checksum = "320b01e011bf8d5d7a4a4a4be966d9160968935849c83b918827f6a435e7f627"
dependencies = [ dependencies = [
"snafu-derive", "snafu-derive",
] ]
[[package]] [[package]]
name = "snafu-derive" name = "snafu-derive"
version = "0.8.5" version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" checksum = "1961e2ef424c1424204d3a5d6975f934f56b6d50ff5732382d84ebf460e147f7"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",

View file

@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs] [workspace.dependencies.fsrs]
# version = "3.0.0" # version = "3.0.0"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git" git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "33ec3ee4d5d73e704633469cf5bf1a42e620a524" rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f"
# path = "../open-spaced-repetition/fsrs-rs" # path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies] [workspace.dependencies]
@ -125,7 +125,7 @@ serde_tuple = "0.5.0"
sha1 = "0.10.6" sha1 = "0.10.6"
sha2 = { version = "0.10.8" } sha2 = { version = "0.10.8" }
simple-file-manifest = "0.11.0" simple-file-manifest = "0.11.0"
snafu = { version = "0.8.5", features = ["rust_1_61"] } snafu = { version = "0.8.6", features = ["rust_1_61"] }
strum = { version = "0.26.3", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] }
syn = { version = "2.0.82", features = ["parsing", "printing"] } syn = { version = "2.0.82", features = ["parsing", "printing"] }
tar = "0.4.42" tar = "0.4.42"

View file

@ -3151,7 +3151,7 @@
}, },
{ {
"name": "priority-queue", "name": "priority-queue",
"version": "2.3.1", "version": "2.5.0",
"authors": "Gianmarco Garrisi <gianmarcogarrisi@tutanota.com>", "authors": "Gianmarco Garrisi <gianmarcogarrisi@tutanota.com>",
"repository": "https://github.com/garro95/priority-queue", "repository": "https://github.com/garro95/priority-queue",
"license": "LGPL-3.0-or-later OR MPL-2.0", "license": "LGPL-3.0-or-later OR MPL-2.0",
@ -4015,7 +4015,7 @@
}, },
{ {
"name": "snafu", "name": "snafu",
"version": "0.8.5", "version": "0.8.6",
"authors": "Jake Goulding <jake.goulding@gmail.com>", "authors": "Jake Goulding <jake.goulding@gmail.com>",
"repository": "https://github.com/shepmaster/snafu", "repository": "https://github.com/shepmaster/snafu",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
@ -4024,7 +4024,7 @@
}, },
{ {
"name": "snafu-derive", "name": "snafu-derive",
"version": "0.8.5", "version": "0.8.6",
"authors": "Jake Goulding <jake.goulding@gmail.com>", "authors": "Jake Goulding <jake.goulding@gmail.com>",
"repository": "https://github.com/shepmaster/snafu", "repository": "https://github.com/shepmaster/snafu",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",

@ -1 +1 @@
Subproject commit ca04132a8f82296f3e0ea22b74bb4221e1d11d3f Subproject commit 78412ce163d4dc50dd82f5b27cde3119086a2eb7

View file

@ -395,9 +395,10 @@ deck-config-weights = FSRS parameters
deck-config-compute-optimal-weights = Optimize FSRS parameters deck-config-compute-optimal-weights = Optimize FSRS parameters
deck-config-compute-minimum-recommended-retention = Minimum recommended retention deck-config-compute-minimum-recommended-retention = Minimum recommended retention
deck-config-optimize-button = Optimize Current Preset deck-config-optimize-button = Optimize Current Preset
deck-config-health-check = Check health when optimizing (slow)
deck-config-compute-button = Compute deck-config-compute-button = Compute
deck-config-ignore-before = Ignore cards reviewed before deck-config-ignore-before = Ignore cards reviewed before
deck-config-time-to-optimize = It's been a while - using the Optimize All button is recommended. deck-config-time-to-optimize = It's been a while - using the Optimize All Presets button is recommended.
deck-config-evaluate-button = Evaluate deck-config-evaluate-button = Evaluate
deck-config-desired-retention = Desired retention deck-config-desired-retention = Desired retention
deck-config-historical-retention = Historical retention deck-config-historical-retention = Historical retention
@ -470,11 +471,12 @@ deck-config-compute-optimal-retention-tooltip4 =
willing to invest more study time to achieve it. Setting your desired retention lower than the minimum willing to invest more study time to achieve it. Setting your desired retention lower than the minimum
is not recommended, as it will lead to a higher workload, because of the high forgetting rate. is not recommended, as it will lead to a higher workload, because of the high forgetting rate.
deck-config-please-save-your-changes-first = Please save your changes first. deck-config-please-save-your-changes-first = Please save your changes first.
deck-config-a-100-day-interval = deck-config-workload-factor-change = Approximate workload: {$factor}x
{ $days -> (compared to {$previousDR}% desired retention)
[one] A 100 day interval will become { $days } day. deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
*[other] A 100 day interval will become { $days } days. deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals.
} deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals.
deck-config-percent-of-reviews = deck-config-percent-of-reviews =
{ $reviews -> { $reviews ->
[one] { $pct }% of { $reviews } review [one] { $pct }% of { $reviews } review
@ -484,6 +486,15 @@ deck-config-percent-input = { $pct }%
deck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }... deck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }...
deck-config-fsrs-must-be-enabled = FSRS must be enabled first. deck-config-fsrs-must-be-enabled = FSRS must be enabled first.
deck-config-fsrs-params-optimal = The FSRS parameters currently appear to be optimal. deck-config-fsrs-params-optimal = The FSRS parameters currently appear to be optimal.
deck-config-fsrs-bad-fit-warning = Your memory is difficult for FSRS to predict. Recommendations:
- Suspend or reformulate leeches.
- Use the answer buttons consistently. Keep in mind that "Hard" is a passing grade, not a failing grade.
- Understand before you memorize.
If you follow these suggestions, performance will usually improve over the next few months.
deck-config-fsrs-good-fit = FSRS is well adjusted to your memory.
deck-config-fsrs-params-no-reviews = No reviews found. Make sure this preset is assigned to all decks (including subdecks) that you want to optimize, and try again. deck-config-fsrs-params-no-reviews = No reviews found. Make sure this preset is assigned to all decks (including subdecks) that you want to optimize, and try again.
deck-config-wait-for-audio = Wait for audio deck-config-wait-for-audio = Wait for audio
@ -512,6 +523,12 @@ deck-config-fsrs-simulator-radio-memorized = Memorized
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
deck-config-a-100-day-interval =
{ $days ->
[one] A 100 day interval will become { $days } day.
*[other] A 100 day interval will become { $days } days.
}
deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day
deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day
deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total

View file

@ -96,6 +96,7 @@ editing-image-occlusion-rectangle-tool = Rectangle
editing-image-occlusion-ellipse-tool = Ellipse editing-image-occlusion-ellipse-tool = Ellipse
editing-image-occlusion-polygon-tool = Polygon editing-image-occlusion-polygon-tool = Polygon
editing-image-occlusion-text-tool = Text editing-image-occlusion-text-tool = Text
editing-image-occlusion-fill-tool = Fill with colour
editing-image-occlusion-toggle-mask-editor = Toggle Mask Editor editing-image-occlusion-toggle-mask-editor = Toggle Mask Editor
editing-image-occlusion-reset = Reset Image Occlusion editing-image-occlusion-reset = Reset Image Occlusion
editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion? editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion?

@ -1 +1 @@
Subproject commit f35acabb46dc9197a62c47eb7f2ca062628b1d94 Subproject commit fbe9d1c731f7ad09953e63fdb0c455a6d3a3b6be

View file

@ -25,6 +25,8 @@ service DeckConfigService {
returns (collection.OpChanges); returns (collection.OpChanges);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse); returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
} }
// Implicitly includes any of the above methods that are not listed in the // Implicitly includes any of the above methods that are not listed in the
@ -35,6 +37,17 @@ message DeckConfigId {
int64 dcid = 1; int64 dcid = 1;
} }
message GetRetentionWorkloadRequest {
repeated float w = 1;
string search = 2;
float before = 3;
float after = 4;
}
message GetRetentionWorkloadResponse {
float factor = 1;
}
message GetIgnoredBeforeCountRequest { message GetIgnoredBeforeCountRequest {
string ignore_revlogs_before_date = 1; string ignore_revlogs_before_date = 1;
string search = 2; string search = 2;
@ -222,6 +235,7 @@ message DeckConfigsForUpdate {
// only applies to v3 scheduler // only applies to v3 scheduler
bool new_cards_ignore_review_limit = 7; bool new_cards_ignore_review_limit = 7;
bool fsrs = 8; bool fsrs = 8;
bool fsrs_health_check = 11;
bool apply_all_parent_limits = 9; bool apply_all_parent_limits = 9;
uint32 days_since_last_fsrs_optimize = 10; uint32 days_since_last_fsrs_optimize = 10;
} }
@ -245,4 +259,5 @@ message UpdateDeckConfigsRequest {
bool fsrs = 8; bool fsrs = 8;
bool apply_all_parent_limits = 9; bool apply_all_parent_limits = 9;
bool fsrs_reschedule = 10; bool fsrs_reschedule = 10;
bool fsrs_health_check = 11;
} }

View file

@ -354,11 +354,13 @@ message ComputeFsrsParamsRequest {
repeated float current_params = 2; repeated float current_params = 2;
int64 ignore_revlogs_before_ms = 3; int64 ignore_revlogs_before_ms = 3;
uint32 num_of_relearning_steps = 4; uint32 num_of_relearning_steps = 4;
bool health_check = 5;
} }
message ComputeFsrsParamsResponse { message ComputeFsrsParamsResponse {
repeated float params = 1; repeated float params = 1;
uint32 fsrs_items = 2; uint32 fsrs_items = 2;
optional bool health_check_passed = 3;
} }
message ComputeFsrsParamsFromItemsRequest { message ComputeFsrsParamsFromItemsRequest {
@ -435,9 +437,9 @@ message GetOptimalRetentionParametersResponse {
} }
message EvaluateParamsRequest { message EvaluateParamsRequest {
repeated float params = 1; string search = 1;
string search = 2; int64 ignore_revlogs_before_ms = 2;
int64 ignore_revlogs_before_ms = 3; uint32 num_of_relearning_steps = 3;
} }
message EvaluateParamsResponse { message EvaluateParamsResponse {

View file

@ -76,7 +76,7 @@ class MediaManager(DeprecatedNamesMixin):
return self.col._backend.strip_av_tags(text) return self.col._backend.strip_av_tags(text)
def _extract_filenames(self, text: str) -> list[str]: def _extract_filenames(self, text: str) -> list[str]:
"This only exists do support a legacy function; do not use." "This only exists to support a legacy function; do not use."
out = self.col._backend.extract_av_tags(text=text, question_side=True) out = self.col._backend.extract_av_tags(text=text, question_side=True)
return [ return [
x.filename x.filename

View file

@ -9,10 +9,14 @@ These can be accessed via eg card.question_av_tags()
from __future__ import annotations from __future__ import annotations
import os
import os.path
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Union from typing import Union
from anki import hooks
@dataclass @dataclass
class TTSTag: class TTSTag:
@ -34,10 +38,30 @@ class SoundOrVideoTag:
"""Contains the filename inside a [sound:...] tag. """Contains the filename inside a [sound:...] tag.
Video files also use [sound:...]. Video files also use [sound:...].
SECURITY: We should only ever construct this with basename(filename),
as passing arbitrary paths to mpv from a shared deck is a security issue.
Anki add-ons can supply an absolute file path to play any file on disk
using the built-in media player.
""" """
filename: str filename: str
def path(self, media_folder: str) -> str:
"Prepend the media folder to the filename."
if os.path.basename(self.filename) == self.filename:
# Path in the current collection's media folder.
# Turn it into a fully-qualified path so mpv can find it, and to
# ensure the filename doesn't get treated like a non-file scheme.
head, tail = media_folder, self.filename
else:
# Add-ons can use absolute paths to play arbitrary files on disk.
# Example: sound.av_player.play_tags([SoundOrVideoTag("/path/to/file")])
head, tail = os.path.split(os.path.abspath(self.filename))
tail = hooks.media_file_filter(tail)
return os.path.join(head, tail)
# note this does not include image tags, which are handled with HTML. # note this does not include image tags, which are handled with HTML.
AVTag = Union[SoundOrVideoTag, TTSTag] AVTag = Union[SoundOrVideoTag, TTSTag]

View file

@ -28,6 +28,7 @@ template_legacy.py file, using the legacy addHook() system.
from __future__ import annotations from __future__ import annotations
import os.path
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Union from typing import Any, Union
@ -95,7 +96,7 @@ class PartiallyRenderedCard:
def av_tag_to_native(tag: card_rendering_pb2.AVTag) -> AVTag: def av_tag_to_native(tag: card_rendering_pb2.AVTag) -> AVTag:
val = tag.WhichOneof("value") val = tag.WhichOneof("value")
if val == "sound_or_video": if val == "sound_or_video":
return SoundOrVideoTag(filename=tag.sound_or_video) return SoundOrVideoTag(filename=os.path.basename(tag.sound_or_video))
else: else:
return TTSTag( return TTSTag(
field_text=tag.tts.field_text, field_text=tag.tts.field_text,

View file

@ -53,6 +53,7 @@ from aqt.operations.tag import (
from aqt.qt import * from aqt.qt import *
from aqt.sound import av_player from aqt.sound import av_player
from aqt.switch import Switch from aqt.switch import Switch
from aqt.theme import WidgetStyle
from aqt.undo import UndoActionsInfo from aqt.undo import UndoActionsInfo
from aqt.utils import ( from aqt.utils import (
HelpPage, HelpPage,
@ -170,6 +171,7 @@ class Browser(QMainWindow):
if self.height() != 0: if self.height() != 0:
self.aspect_ratio = self.width() / self.height() self.aspect_ratio = self.width() / self.height()
self.set_layout(self.mw.pm.browser_layout(), True) self.set_layout(self.mw.pm.browser_layout(), True)
self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden())
# disable undo/redo # disable undo/redo
self.on_undo_state_change(mw.undo_actions_info()) self.on_undo_state_change(mw.undo_actions_info())
# legacy alias # legacy alias
@ -726,6 +728,7 @@ class Browser(QMainWindow):
self.form.actionSidebarFilter.triggered, self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar, self.focusSidebarSearchBar,
) )
qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange)
grid = QGridLayout() grid = QGridLayout()
grid.addWidget(self.sidebar.searchBar, 0, 0) grid.addWidget(self.sidebar.searchBar, 0, 0)
grid.addWidget(self.sidebar.toolbar, 0, 1) grid.addWidget(self.sidebar.toolbar, 0, 1)
@ -745,9 +748,17 @@ class Browser(QMainWindow):
self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)
def showSidebar(self, show: bool = True) -> None: def showSidebar(self, show: bool = True) -> None:
want_visible = not self.sidebarDockWidget.isVisible()
self.sidebarDockWidget.setVisible(show) self.sidebarDockWidget.setVisible(show)
if want_visible and show:
def onSidebarVisibilityChange(self, visible):
margins = self.form.verticalLayout_3.contentsMargins()
skip_left_margin = visible and not (
is_mac and aqt.mw.pm.get_widget_style() == WidgetStyle.NATIVE
)
margins.setLeft(0 if skip_left_margin else margins.right())
self.form.verticalLayout_3.setContentsMargins(margins)
if visible:
self.sidebar.refresh() self.sidebar.refresh()
def focusSidebar(self) -> None: def focusSidebar(self) -> None:
@ -1128,6 +1139,9 @@ class Browser(QMainWindow):
dialog=dialog, dialog=dialog,
), ),
) )
if key := aqt.mw.pm.get_answer_key(ease):
QShortcut(key, dialog, activated=btn.click) # type: ignore
btn.setToolTip(tr.actions_shortcut_key(key))
layout.addWidget(btn) layout.addWidget(btn)
# Add cancel button # Add cancel button

View file

@ -235,6 +235,8 @@ class ErrorHandler(QObject):
if "unable to get local issuer certificate" in error and is_win: if "unable to get local issuer certificate" in error and is_win:
showWarning(tr.errors_windows_ssl_updates()) showWarning(tr.errors_windows_ssl_updates())
return return
if is_chromium_cert_error(error):
return
debug_text = supportText() + "\n" + error debug_text = supportText() + "\n" + error

View file

@ -7,9 +7,8 @@ import enum
import logging import logging
import mimetypes import mimetypes
import os import os
import random
import re import re
import string import secrets
import sys import sys
import threading import threading
import traceback import traceback
@ -659,6 +658,7 @@ exposed_backend_list = [
"simulate_fsrs_review", "simulate_fsrs_review",
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload",
] ]
@ -764,7 +764,7 @@ def legacy_page_data() -> Response:
return _text_response(HTTPStatus.NOT_FOUND, "page not found") return _text_response(HTTPStatus.NOT_FOUND, "page not found")
_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32)) _APIKEY = secrets.token_urlsafe(32)
def _have_api_access() -> bool: def _have_api_access() -> bool:

View file

@ -41,6 +41,7 @@ import time
from queue import Empty, Full, Queue from queue import Empty, Full, Queue
from shutil import which from shutil import which
import aqt
from anki.utils import is_mac, is_win from anki.utils import is_mac, is_win
@ -68,6 +69,7 @@ if is_win:
# pylint: disable=import-error # pylint: disable=import-error
import pywintypes import pywintypes
import win32file # pytype: disable=import-error import win32file # pytype: disable=import-error
import win32job
import win32pipe import win32pipe
import winerror import winerror
@ -130,6 +132,22 @@ class MPVBase:
def _start_process(self): def _start_process(self):
"""Start the mpv process.""" """Start the mpv process."""
self._proc = subprocess.Popen(self.argv, env=self.popenEnv) self._proc = subprocess.Popen(self.argv, env=self.popenEnv)
if is_win:
# Ensure mpv gets terminated if Anki closes abruptly.
self._job = win32job.CreateJobObject(None, "")
extended_info = win32job.QueryInformationJobObject(
self._job, win32job.JobObjectExtendedLimitInformation
)
extended_info["BasicLimitInformation"][
"LimitFlags"
] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
win32job.SetInformationJobObject(
self._job,
win32job.JobObjectExtendedLimitInformation,
extended_info,
)
handle = self._proc._handle # pylint: disable=no-member
win32job.AssignProcessToJobObject(self._job, handle)
def _stop_process(self): def _stop_process(self):
"""Stop the mpv process.""" """Stop the mpv process."""
@ -444,7 +462,7 @@ class MPV(MPVBase):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._register_callbacks() aqt.mw.taskman.run_in_background(self._register_callbacks, None)
def _register_callbacks(self): def _register_callbacks(self):
self._callbacks = {} self._callbacks = {}

View file

@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import os.path
import platform import platform
import re import re
import subprocess import subprocess
@ -23,7 +24,6 @@ from markdown import markdown
import aqt import aqt
import aqt.mpv import aqt.mpv
import aqt.qt import aqt.qt
from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag
from anki.utils import is_lin, is_mac, is_win, namedtmp from anki.utils import is_lin, is_mac, is_win, namedtmp
@ -177,15 +177,27 @@ class AVPlayer:
self._stop_if_playing() self._stop_if_playing()
def play_file(self, filename: str) -> None: def play_file(self, filename: str) -> None:
"""Play the provided path.
SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,
you should only ever use the os.path.basename(filename) as the filename."""
self.play_tags([SoundOrVideoTag(filename=filename)]) self.play_tags([SoundOrVideoTag(filename=filename)])
def play_file_with_caller(self, filename: str, caller: Any) -> None: def play_file_with_caller(self, filename: str, caller: Any) -> None:
"""Play the provided path, noting down the caller.
SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,
you should only ever use the os.path.basename(filename) as the filename."""
if self.current_caller: if self.current_caller:
self.current_caller_interrupted = True self.current_caller_interrupted = True
self.current_caller = caller self.current_caller = caller
self.play_file(filename) self.play_file(filename)
def insert_file(self, filename: str) -> None: def insert_file(self, filename: str) -> None:
"""Place the provided path at the top of the playlist.
SECURITY: Filename may be an arbitrary path. For filenames coming from a collection,
you should only ever use the os.path.basename(filename) as the filename."""
self._enqueued.insert(0, SoundOrVideoTag(filename=filename)) self._enqueued.insert(0, SoundOrVideoTag(filename=filename))
self._play_next_if_idle() self._play_next_if_idle()
@ -327,7 +339,7 @@ class SimpleProcessPlayer(Player): # pylint: disable=abstract-method
def _play(self, tag: AVTag) -> None: def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag) assert isinstance(tag, SoundOrVideoTag)
self._process = subprocess.Popen( self._process = subprocess.Popen(
self.args + ["--", tag.filename], self.args + ["--", tag.path(self._media_folder)],
env=self.env, env=self.env,
cwd=self._media_folder, cwd=self._media_folder,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
@ -453,8 +465,7 @@ class MpvManager(MPV, SoundOrVideoPlayer):
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
assert isinstance(tag, SoundOrVideoTag) assert isinstance(tag, SoundOrVideoTag)
self._on_done = on_done self._on_done = on_done
filename = hooks.media_file_filter(tag.filename) path = tag.path(self.media_folder)
path = os.path.join(self.media_folder, filename)
if self.mpv_version is None or self.mpv_version >= (0, 38, 0): if self.mpv_version is None or self.mpv_version >= (0, 38, 0):
self.command("loadfile", path, "replace", -1, "pause=no") self.command("loadfile", path, "replace", -1, "pause=no")
@ -506,10 +517,8 @@ class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer):
def _play(self, tag: AVTag) -> None: def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag) assert isinstance(tag, SoundOrVideoTag)
filename = hooks.media_file_filter(tag.filename)
self._process = subprocess.Popen( self._process = subprocess.Popen(
self.args + ["--", filename], self.args + ["--", tag.path(self.media_folder)],
env=self.env, env=self.env,
cwd=self.media_folder, cwd=self.media_folder,
stdin=subprocess.PIPE, stdin=subprocess.PIPE,

View file

@ -40,6 +40,7 @@ pub enum BoolKey {
WithScheduling, WithScheduling,
WithDeckConfigs, WithDeckConfigs,
Fsrs, Fsrs,
FsrsHealthCheck,
LoadBalancerEnabled, LoadBalancerEnabled,
FsrsShortTermWithStepsEnabled, FsrsShortTermWithStepsEnabled,
#[strum(to_string = "normalize_note_text")] #[strum(to_string = "normalize_note_text")]
@ -76,6 +77,7 @@ impl Collection {
| BoolKey::RestorePositionBrowser | BoolKey::RestorePositionBrowser
| BoolKey::RestorePositionReviewer | BoolKey::RestorePositionReviewer
| BoolKey::LoadBalancerEnabled | BoolKey::LoadBalancerEnabled
| BoolKey::FsrsHealthCheck
| BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true),
// other options default to false // other options default to false

View file

@ -110,9 +110,9 @@ impl DeckConfig {
/// Retrieve the FSRS 6.0 params, falling back on 5.0 or 4.x ones. /// Retrieve the FSRS 6.0 params, falling back on 5.0 or 4.x ones.
pub fn fsrs_params(&self) -> &Vec<f32> { pub fn fsrs_params(&self) -> &Vec<f32> {
if self.inner.fsrs_params_6.len() == 21 { if !self.inner.fsrs_params_6.is_empty() {
&self.inner.fsrs_params_6 &self.inner.fsrs_params_6
} else if self.inner.fsrs_params_5.len() == 19 { } else if !self.inner.fsrs_params_5.is_empty() {
&self.inner.fsrs_params_5 &self.inner.fsrs_params_5
} else { } else {
&self.inner.fsrs_params_4 &self.inner.fsrs_params_4

View file

@ -96,6 +96,74 @@ impl crate::services::DeckConfigService for Collection {
total: guard.cards.try_into().unwrap_or(0), total: guard.cards.try_into().unwrap_or(0),
}) })
} }
fn get_retention_workload(
&mut self,
input: anki_proto::deck_config::GetRetentionWorkloadRequest,
) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> {
const LEARN_SPAN: usize = 100_000_000;
const TERMINATION_PROB: f32 = 0.001;
// the default values are from https://github.com/open-spaced-repetition/Anki-button-usage/blob/881009015c2a85ac911021d76d0aacb124849937/analysis.ipynb
const DEFAULT_LEARN_COST: f32 = 19.4698;
const DEFAULT_PASS_COST: f32 = 7.8454;
const DEFAULT_FAIL_COST: f32 = 23.185;
const DEFAULT_INITIAL_PASS_RATE: f32 = 0.7645;
let guard =
self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?;
let costs = guard.col.storage.get_costs_for_retention()?;
fn smoothing(obs: f32, default: f32, count: u32) -> f32 {
let alpha = count as f32 / (50.0 + count as f32);
obs * alpha + default * (1.0 - alpha)
}
let cost_success = smoothing(
costs.average_pass_time_ms / 1000.0,
DEFAULT_PASS_COST,
costs.pass_count,
);
let cost_failure = smoothing(
costs.average_fail_time_ms / 1000.0,
DEFAULT_FAIL_COST,
costs.fail_count,
);
let cost_learn = smoothing(
costs.average_learn_time_ms / 1000.0,
DEFAULT_LEARN_COST,
costs.learn_count,
);
let initial_pass_rate = smoothing(
costs.initial_pass_rate,
DEFAULT_INITIAL_PASS_RATE,
costs.pass_count,
);
let before = fsrs::expected_workload(
&input.w,
input.before,
LEARN_SPAN,
cost_success,
cost_failure,
cost_learn,
initial_pass_rate,
TERMINATION_PROB,
)?;
let after = fsrs::expected_workload(
&input.w,
input.after,
LEARN_SPAN,
cost_success,
cost_failure,
cost_learn,
initial_pass_rate,
TERMINATION_PROB,
)?;
Ok(anki_proto::deck_config::GetRetentionWorkloadResponse {
factor: after / before,
})
}
} }
impl From<DeckConfig> for anki_proto::deck_config::DeckConfig { impl From<DeckConfig> for anki_proto::deck_config::DeckConfig {
@ -124,6 +192,7 @@ impl From<anki_proto::deck_config::UpdateDeckConfigsRequest> for UpdateDeckConfi
apply_all_parent_limits: c.apply_all_parent_limits, apply_all_parent_limits: c.apply_all_parent_limits,
fsrs: c.fsrs, fsrs: c.fsrs,
fsrs_reschedule: c.fsrs_reschedule, fsrs_reschedule: c.fsrs_reschedule,
fsrs_health_check: c.fsrs_health_check,
} }
} }
} }

View file

@ -22,6 +22,7 @@ use crate::prelude::*;
use crate::scheduler::fsrs::memory_state::UpdateMemoryStateEntry; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateEntry;
use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest;
use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config; use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config;
use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::search::JoinSearches; use crate::search::JoinSearches;
use crate::search::Negated; use crate::search::Negated;
use crate::search::SearchNode; use crate::search::SearchNode;
@ -41,6 +42,7 @@ pub struct UpdateDeckConfigsRequest {
pub apply_all_parent_limits: bool, pub apply_all_parent_limits: bool,
pub fsrs: bool, pub fsrs: bool,
pub fsrs_reschedule: bool, pub fsrs_reschedule: bool,
pub fsrs_health_check: bool,
} }
impl Collection { impl Collection {
@ -71,6 +73,7 @@ impl Collection {
new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit), new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit),
apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits), apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits),
fsrs: self.get_config_bool(BoolKey::Fsrs), fsrs: self.get_config_bool(BoolKey::Fsrs),
fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck),
days_since_last_fsrs_optimize, days_since_last_fsrs_optimize,
}) })
} }
@ -300,6 +303,7 @@ impl Collection {
req.new_cards_ignore_review_limit, req.new_cards_ignore_review_limit,
)?; )?;
self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?; self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?;
self.set_config_bool_inner(BoolKey::FsrsHealthCheck, req.fsrs_health_check)?;
Ok(()) Ok(())
} }
@ -365,14 +369,15 @@ impl Collection {
}; };
let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?; let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?;
let num_of_relearning_steps = config.inner.relearn_steps.len(); let num_of_relearning_steps = config.inner.relearn_steps.len();
match self.compute_params( match self.compute_params(ComputeParamsRequest {
&search, search: &search,
ignore_revlogs_before_ms, ignore_revlogs_before_ms,
idx as u32 + 1, current_preset: idx as u32 + 1,
config_len, total_presets: config_len,
config.fsrs_params(), current_params: config.fsrs_params(),
num_of_relearning_steps, num_of_relearning_steps,
) { health_check: false,
}) {
Ok(params) => { Ok(params) => {
println!("{}: {:?}", config.name, params.params); println!("{}: {:?}", config.name, params.params);
config.inner.fsrs_params_6 = params.params; config.inner.fsrs_params_6 = params.params;
@ -452,6 +457,7 @@ mod test {
col.set_config_string_inner(StringKey::CardStateCustomizer, "")?; col.set_config_string_inner(StringKey::CardStateCustomizer, "")?;
col.set_config_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?; col.set_config_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?;
col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?; col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?;
col.set_config_bool_inner(BoolKey::FsrsHealthCheck, true)?;
// pretend we're in sync // pretend we're in sync
let stamps = col.storage.get_collection_timestamps()?; let stamps = col.storage.get_collection_timestamps()?;
@ -488,6 +494,7 @@ mod test {
apply_all_parent_limits: false, apply_all_parent_limits: false,
fsrs: false, fsrs: false,
fsrs_reschedule: false, fsrs_reschedule: false,
fsrs_health_check: true,
}; };
assert!(!col.update_deck_configs(input.clone())?.changes.had_change()); assert!(!col.update_deck_configs(input.clone())?.changes.had_change());

View file

@ -64,19 +64,9 @@ pub fn get_image_cloze_data(text: &str) -> String {
} }
for property in occlusion.properties { for property in occlusion.properties {
match property.name.as_str() { match property.name.as_str() {
"left" => { "left" | "top" | "angle" | "fill" => {
if !property.value.is_empty() { if !property.value.is_empty() {
result.push_str(&format!("data-left=\"{}\" ", property.value)); result.push_str(&format!("data-{}=\"{}\" ", property.name, property.value));
}
}
"top" => {
if !property.value.is_empty() {
result.push_str(&format!("data-top=\"{}\" ", property.value));
}
}
"angle" => {
if !property.value.is_empty() {
result.push_str(&format!("data-angle=\"{}\" ", property.value));
} }
} }
"width" => { "width" => {

View file

@ -183,6 +183,8 @@ impl Collection {
.or_insert_with(|| flag_name(card.flags).into()); .or_insert_with(|| flag_name(card.flags).into());
map.entry("Card") map.entry("Card")
.or_insert_with(|| template.name.clone().into()); .or_insert_with(|| template.name.clone().into());
map.entry("CardID")
.or_insert_with(|| card.id.to_string().into());
Ok(()) Ok(())
} }

View file

@ -16,7 +16,6 @@ use super::rescheduler::Rescheduler;
use crate::card::CardType; use crate::card::CardType;
use crate::prelude::*; use crate::prelude::*;
use crate::revlog::RevlogEntry; use crate::revlog::RevlogEntry;
use crate::revlog::RevlogReviewKind;
use crate::scheduler::answering::get_fuzz_seed; use crate::scheduler::answering::get_fuzz_seed;
use crate::scheduler::fsrs::params::reviews_for_fsrs; use crate::scheduler::fsrs::params::reviews_for_fsrs;
use crate::scheduler::fsrs::params::Params; use crate::scheduler::fsrs::params::Params;
@ -163,15 +162,8 @@ impl Collection {
); );
} }
*due = new_due; *due = new_due;
// Add a rescheduled revlog entry if the last entry wasn't // Add a rescheduled revlog entry
// rescheduled self.log_rescheduled_review(&card, original_interval, usn)?;
if !last_info.last_revlog_is_rescheduled {
self.log_rescheduled_review(
&card,
original_interval,
usn,
)?;
}
} }
} }
} }
@ -289,9 +281,6 @@ struct LastRevlogInfo {
/// reviewed the card and now, so that we can determine an accurate period /// reviewed the card and now, so that we can determine an accurate period
/// when the card has subsequently been rescheduled to a different day. /// when the card has subsequently been rescheduled to a different day.
last_reviewed_at: Option<TimestampSecs>, last_reviewed_at: Option<TimestampSecs>,
/// If true, the last action on this card was a reschedule, so we
/// can avoid writing an extra revlog entry on another reschedule.
last_revlog_is_rescheduled: bool,
} }
/// Return a map of cards to info about last review/reschedule. /// Return a map of cards to info about last review/reschedule.
@ -303,20 +292,12 @@ fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogIn
.into_iter() .into_iter()
.for_each(|(card_id, group)| { .for_each(|(card_id, group)| {
let mut last_reviewed_at = None; let mut last_reviewed_at = None;
let mut last_revlog_is_rescheduled = false;
for e in group.into_iter() { for e in group.into_iter() {
if e.button_chosen >= 1 { if e.button_chosen >= 1 {
last_reviewed_at = Some(e.id.as_secs()); last_reviewed_at = Some(e.id.as_secs());
} }
last_revlog_is_rescheduled = e.review_kind == RevlogReviewKind::Rescheduled;
} }
out.insert( out.insert(card_id, LastRevlogInfo { last_reviewed_at });
card_id,
LastRevlogInfo {
last_reviewed_at,
last_revlog_is_rescheduled,
},
);
}); });
out out
} }

View file

@ -51,19 +51,46 @@ pub(crate) fn ignore_revlogs_before_ms_from_config(config: &DeckConfig) -> Resul
ignore_revlogs_before_date_to_ms(&config.inner.ignore_revlogs_before_date) ignore_revlogs_before_date_to_ms(&config.inner.ignore_revlogs_before_date)
} }
pub struct ComputeParamsRequest<'t> {
pub search: &'t str,
pub ignore_revlogs_before_ms: TimestampMillis,
pub current_preset: u32,
pub total_presets: u32,
pub current_params: &'t Params,
pub num_of_relearning_steps: usize,
pub health_check: bool,
}
/// r: retention
fn log_loss_adjustment(r: f32) -> f32 {
0.623 * (4. * r * (1. - r)).powf(0.738)
}
/// r: retention
///
/// c: review count
fn rmse_adjustment(r: f32, c: u32) -> f32 {
0.0135 / (r.powf(0.504) - 1.14) + 0.176 / ((c as f32 / 1000.).powf(0.825) + 2.22) + 0.101
}
impl Collection { impl Collection {
/// Note this does not return an error if there are less than 400 items - /// Note this does not return an error if there are less than 400 items -
/// the caller should instead check the fsrs_items count in the return /// the caller should instead check the fsrs_items count in the return
/// value. /// value.
pub fn compute_params( pub fn compute_params(
&mut self, &mut self,
search: &str, request: ComputeParamsRequest,
ignore_revlogs_before: TimestampMillis,
current_preset: u32,
total_presets: u32,
current_params: &Params,
num_of_relearning_steps: usize,
) -> Result<ComputeFsrsParamsResponse> { ) -> Result<ComputeFsrsParamsResponse> {
let ComputeParamsRequest {
search,
ignore_revlogs_before_ms: ignore_revlogs_before,
current_preset,
total_presets,
current_params,
num_of_relearning_steps,
health_check,
} = request;
self.clear_progress(); self.clear_progress();
let timing = self.timing_today()?; let timing = self.timing_today()?;
let revlogs = self.revlog_for_srs(search)?; let revlogs = self.revlog_for_srs(search)?;
@ -75,6 +102,7 @@ impl Collection {
return Ok(ComputeFsrsParamsResponse { return Ok(ComputeFsrsParamsResponse {
params: current_params.to_vec(), params: current_params.to_vec(),
fsrs_items, fsrs_items,
health_check_passed: None,
}); });
} }
// adapt the progress handler to our built-in progress handling // adapt the progress handler to our built-in progress handling
@ -108,22 +136,22 @@ impl Collection {
let (progress, progress_thread) = create_progress_thread()?; let (progress, progress_thread) = create_progress_thread()?;
let fsrs = FSRS::new(None)?; let fsrs = FSRS::new(None)?;
let mut params = fsrs.compute_parameters(ComputeParametersInput { let input = ComputeParametersInput {
train_set: items.clone(), train_set: items.clone(),
progress: Some(progress.clone()), progress: Some(progress.clone()),
enable_short_term: true, enable_short_term: true,
num_relearning_steps: Some(num_of_relearning_steps), num_relearning_steps: Some(num_of_relearning_steps),
})?; };
let mut params = fsrs.compute_parameters(input.clone())?;
progress_thread.join().ok(); progress_thread.join().ok();
if let Ok(fsrs) = FSRS::new(Some(current_params)) { if let Ok(current_fsrs) = FSRS::new(Some(current_params)) {
let current_log_loss = fsrs.evaluate(items.clone(), |_| true)?.log_loss; let current_log_loss = current_fsrs.evaluate(items.clone(), |_| true)?.log_loss;
let optimized_fsrs = FSRS::new(Some(&params))?; let optimized_fsrs = FSRS::new(Some(&params))?;
let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss; let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss;
if current_log_loss <= optimized_log_loss { if current_log_loss <= optimized_log_loss {
if num_of_relearning_steps <= 1 { if num_of_relearning_steps <= 1 {
params = current_params.to_vec(); params = current_params.to_vec();
} else { } else {
let current_fsrs = FSRS::new(Some(current_params))?;
let memory_state = MemoryState { let memory_state = MemoryState {
stability: 1.0, stability: 1.0,
difficulty: 1.0, difficulty: 1.0,
@ -146,7 +174,34 @@ impl Collection {
} }
} }
Ok(ComputeFsrsParamsResponse { params, fsrs_items }) let health_check_passed = if health_check {
let fsrs = FSRS::new(None)?;
fsrs.evaluate_with_time_series_splits(input, |_| true)
.ok()
.map(|eval| {
let r = items.iter().fold(0, |p, item| {
p + (item
.reviews
.last()
.map(|reviews| reviews.rating)
.unwrap_or(0)
> 1) as u32
}) as f32
/ fsrs_items as f32;
let adjusted_log_loss = eval.log_loss / log_loss_adjustment(r);
let adjusted_rmse = eval.rmse_bins / rmse_adjustment(r, fsrs_items);
adjusted_log_loss <= 1.11 || adjusted_rmse <= 1.53
})
} else {
None
};
Ok(ComputeFsrsParamsResponse {
params,
fsrs_items,
health_check_passed,
})
} }
pub(crate) fn revlog_for_srs( pub(crate) fn revlog_for_srs(
@ -218,22 +273,24 @@ impl Collection {
pub fn evaluate_params( pub fn evaluate_params(
&mut self, &mut self,
params: &Params,
search: &str, search: &str,
ignore_revlogs_before: TimestampMillis, ignore_revlogs_before: TimestampMillis,
num_of_relearning_steps: usize,
) -> Result<ModelEvaluation> { ) -> Result<ModelEvaluation> {
let timing = self.timing_today()?; let timing = self.timing_today()?;
let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>(); let revlogs = self.revlog_for_srs(search)?;
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
let revlogs: Vec<RevlogEntry> = guard
.col
.storage
.get_revlog_entries_for_searched_cards_in_card_order()?;
let (items, review_count) = let (items, review_count) =
fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before); fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before);
let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>();
anki_progress.state.reviews = review_count as u32; anki_progress.state.reviews = review_count as u32;
let fsrs = FSRS::new(Some(params))?; let fsrs = FSRS::new(None)?;
Ok(fsrs.evaluate(items, |ip| { let input = ComputeParametersInput {
train_set: items.clone(),
progress: None,
enable_short_term: true,
num_relearning_steps: Some(num_of_relearning_steps),
};
Ok(fsrs.evaluate_with_time_series_splits(input, |ip| {
anki_progress anki_progress
.update(false, |p| { .update(false, |p| {
p.total_iterations = ip.total as u32; p.total_iterations = ip.total as u32;

View file

@ -23,6 +23,7 @@ use fsrs::FSRS;
use crate::backend::Backend; use crate::backend::Backend;
use crate::prelude::*; use crate::prelude::*;
use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::new::NewCardDueOrder;
use crate::scheduler::states::CardState; use crate::scheduler::states::CardState;
use crate::scheduler::states::SchedulingStates; use crate::scheduler::states::SchedulingStates;
@ -264,14 +265,15 @@ impl crate::services::SchedulerService for Collection {
&mut self, &mut self,
input: scheduler::ComputeFsrsParamsRequest, input: scheduler::ComputeFsrsParamsRequest,
) -> Result<scheduler::ComputeFsrsParamsResponse> { ) -> Result<scheduler::ComputeFsrsParamsResponse> {
self.compute_params( self.compute_params(ComputeParamsRequest {
&input.search, search: &input.search,
input.ignore_revlogs_before_ms.into(), ignore_revlogs_before_ms: input.ignore_revlogs_before_ms.into(),
1, current_preset: 1,
1, total_presets: 1,
&input.current_params, current_params: &input.current_params,
input.num_of_relearning_steps as usize, num_of_relearning_steps: input.num_of_relearning_steps as usize,
) health_check: input.health_check,
})
} }
fn simulate_fsrs_review( fn simulate_fsrs_review(
@ -295,9 +297,9 @@ impl crate::services::SchedulerService for Collection {
input: scheduler::EvaluateParamsRequest, input: scheduler::EvaluateParamsRequest,
) -> Result<scheduler::EvaluateParamsResponse> { ) -> Result<scheduler::EvaluateParamsResponse> {
let ret = self.evaluate_params( let ret = self.evaluate_params(
&input.params,
&input.search, &input.search,
input.ignore_revlogs_before_ms.into(), input.ignore_revlogs_before_ms.into(),
input.num_of_relearning_steps as usize,
)?; )?;
Ok(scheduler::EvaluateParamsResponse { Ok(scheduler::EvaluateParamsResponse {
log_loss: ret.log_loss, log_loss: ret.log_loss,
@ -372,7 +374,11 @@ impl crate::services::BackendSchedulerService for Backend {
enable_short_term: true, enable_short_term: true,
num_relearning_steps: None, num_relearning_steps: None,
})?; })?;
Ok(ComputeFsrsParamsResponse { params, fsrs_items }) Ok(ComputeFsrsParamsResponse {
params,
fsrs_items,
health_check_passed: None,
})
} }
fn fsrs_benchmark( fn fsrs_benchmark(

View file

@ -0,0 +1,85 @@
WITH searched_revlogs AS (
SELECT *,
RANK() OVER (
PARTITION BY cid
ORDER BY id ASC
) AS rank_num
FROM revlog
WHERE ease > 0
AND cid IN search_cids
ORDER BY id DESC -- Use the last 10_000 reviews
LIMIT 10000
), average_pass AS (
SELECT AVG(time)
FROM searched_revlogs
WHERE ease > 1
AND type = 1
),
lapse_count AS (
SELECT COUNT(time) AS lapse_count
FROM searched_revlogs
WHERE ease = 1
AND type = 1
),
fail_sum AS (
SELECT SUM(time) AS total_fail_time
FROM searched_revlogs
WHERE (
ease = 1
AND type = 1
)
OR type = 2
),
-- (sum(Relearning) + sum(Lapses)) / count(Lapses)
average_fail AS (
SELECT total_fail_time * 1.0 / NULLIF(lapse_count, 0) AS avg_fail_time
FROM fail_sum,
lapse_count
),
-- Can lead to cards with partial learn histories skewing the time
summed_learns AS (
SELECT cid,
SUM(time) AS total_time
FROM searched_revlogs
WHERE searched_revlogs.type = 0
GROUP BY cid
),
average_learn AS (
SELECT AVG(total_time) AS avg_learn_time
FROM summed_learns
),
initial_pass_rate AS (
SELECT AVG(
CASE
WHEN ease > 1 THEN 1.0
ELSE 0.0
END
) AS initial_pass_rate
FROM searched_revlogs
WHERE rank_num = 1
),
pass_cnt AS (
SELECT COUNT(*) AS cnt
FROM searched_revlogs
WHERE ease > 1
AND type = 1
),
fail_cnt AS (
SELECT COUNT(*) AS cnt
FROM searched_revlogs
WHERE ease = 1
AND type = 1
),
learn_cnt AS (
SELECT COUNT(*) AS cnt
FROM searched_revlogs
WHERE type = 0
)
SELECT *
FROM average_pass,
average_fail,
average_learn,
initial_pass_rate,
pass_cnt,
fail_cnt,
learn_cnt;

View file

@ -42,6 +42,17 @@ use crate::timestamp::TimestampMillis;
use crate::timestamp::TimestampSecs; use crate::timestamp::TimestampSecs;
use crate::types::Usn; use crate::types::Usn;
#[derive(Debug, Clone, Default)]
pub struct RetentionCosts {
pub average_pass_time_ms: f32,
pub average_fail_time_ms: f32,
pub average_learn_time_ms: f32,
pub initial_pass_rate: f32,
pub pass_count: u32,
pub fail_count: u32,
pub learn_count: u32,
}
impl FromSql for CardType { impl FromSql for CardType {
fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> { fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> {
if let ValueRef::Integer(i) = value { if let ValueRef::Integer(i) = value {
@ -747,6 +758,24 @@ impl super::SqliteStorage {
.get(0)?) .get(0)?)
} }
pub(crate) fn get_costs_for_retention(&self) -> Result<RetentionCosts> {
let mut statement = self
.db
.prepare(include_str!("get_costs_for_retention.sql"))?;
let mut query = statement.query(params![])?;
let row = query.next()?.unwrap();
Ok(RetentionCosts {
average_pass_time_ms: row.get(0).unwrap_or(7000.),
average_fail_time_ms: row.get(1).unwrap_or(23_000.),
average_learn_time_ms: row.get(2).unwrap_or(30_000.),
initial_pass_rate: row.get(3).unwrap_or(0.5),
pass_count: row.get(4).unwrap_or(0),
fail_count: row.get(5).unwrap_or(0),
learn_count: row.get(6).unwrap_or(0),
})
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> { pub(crate) fn get_all_cards(&self) -> Vec<Card> {
self.db self.db

View file

@ -20,16 +20,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let disabledChoices: T[] = []; export let disabledChoices: T[] = [];
$: label = choices.find((c) => c.value === value)?.label; $: label = choices.find((c) => c.value === value)?.label;
</script> $: parser = (item) => ({
<Select
bind:value
{label}
{disabled}
list={choices}
parser={(item) => ({
content: item.label, content: item.label,
value: item.value, value: item.value,
disabled: disabledChoices.includes(item.value), disabled: disabledChoices.includes(item.value),
})} });
/> </script>
<Select bind:value {label} {disabled} list={choices} {parser} />

View file

@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let percentage = false; export let percentage = false;
let input: HTMLInputElement; let input: HTMLInputElement;
let focused = false; export let focused = false;
let multiplier: number; let multiplier: number;
$: multiplier = percentage ? 100 : 1; $: multiplier = percentage ? 100 : 1;
@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue} value={stringValue}
bind:this={input} bind:this={input}
on:blur={update} on:blur={update}
on:change={update}
on:input={onInput} on:input={onInput}
on:focusin={() => (focused = true)} on:focusin={() => (focused = true)}
on:focusout={() => (focused = false)} on:focusout={() => (focused = false)}

View file

@ -59,6 +59,8 @@ import FormatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?component";
import formatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?url"; import formatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?url";
import FormatBold_ from "@mdi/svg/svg/format-bold.svg?component"; import FormatBold_ from "@mdi/svg/svg/format-bold.svg?component";
import formatBold_ from "@mdi/svg/svg/format-bold.svg?url"; import formatBold_ from "@mdi/svg/svg/format-bold.svg?url";
import FormatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?component";
import formatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?url";
import HighlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?component"; import HighlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?component";
import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url"; import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url";
import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component"; import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component";
@ -264,6 +266,7 @@ export const mdiEllipseOutline = { url: ellipseOutline_, component: EllipseOutli
export const mdiEye = { url: eye_, component: Eye_ }; export const mdiEye = { url: eye_, component: Eye_ };
export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ }; export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ };
export const mdiFormatBold = { url: formatBold_, component: FormatBold_ }; export const mdiFormatBold = { url: formatBold_, component: FormatBold_ };
export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ };
export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ }; export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ };
export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ }; export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ };
export const mdiGroup = { url: group_, component: Group_ }; export const mdiGroup = { url: group_, component: Group_ };

View file

@ -71,6 +71,7 @@ pre {
width: 100%; width: 100%;
// https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething // https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething
box-sizing: border-box; box-sizing: border-box;
line-height: 1.75;
} }
code#typeans { code#typeans {

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { import {
computeFsrsParams, computeFsrsParams,
evaluateParams, evaluateParams,
getRetentionWorkload,
setWantsAbort, setWantsAbort,
} from "@generated/backend"; } from "@generated/backend";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte";
import SimulatorModal from "./SimulatorModal.svelte"; import SimulatorModal from "./SimulatorModal.svelte";
import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb"; import {
GetRetentionWorkloadRequest,
UpdateDeckConfigsMode,
} from "@generated/anki/deck_config_pb";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let openHelpModal: (String) => void; export let openHelpModal: (String) => void;
export let onPresetChange: () => void; export let onPresetChange: () => void;
export let newlyEnabled = false;
const config = state.currentConfig; const config = state.currentConfig;
const defaults = state.defaults; const defaults = state.defaults;
@ -39,18 +44,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: lastOptimizationWarning = $: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false;
let optimized = false;
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
$: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true;
}
$: showDesiredRetentionTooltip =
newlyEnabled || desiredRetentionEverFocused || optimized;
let computeParamsProgress: ComputeParamsProgress | undefined; let computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false; let computingParams = false;
let checkingParams = false; let checkingParams = false;
const healthCheck = state.fsrsHealthCheck;
$: computing = computingParams || checkingParams; $: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2)); $: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning( $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
roundedRetention,
fsrsParams($config), let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
); const WORKLOAD_UPDATE_DELAY_MS = 100;
let desiredRetentionChangeInfo = "";
$: {
clearTimeout(timeoutId);
if (showDesiredRetentionTooltip) {
timeoutId = setTimeout(() => {
getRetentionChangeInfo(roundedRetention, fsrsParams($config));
}, WORKLOAD_UPDATE_DELAY_MS);
} else {
desiredRetentionChangeInfo = "";
}
}
$: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: retentionWarningClass = getRetentionWarningClass(roundedRetention);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
@ -67,23 +96,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder, reviewOrder: $config.reviewOrder,
}); });
function getRetentionWarning(retention: number, params: number[]): string { const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5 const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100; function getRetentionLongShortWarning(retention: number) {
const days = Math.round( if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
(stability / factor) * (Math.pow(retention, 1 / decay) - 1), return tr.deckConfigDesiredRetentionTooLow();
); } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
if (days === 100) { return tr.deckConfigDesiredRetentionTooHigh();
} else {
return ""; return "";
} }
return tr.deckConfigA100DayInterval({ days }); }
async function getRetentionChangeInfo(retention: number, params: number[]) {
if (+startingDesiredRetention == roundedRetention) {
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged();
return;
}
const request = new GetRetentionWorkloadRequest({
w: params,
search: defaultparamSearch,
before: +startingDesiredRetention,
after: retention,
});
const resp = await getRetentionWorkload(request);
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({
factor: resp.factor.toFixed(2),
previousDr: (+startingDesiredRetention * 100).toString(),
});
} }
function getRetentionWarningClass(retention: number): string { function getRetentionWarningClass(retention: number): string {
if (retention < 0.7 || retention > 0.97) { if (retention < 0.7 || retention > 0.97) {
return "alert-danger"; return "alert-danger";
} else if (retention < 0.8 || retention > 0.95) { } else if (
retention < DESIRED_RETENTION_LOW_THRESHOLD ||
retention > DESIRED_RETENTION_HIGH_THRESHOLD
) {
return "alert-warning"; return "alert-warning";
} else { } else {
return "alert-info"; return "alert-info";
@ -130,6 +180,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(), ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
currentParams: params, currentParams: params,
numOfRelearningSteps: numOfRelearningStepsInDay, numOfRelearningSteps: numOfRelearningStepsInDay,
healthCheck: $healthCheck,
}); });
const already_optimal = const already_optimal =
@ -139,13 +190,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
)) || )) ||
resp.params.length === 0; resp.params.length === 0;
if (already_optimal) { if (resp.healthCheckPassed !== undefined) {
if (resp.healthCheckPassed) {
setTimeout(() => alert(tr.deckConfigFsrsGoodFit()), 200);
} else {
setTimeout(
() => alert(tr.deckConfigFsrsBadFitWarning()),
200,
);
}
} else if (already_optimal) {
const msg = resp.fsrsItems const msg = resp.fsrsItems
? tr.deckConfigFsrsParamsOptimal() ? tr.deckConfigFsrsParamsOptimal()
: tr.deckConfigFsrsParamsNoReviews(); : tr.deckConfigFsrsParamsNoReviews();
setTimeout(() => alert(msg), 200); setTimeout(() => alert(msg), 200);
} else { }
if (!already_optimal) {
$config.fsrsParams6 = resp.params; $config.fsrsParams6 = resp.params;
optimized = true;
} }
if (computeParamsProgress) { if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total; computeParamsProgress.current = computeParamsProgress.total;
@ -180,9 +242,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
? $config.paramSearch ? $config.paramSearch
: defaultparamSearch; : defaultparamSearch;
const resp = await evaluateParams({ const resp = await evaluateParams({
params: fsrsParams($config),
search, search,
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(), ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
numOfRelearningSteps: $config.relearnSteps.length,
}); });
if (computeParamsProgress) { if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total; computeParamsProgress.current = computeParamsProgress.total;
@ -237,12 +299,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.7} min={0.7}
max={0.99} max={0.99}
percentage={true} percentage={true}
bind:focused={desiredRetentionFocused}
> >
<SettingTitle on:click={() => openHelpModal("desiredRetention")}> <SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()} {tr.deckConfigDesiredRetention()}
</SettingTitle> </SettingTitle>
</SpinBoxFloatRow> </SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} /> <Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<div class="ms-1 me-1"> <div class="ms-1 me-1">
@ -261,6 +325,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
placeholder={defaultparamSearch} placeholder={defaultparamSearch}
/> />
<SwitchRow bind:value={$fsrsReschedule} defaultValue={false}>
<SettingTitle on:click={() => openHelpModal("rescheduleCardsOnChange")}>
<GlobalLabel title={tr.deckConfigRescheduleCardsOnChange()} />
</SettingTitle>
</SwitchRow>
{#if $fsrsReschedule}
<Warning warning={tr.deckConfigRescheduleCardsWarning()} />
{/if}
<SwitchRow bind:value={$healthCheck} defaultValue={false}>
<SettingTitle on:click={() => openHelpModal("deckConfigHealthCheck")}>
<GlobalLabel title={tr.deckConfigHealthCheck()} />
</SettingTitle>
</SwitchRow>
<button <button
class="btn {computingParams ? 'btn-warning' : 'btn-primary'}" class="btn {computingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!computingParams && computing} disabled={!computingParams && computing}
@ -272,17 +352,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{tr.deckConfigOptimizeButton()} {tr.deckConfigOptimizeButton()}
{/if} {/if}
</button> </button>
<button {#if false}
class="btn {checkingParams ? 'btn-warning' : 'btn-primary'}" <!-- Can be re-enabled by some method in the future -->
disabled={!checkingParams && computing} <button
on:click={() => checkParams()} class="btn {checkingParams ? 'btn-warning' : 'btn-primary'}"
> disabled={!checkingParams && computing}
{#if checkingParams} on:click={() => checkParams()}
{tr.actionsCancel()} >
{:else} {#if checkingParams}
{tr.deckConfigEvaluateButton()} {tr.actionsCancel()}
{/if} {:else}
</button> {tr.deckConfigEvaluateButton()}
{/if}
</button>
{/if}
<div> <div>
{#if computingParams || checkingParams} {#if computingParams || checkingParams}
{computeParamsProgressString} {computeParamsProgressString}
@ -300,18 +383,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</button> </button>
</div> </div>
<div class="m-2">
<SwitchRow bind:value={$fsrsReschedule} defaultValue={false}>
<SettingTitle on:click={() => openHelpModal("rescheduleCardsOnChange")}>
<GlobalLabel title={tr.deckConfigRescheduleCardsOnChange()} />
</SettingTitle>
</SwitchRow>
{#if $fsrsReschedule}
<Warning warning={tr.deckConfigRescheduleCardsWarning()} />
{/if}
</div>
<div class="m-2"> <div class="m-2">
<button class="btn btn-primary" on:click={() => (showSimulator = true)}> <button class="btn btn-primary" on:click={() => (showSimulator = true)}>
{tr.deckConfigFsrsSimulatorExperimental()} {tr.deckConfigFsrsSimulatorExperimental()}
@ -331,4 +402,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn { .btn {
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
:global(.two-line) {
white-space: pre-wrap;
min-height: calc(2ch + 30px);
box-sizing: content-box;
display: flex;
align-content: center;
flex-wrap: wrap;
}
</style> </style>

View file

@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onPresetChange: () => void; export let onPresetChange: () => void;
const fsrs = state.fsrs; const fsrs = state.fsrs;
let newlyEnabled = false;
$: if (!$fsrs) {
newlyEnabled = true;
}
const settings = { const settings = {
fsrs: { fsrs: {
@ -94,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if $fsrs} {#if $fsrs}
<FsrsOptions <FsrsOptions
{state} {state}
{newlyEnabled}
openHelpModal={(key) => openHelpModal={(key) =>
openHelpModal(Object.keys(settings).indexOf(key))} openHelpModal(Object.keys(settings).indexOf(key))}
{onPresetChange} {onPresetChange}

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick } from "svelte";
import * as tr from "@generated/ftl";
export let value: number[]; export let value: number[];
export let defaults: number[]; export let defaults: number[];
@ -28,12 +29,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return params.map((v) => v.toFixed(4)).join(", "); return params.map((v) => v.toFixed(4)).join(", ");
} }
function update(this: HTMLInputElement): void { const validParamCounts = [0, 17, 19, 21];
value = this.value
function update(e: Event): void {
const input = e.target as HTMLTextAreaElement;
const newValue = input.value
.replace(/ /g, "") .replace(/ /g, "")
.split(",") .split(",")
.filter((e) => e) .filter((e) => e)
.map((v) => Number(v)); .map((v) => Number(v));
if (validParamCounts.includes(newValue.length)) {
value = newValue;
} else {
alert(tr.deckConfigInvalidParameters());
input.value = stringValue;
}
} }
</script> </script>

View file

@ -383,38 +383,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if} {/if}
</details> </details>
<div class="m-2"> <details>
<details> <summary>{tr.deckConfigComputeOptimalRetention()}</summary>
<summary>{tr.deckConfigComputeOptimalRetention()}</summary> <button
<button class="btn {computingRetention ? 'btn-warning' : 'btn-primary'}"
class="btn {computingRetention disabled={!computingRetention && computing}
? 'btn-warning' on:click={() => computeRetention()}
: 'btn-primary'}" >
disabled={!computingRetention && computing}
on:click={() => computeRetention()}
>
{#if computingRetention}
{tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if}
</button>
{#if optimalRetention}
{estimatedRetention(optimalRetention)}
{#if optimalRetention - $config.desiredRetention >= 0.01}
<Warning
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
className="alert-warning"
/>
{/if}
{/if}
{#if computingRetention} {#if computingRetention}
<div>{computeRetentionProgressString}</div> {tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if} {/if}
</details> </button>
</div>
{#if optimalRetention}
{estimatedRetention(optimalRetention)}
{#if optimalRetention - $config.desiredRetention >= 0.01}
<Warning
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
className="alert-warning"
/>
{/if}
{/if}
{#if computingRetention}
<div>{computeRetentionProgressString}</div>
{/if}
</details>
<button <button
class="btn {computing ? 'btn-warning' : 'btn-primary'}" class="btn {computing ? 'btn-warning' : 'btn-primary'}"

View file

@ -15,6 +15,7 @@
export let max = 9999; export let max = 9999;
export let step = 0.01; export let step = 0.01;
export let percentage = false; export let percentage = false;
export let focused = false;
</script> </script>
<Row --cols={13}> <Row --cols={13}>
@ -23,7 +24,7 @@
</Col> </Col>
<Col --col-size={6} breakpoint="xs"> <Col --col-size={6} breakpoint="xs">
<ConfigInput> <ConfigInput>
<SpinBox bind:value {min} {max} {step} {percentage} /> <SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} /> <RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput> </ConfigInput>
</Col> </Col>

View file

@ -62,6 +62,7 @@ export class DeckOptionsState {
readonly applyAllParentLimits: Writable<boolean>; readonly applyAllParentLimits: Writable<boolean>;
readonly fsrs: Writable<boolean>; readonly fsrs: Writable<boolean>;
readonly fsrsReschedule: Writable<boolean> = writable(false); readonly fsrsReschedule: Writable<boolean> = writable(false);
readonly fsrsHealthCheck: Writable<boolean>;
readonly daysSinceLastOptimization: Writable<number>; readonly daysSinceLastOptimization: Writable<number>;
readonly currentPresetName: Writable<string>; readonly currentPresetName: Writable<string>;
/** Used to detect if there are any pending changes */ /** Used to detect if there are any pending changes */
@ -103,6 +104,7 @@ export class DeckOptionsState {
this.newCardsIgnoreReviewLimit = writable(data.newCardsIgnoreReviewLimit); this.newCardsIgnoreReviewLimit = writable(data.newCardsIgnoreReviewLimit);
this.applyAllParentLimits = writable(data.applyAllParentLimits); this.applyAllParentLimits = writable(data.applyAllParentLimits);
this.fsrs = writable(data.fsrs); this.fsrs = writable(data.fsrs);
this.fsrsHealthCheck = writable(data.fsrsHealthCheck);
this.daysSinceLastOptimization = writable(data.daysSinceLastFsrsOptimize); this.daysSinceLastOptimization = writable(data.daysSinceLastFsrsOptimize);
// decrement the use count of the starting item, as we'll apply +1 to currently // decrement the use count of the starting item, as we'll apply +1 to currently
@ -267,6 +269,7 @@ export class DeckOptionsState {
applyAllParentLimits: get(this.applyAllParentLimits), applyAllParentLimits: get(this.applyAllParentLimits),
fsrs: get(this.fsrs), fsrs: get(this.fsrs),
fsrsReschedule: get(this.fsrsReschedule), fsrsReschedule: get(this.fsrsReschedule),
fsrsHealthCheck: get(this.fsrsHealthCheck),
}; };
} }

View file

@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Callback } from "@tslib/typing"; import type { Callback } from "@tslib/typing";
import { singleCallback } from "@tslib/typing"; import { singleCallback } from "@tslib/typing";
import { getContext, onDestroy, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import type { Readable } from "svelte/store"; import { writable, type Readable } from "svelte/store";
import DropdownItem from "$lib/components/DropdownItem.svelte"; import DropdownItem from "$lib/components/DropdownItem.svelte";
import Icon from "$lib/components/Icon.svelte"; import Icon from "$lib/components/Icon.svelte";
@ -33,7 +33,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
opacityStateStore, opacityStateStore,
} from "./store"; } from "./store";
import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index"; import { drawEllipse, drawPolygon, drawRectangle, drawText } from "./tools/index";
import { makeMaskTransparent } from "./tools/lib"; import { makeMaskTransparent, SHAPE_MASK_COLOR } from "./tools/lib";
import { enableSelectable, stopDraw } from "./tools/lib"; import { enableSelectable, stopDraw } from "./tools/lib";
import { import {
alignTools, alignTools,
@ -42,7 +42,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
zoomTools, zoomTools,
} from "./tools/more-tools"; } from "./tools/more-tools";
import { toggleTranslucentKeyCombination } from "./tools/shortcuts"; import { toggleTranslucentKeyCombination } from "./tools/shortcuts";
import { tools } from "./tools/tool-buttons"; import { tools, type ActiveTool } from "./tools/tool-buttons";
import { drawCursor } from "./tools/tool-cursor"; import { drawCursor } from "./tools/tool-cursor";
import { removeUnfinishedPolygon } from "./tools/tool-polygon"; import { removeUnfinishedPolygon } from "./tools/tool-polygon";
import { undoRedoTools, undoStack } from "./tools/tool-undo-redo"; import { undoRedoTools, undoStack } from "./tools/tool-undo-redo";
@ -54,10 +54,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onWheelDrag, onWheelDrag,
onWheelDragX, onWheelDragX,
} from "./tools/tool-zoom"; } from "./tools/tool-zoom";
import { fillMask } from "./tools/tool-fill";
export let canvas; export let canvas;
export let iconSize; export let iconSize;
export let activeTool = "cursor"; export let activeTool: ActiveTool = "cursor";
let showAlignTools = false; let showAlignTools = false;
let leftPos = 82; let leftPos = 82;
let maskOpacity = false; let maskOpacity = false;
@ -72,6 +73,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const controlKey = "Control"; const controlKey = "Control";
const shiftKey = "Shift"; const shiftKey = "Shift";
let removeHandlers: Callback; let removeHandlers: Callback;
let colourRef: HTMLInputElement | undefined;
const colour = writable(SHAPE_MASK_COLOR);
function onClick(event: MouseEvent) { function onClick(event: MouseEvent) {
const upperCanvas = document.querySelector(".upper-canvas"); const upperCanvas = document.querySelector(".upper-canvas");
@ -168,7 +171,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
const handleToolChanges = (newActiveTool: string) => { const handleToolChanges = (newActiveTool: ActiveTool, clicked: boolean = false) => {
disableFunctions(); disableFunctions();
enableSelectable(canvas, true); enableSelectable(canvas, true);
// remove unfinished polygon when switching to other tools // remove unfinished polygon when switching to other tools
@ -193,6 +196,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
handleToolChanges(activeTool); handleToolChanges(activeTool);
}); });
break; break;
case "fill-mask":
if (clicked) {
colourRef?.click();
}
fillMask(canvas, colour);
break;
} }
}; };
@ -231,16 +240,30 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}); });
</script> </script>
<div class="tool-bar-container"> <datalist id="colour-palette">
<option value={SHAPE_MASK_COLOR}></option>
</datalist>
<input
type="color"
bind:this={colourRef}
style:display="none"
list="colour-palette"
value={SHAPE_MASK_COLOR}
on:input={(e) => ($colour = e.currentTarget!.value)}
/>
<div class="tool-bar-container" style:--fill-tool-colour={$colour}>
{#each tools as tool} {#each tools as tool}
{@const active = activeTool == tool.id}
<IconButton <IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}" class="tool-icon-button {active ? 'active-tool' : ''} {tool.id}"
{iconSize} iconSize={iconSize * (tool["iconSizeMult"] ?? 1)}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})" tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
active={activeTool === tool.id} {active}
on:click={() => { on:click={() => {
activeTool = tool.id; activeTool = tool.id;
handleToolChanges(activeTool); handleToolChanges(activeTool, true);
}} }}
> >
<Icon icon={tool.icon} /> <Icon icon={tool.icon} />
@ -250,7 +273,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
keyCombination={tool.shortcut} keyCombination={tool.shortcut}
on:action={() => { on:action={() => {
activeTool = tool.id; activeTool = tool.id;
handleToolChanges(activeTool); handleToolChanges(activeTool, true);
}} }}
/> />
{/if} {/if}
@ -551,6 +574,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px; padding-bottom: 100px;
} }
:global(.fill-mask svg) {
fill: var(--fill-tool-colour) !important;
stroke: black;
stroke-width: 1px;
}
:global([dir="rtl"] .tool-bar-container) { :global([dir="rtl"] .tool-bar-container) {
left: unset; left: unset;
right: 2px; right: 2px;

View file

@ -217,7 +217,7 @@ function drawShapes(
context, context,
size, size,
shape, shape,
fill: properties.inActiveShapeColor, fill: shape.fill ?? properties.inActiveShapeColor,
stroke: properties.inActiveBorder.color, stroke: properties.inActiveBorder.color,
strokeWidth: properties.inActiveBorder.width, strokeWidth: properties.inActiveBorder.width,
}); });
@ -358,7 +358,7 @@ function drawShape({
maxWidth + TEXT_PADDING, maxWidth + TEXT_PADDING,
totalHeight + TEXT_PADDING, totalHeight + TEXT_PADDING,
); );
ctx.fillStyle = "#000"; ctx.fillStyle = shape.fill ?? "#000";
for (const line of linePositions) { for (const line of linePositions) {
ctx.fillText(line.text, line.x, line.y); ctx.fillText(line.text, line.x, line.y);
} }

View file

@ -77,6 +77,7 @@ function extractShapeFromRenderedCloze(cloze: HTMLDivElement): Shape | null {
scale: cloze.dataset.scale, scale: cloze.dataset.scale,
fs: cloze.dataset.fontSize, fs: cloze.dataset.fontSize,
angle: cloze.dataset.angle, angle: cloze.dataset.angle,
...(cloze.dataset.fill == null ? {} : { fill: cloze.dataset.fill }),
}; };
return buildShape(type, props); return buildShape(type, props);
} }

View file

@ -19,11 +19,12 @@ export class Text extends Shape {
text = "", text = "",
scaleX = 1, scaleX = 1,
scaleY = 1, scaleY = 1,
fill = TEXT_COLOR,
fontSize, fontSize,
...rest ...rest
}: ConstructorParams<Text> = {}) { }: ConstructorParams<Text> = {}) {
super(rest); super(rest);
this.fill = TEXT_COLOR; this.fill = fill;
this.text = text; this.text = text;
this.scaleX = scaleX; this.scaleX = scaleX;
this.scaleY = scaleY; this.scaleY = scaleY;
@ -38,6 +39,7 @@ export class Text extends Shape {
// scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio // scaleX and scaleY are guaranteed to be equal since we lock the aspect ratio
scale: floatToDisplay(this.scaleX), scale: floatToDisplay(this.scaleX),
fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined, fs: this.fontSize ? floatToDisplay(this.fontSize) : undefined,
...(this.fill === TEXT_COLOR ? {} : { fill: this.fill }),
}; };
} }

View file

@ -105,6 +105,21 @@ export const unGroupShapes = (canvas: fabric.Canvas): void => {
redraw(canvas); redraw(canvas);
}; };
/** Check for the target within a (potentially nested) group
* NOTE: assumes that masks do not overlap */
export const findTargetInGroup = (group: fabric.Group, p: fabric.Point): fabric.Object | undefined => {
if (!group) { return; }
const point = fabric.util.transformPoint(p, fabric.util.invertTransform(group.calcOwnMatrix()));
for (const shape of group.getObjects()) {
if (shape instanceof fabric.Group) {
const ret = findTargetInGroup(shape, point);
if (ret) { return ret; }
} else if (shape.containsPoint(point)) {
return shape;
}
}
};
const copyItem = (canvas: fabric.Canvas): void => { const copyItem = (canvas: fabric.Canvas): void => {
const activeObject = canvas.getActiveObject(); const activeObject = canvas.getActiveObject();
if (!activeObject) { if (!activeObject) {

View file

@ -6,6 +6,7 @@ export const rectangleKeyCombination = "R";
export const ellipseKeyCombination = "E"; export const ellipseKeyCombination = "E";
export const polygonKeyCombination = "P"; export const polygonKeyCombination = "P";
export const textKeyCombination = "T"; export const textKeyCombination = "T";
export const fillKeyCombination = "C";
export const magnifyKeyCombination = "M"; export const magnifyKeyCombination = "M";
export const undoKeyCombination = "Control+Z"; export const undoKeyCombination = "Control+Z";
export const redoKeyCombination = "Control+Y"; export const redoKeyCombination = "Control+Y";

View file

@ -6,6 +6,7 @@ import * as tr from "@generated/ftl";
import { import {
mdiCursorDefaultOutline, mdiCursorDefaultOutline,
mdiEllipseOutline, mdiEllipseOutline,
mdiFormatColorFill,
mdiRectangleOutline, mdiRectangleOutline,
mdiTextBox, mdiTextBox,
mdiVectorPolygonVariant, mdiVectorPolygonVariant,
@ -14,6 +15,7 @@ import {
import { import {
cursorKeyCombination, cursorKeyCombination,
ellipseKeyCombination, ellipseKeyCombination,
fillKeyCombination,
polygonKeyCombination, polygonKeyCombination,
rectangleKeyCombination, rectangleKeyCombination,
textKeyCombination, textKeyCombination,
@ -50,4 +52,13 @@ export const tools = [
tooltip: tr.editingImageOcclusionTextTool, tooltip: tr.editingImageOcclusionTextTool,
shortcut: textKeyCombination, shortcut: textKeyCombination,
}, },
]; {
id: "fill-mask",
icon: mdiFormatColorFill,
iconSizeMult: 1.4,
tooltip: tr.editingImageOcclusionFillTool,
shortcut: fillKeyCombination,
},
] as const;
export type ActiveTool = typeof tools[number]["id"];

View file

@ -0,0 +1,28 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { fabric } from "fabric";
import { get, type Readable } from "svelte/store";
import { findTargetInGroup, stopDraw } from "./lib";
import { undoStack } from "./tool-undo-redo";
export const fillMask = (canvas: fabric.Canvas, colourStore: Readable<string>): void => {
// remove selectable for shapes
canvas.discardActiveObject();
canvas.forEachObject(function(o) {
o.selectable = false;
});
canvas.selectionColor = "rgba(0, 0, 0, 0)";
stopDraw(canvas);
canvas.on("mouse:down", function(o) {
const target = o.target instanceof fabric.Group
? findTargetInGroup(o.target, canvas.getPointer(o.e) as fabric.Point)
: o.target;
const colour = get(colourStore);
if (!target || target.fill === colour) { return; }
target.fill = colour;
undoStack.onObjectModified();
});
};

View file

@ -223,7 +223,7 @@ export const modifiedPolygon = (canvas: fabric.Canvas, polygon: fabric.Polygon):
}); });
const polygon1 = new fabric.Polygon(transformedPoints, { const polygon1 = new fabric.Polygon(transformedPoints, {
fill: SHAPE_MASK_COLOR, fill: polygon.fill ?? SHAPE_MASK_COLOR,
objectCaching: false, objectCaching: false,
stroke: BORDER_COLOR, stroke: BORDER_COLOR,
strokeWidth: 1, strokeWidth: 1,