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>
Spiritual Father <https://github.com/spiritualfather>
Emmanuel Ferdman <https://github.com/emmanuel-ferdman>
Marvin Kopf <marvinkopf@outlook.com>
********************
The text of the 3 clause BSD license follows:

14
Cargo.lock generated
View file

@ -2295,7 +2295,7 @@ dependencies = [
[[package]]
name = "fsrs"
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 = [
"burn",
"itertools 0.14.0",
@ -5010,9 +5010,9 @@ dependencies = [
[[package]]
name = "priority-queue"
version = "2.3.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef08705fa1589a1a59aa924ad77d14722cb0cd97b67dd5004ed5f4a4873fce8d"
checksum = "5676d703dda103cbb035b653a9f11448c0a7216c7926bd35fcb5865475d0c970"
dependencies = [
"autocfg",
"equivalent",
@ -6290,18 +6290,18 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]]
name = "snafu"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019"
checksum = "320b01e011bf8d5d7a4a4a4be966d9160968935849c83b918827f6a435e7f627"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917"
checksum = "1961e2ef424c1424204d3a5d6975f934f56b6d50ff5732382d84ebf460e147f7"
dependencies = [
"heck",
"proc-macro2",

View file

@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs]
# version = "3.0.0"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "33ec3ee4d5d73e704633469cf5bf1a42e620a524"
rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f"
# path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies]
@ -125,7 +125,7 @@ serde_tuple = "0.5.0"
sha1 = "0.10.6"
sha2 = { version = "0.10.8" }
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"] }
syn = { version = "2.0.82", features = ["parsing", "printing"] }
tar = "0.4.42"

View file

@ -3151,7 +3151,7 @@
},
{
"name": "priority-queue",
"version": "2.3.1",
"version": "2.5.0",
"authors": "Gianmarco Garrisi <gianmarcogarrisi@tutanota.com>",
"repository": "https://github.com/garro95/priority-queue",
"license": "LGPL-3.0-or-later OR MPL-2.0",
@ -4015,7 +4015,7 @@
},
{
"name": "snafu",
"version": "0.8.5",
"version": "0.8.6",
"authors": "Jake Goulding <jake.goulding@gmail.com>",
"repository": "https://github.com/shepmaster/snafu",
"license": "Apache-2.0 OR MIT",
@ -4024,7 +4024,7 @@
},
{
"name": "snafu-derive",
"version": "0.8.5",
"version": "0.8.6",
"authors": "Jake Goulding <jake.goulding@gmail.com>",
"repository": "https://github.com/shepmaster/snafu",
"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-minimum-recommended-retention = Minimum recommended retention
deck-config-optimize-button = Optimize Current Preset
deck-config-health-check = Check health when optimizing (slow)
deck-config-compute-button = Compute
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-desired-retention = Desired 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
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-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-workload-factor-change = Approximate workload: {$factor}x
(compared to {$previousDR}% desired retention)
deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
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 =
{ $reviews ->
[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-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-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-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.
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-count = Review Count/Day
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-polygon-tool = Polygon
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-reset = Reset 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);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
}
// Implicitly includes any of the above methods that are not listed in the
@ -35,6 +37,17 @@ message DeckConfigId {
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 {
string ignore_revlogs_before_date = 1;
string search = 2;
@ -222,6 +235,7 @@ message DeckConfigsForUpdate {
// only applies to v3 scheduler
bool new_cards_ignore_review_limit = 7;
bool fsrs = 8;
bool fsrs_health_check = 11;
bool apply_all_parent_limits = 9;
uint32 days_since_last_fsrs_optimize = 10;
}
@ -245,4 +259,5 @@ message UpdateDeckConfigsRequest {
bool fsrs = 8;
bool apply_all_parent_limits = 9;
bool fsrs_reschedule = 10;
bool fsrs_health_check = 11;
}

View file

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

View file

@ -76,7 +76,7 @@ class MediaManager(DeprecatedNamesMixin):
return self.col._backend.strip_av_tags(text)
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)
return [
x.filename

View file

@ -9,10 +9,14 @@ These can be accessed via eg card.question_av_tags()
from __future__ import annotations
import os
import os.path
import re
from dataclasses import dataclass
from typing import Union
from anki import hooks
@dataclass
class TTSTag:
@ -34,10 +38,30 @@ class SoundOrVideoTag:
"""Contains the filename inside a [sound:...] tag.
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
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.
AVTag = Union[SoundOrVideoTag, TTSTag]

View file

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

View file

@ -53,6 +53,7 @@ from aqt.operations.tag import (
from aqt.qt import *
from aqt.sound import av_player
from aqt.switch import Switch
from aqt.theme import WidgetStyle
from aqt.undo import UndoActionsInfo
from aqt.utils import (
HelpPage,
@ -170,6 +171,7 @@ class Browser(QMainWindow):
if self.height() != 0:
self.aspect_ratio = self.width() / self.height()
self.set_layout(self.mw.pm.browser_layout(), True)
self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden())
# disable undo/redo
self.on_undo_state_change(mw.undo_actions_info())
# legacy alias
@ -726,6 +728,7 @@ class Browser(QMainWindow):
self.form.actionSidebarFilter.triggered,
self.focusSidebarSearchBar,
)
qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange)
grid = QGridLayout()
grid.addWidget(self.sidebar.searchBar, 0, 0)
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)
def showSidebar(self, show: bool = True) -> None:
want_visible = not self.sidebarDockWidget.isVisible()
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()
def focusSidebar(self) -> None:
@ -1128,6 +1139,9 @@ class Browser(QMainWindow):
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)
# Add cancel button

View file

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

View file

@ -7,9 +7,8 @@ import enum
import logging
import mimetypes
import os
import random
import re
import string
import secrets
import sys
import threading
import traceback
@ -659,6 +658,7 @@ exposed_backend_list = [
"simulate_fsrs_review",
# DeckConfigService
"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")
_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32))
_APIKEY = secrets.token_urlsafe(32)
def _have_api_access() -> bool:

View file

@ -41,6 +41,7 @@ import time
from queue import Empty, Full, Queue
from shutil import which
import aqt
from anki.utils import is_mac, is_win
@ -68,6 +69,7 @@ if is_win:
# pylint: disable=import-error
import pywintypes
import win32file # pytype: disable=import-error
import win32job
import win32pipe
import winerror
@ -130,6 +132,22 @@ class MPVBase:
def _start_process(self):
"""Start the mpv process."""
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):
"""Stop the mpv process."""
@ -444,7 +462,7 @@ class MPV(MPVBase):
super().__init__(*args, **kwargs)
self._register_callbacks()
aqt.mw.taskman.run_in_background(self._register_callbacks, None)
def _register_callbacks(self):
self._callbacks = {}

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import os
import os.path
import platform
import re
import subprocess
@ -23,7 +24,6 @@ from markdown import markdown
import aqt
import aqt.mpv
import aqt.qt
from anki import hooks
from anki.cards import Card
from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag
from anki.utils import is_lin, is_mac, is_win, namedtmp
@ -177,15 +177,27 @@ class AVPlayer:
self._stop_if_playing()
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)])
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:
self.current_caller_interrupted = True
self.current_caller = caller
self.play_file(filename)
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._play_next_if_idle()
@ -327,7 +339,7 @@ class SimpleProcessPlayer(Player): # pylint: disable=abstract-method
def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag)
self._process = subprocess.Popen(
self.args + ["--", tag.filename],
self.args + ["--", tag.path(self._media_folder)],
env=self.env,
cwd=self._media_folder,
stdout=subprocess.DEVNULL,
@ -453,8 +465,7 @@ class MpvManager(MPV, SoundOrVideoPlayer):
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
assert isinstance(tag, SoundOrVideoTag)
self._on_done = on_done
filename = hooks.media_file_filter(tag.filename)
path = os.path.join(self.media_folder, filename)
path = tag.path(self.media_folder)
if self.mpv_version is None or self.mpv_version >= (0, 38, 0):
self.command("loadfile", path, "replace", -1, "pause=no")
@ -506,10 +517,8 @@ class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer):
def _play(self, tag: AVTag) -> None:
assert isinstance(tag, SoundOrVideoTag)
filename = hooks.media_file_filter(tag.filename)
self._process = subprocess.Popen(
self.args + ["--", filename],
self.args + ["--", tag.path(self.media_folder)],
env=self.env,
cwd=self.media_folder,
stdin=subprocess.PIPE,

View file

@ -40,6 +40,7 @@ pub enum BoolKey {
WithScheduling,
WithDeckConfigs,
Fsrs,
FsrsHealthCheck,
LoadBalancerEnabled,
FsrsShortTermWithStepsEnabled,
#[strum(to_string = "normalize_note_text")]
@ -76,6 +77,7 @@ impl Collection {
| BoolKey::RestorePositionBrowser
| BoolKey::RestorePositionReviewer
| BoolKey::LoadBalancerEnabled
| BoolKey::FsrsHealthCheck
| BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true),
// 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.
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
} else if self.inner.fsrs_params_5.len() == 19 {
} else if !self.inner.fsrs_params_5.is_empty() {
&self.inner.fsrs_params_5
} else {
&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),
})
}
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 {
@ -124,6 +192,7 @@ impl From<anki_proto::deck_config::UpdateDeckConfigsRequest> for UpdateDeckConfi
apply_all_parent_limits: c.apply_all_parent_limits,
fsrs: c.fsrs,
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::UpdateMemoryStateRequest;
use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config;
use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::search::JoinSearches;
use crate::search::Negated;
use crate::search::SearchNode;
@ -41,6 +42,7 @@ pub struct UpdateDeckConfigsRequest {
pub apply_all_parent_limits: bool,
pub fsrs: bool,
pub fsrs_reschedule: bool,
pub fsrs_health_check: bool,
}
impl Collection {
@ -71,6 +73,7 @@ impl Collection {
new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit),
apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits),
fsrs: self.get_config_bool(BoolKey::Fsrs),
fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck),
days_since_last_fsrs_optimize,
})
}
@ -300,6 +303,7 @@ impl Collection {
req.new_cards_ignore_review_limit,
)?;
self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?;
self.set_config_bool_inner(BoolKey::FsrsHealthCheck, req.fsrs_health_check)?;
Ok(())
}
@ -365,14 +369,15 @@ impl Collection {
};
let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?;
let num_of_relearning_steps = config.inner.relearn_steps.len();
match self.compute_params(
&search,
match self.compute_params(ComputeParamsRequest {
search: &search,
ignore_revlogs_before_ms,
idx as u32 + 1,
config_len,
config.fsrs_params(),
current_preset: idx as u32 + 1,
total_presets: config_len,
current_params: config.fsrs_params(),
num_of_relearning_steps,
) {
health_check: false,
}) {
Ok(params) => {
println!("{}: {:?}", config.name, 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_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?;
col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?;
col.set_config_bool_inner(BoolKey::FsrsHealthCheck, true)?;
// pretend we're in sync
let stamps = col.storage.get_collection_timestamps()?;
@ -488,6 +494,7 @@ mod test {
apply_all_parent_limits: false,
fsrs: false,
fsrs_reschedule: false,
fsrs_health_check: true,
};
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 {
match property.name.as_str() {
"left" => {
"left" | "top" | "angle" | "fill" => {
if !property.value.is_empty() {
result.push_str(&format!("data-left=\"{}\" ", 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));
result.push_str(&format!("data-{}=\"{}\" ", property.name, property.value));
}
}
"width" => {

View file

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

View file

@ -16,7 +16,6 @@ use super::rescheduler::Rescheduler;
use crate::card::CardType;
use crate::prelude::*;
use crate::revlog::RevlogEntry;
use crate::revlog::RevlogReviewKind;
use crate::scheduler::answering::get_fuzz_seed;
use crate::scheduler::fsrs::params::reviews_for_fsrs;
use crate::scheduler::fsrs::params::Params;
@ -163,15 +162,8 @@ impl Collection {
);
}
*due = new_due;
// Add a rescheduled revlog entry if the last entry wasn't
// rescheduled
if !last_info.last_revlog_is_rescheduled {
self.log_rescheduled_review(
&card,
original_interval,
usn,
)?;
}
// Add a rescheduled revlog entry
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
/// when the card has subsequently been rescheduled to a different day.
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.
@ -303,20 +292,12 @@ fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogIn
.into_iter()
.for_each(|(card_id, group)| {
let mut last_reviewed_at = None;
let mut last_revlog_is_rescheduled = false;
for e in group.into_iter() {
if e.button_chosen >= 1 {
last_reviewed_at = Some(e.id.as_secs());
}
last_revlog_is_rescheduled = e.review_kind == RevlogReviewKind::Rescheduled;
}
out.insert(
card_id,
LastRevlogInfo {
last_reviewed_at,
last_revlog_is_rescheduled,
},
);
out.insert(card_id, LastRevlogInfo { last_reviewed_at });
});
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)
}
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 {
/// 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
/// value.
pub fn compute_params(
&mut self,
search: &str,
ignore_revlogs_before: TimestampMillis,
current_preset: u32,
total_presets: u32,
current_params: &Params,
num_of_relearning_steps: usize,
request: ComputeParamsRequest,
) -> 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();
let timing = self.timing_today()?;
let revlogs = self.revlog_for_srs(search)?;
@ -75,6 +102,7 @@ impl Collection {
return Ok(ComputeFsrsParamsResponse {
params: current_params.to_vec(),
fsrs_items,
health_check_passed: None,
});
}
// adapt the progress handler to our built-in progress handling
@ -108,22 +136,22 @@ impl Collection {
let (progress, progress_thread) = create_progress_thread()?;
let fsrs = FSRS::new(None)?;
let mut params = fsrs.compute_parameters(ComputeParametersInput {
let input = ComputeParametersInput {
train_set: items.clone(),
progress: Some(progress.clone()),
enable_short_term: true,
num_relearning_steps: Some(num_of_relearning_steps),
})?;
};
let mut params = fsrs.compute_parameters(input.clone())?;
progress_thread.join().ok();
if let Ok(fsrs) = FSRS::new(Some(current_params)) {
let current_log_loss = fsrs.evaluate(items.clone(), |_| true)?.log_loss;
if let Ok(current_fsrs) = FSRS::new(Some(current_params)) {
let current_log_loss = current_fsrs.evaluate(items.clone(), |_| true)?.log_loss;
let optimized_fsrs = FSRS::new(Some(&params))?;
let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss;
if current_log_loss <= optimized_log_loss {
if num_of_relearning_steps <= 1 {
params = current_params.to_vec();
} else {
let current_fsrs = FSRS::new(Some(current_params))?;
let memory_state = MemoryState {
stability: 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(
@ -218,22 +273,24 @@ impl Collection {
pub fn evaluate_params(
&mut self,
params: &Params,
search: &str,
ignore_revlogs_before: TimestampMillis,
num_of_relearning_steps: usize,
) -> Result<ModelEvaluation> {
let timing = self.timing_today()?;
let mut anki_progress = self.new_progress_handler::<ComputeParamsProgress>();
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 revlogs = self.revlog_for_srs(search)?;
let (items, review_count) =
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;
let fsrs = FSRS::new(Some(params))?;
Ok(fsrs.evaluate(items, |ip| {
let fsrs = FSRS::new(None)?;
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
.update(false, |p| {
p.total_iterations = ip.total as u32;

View file

@ -23,6 +23,7 @@ use fsrs::FSRS;
use crate::backend::Backend;
use crate::prelude::*;
use crate::scheduler::fsrs::params::ComputeParamsRequest;
use crate::scheduler::new::NewCardDueOrder;
use crate::scheduler::states::CardState;
use crate::scheduler::states::SchedulingStates;
@ -264,14 +265,15 @@ impl crate::services::SchedulerService for Collection {
&mut self,
input: scheduler::ComputeFsrsParamsRequest,
) -> Result<scheduler::ComputeFsrsParamsResponse> {
self.compute_params(
&input.search,
input.ignore_revlogs_before_ms.into(),
1,
1,
&input.current_params,
input.num_of_relearning_steps as usize,
)
self.compute_params(ComputeParamsRequest {
search: &input.search,
ignore_revlogs_before_ms: input.ignore_revlogs_before_ms.into(),
current_preset: 1,
total_presets: 1,
current_params: &input.current_params,
num_of_relearning_steps: input.num_of_relearning_steps as usize,
health_check: input.health_check,
})
}
fn simulate_fsrs_review(
@ -295,9 +297,9 @@ impl crate::services::SchedulerService for Collection {
input: scheduler::EvaluateParamsRequest,
) -> Result<scheduler::EvaluateParamsResponse> {
let ret = self.evaluate_params(
&input.params,
&input.search,
input.ignore_revlogs_before_ms.into(),
input.num_of_relearning_steps as usize,
)?;
Ok(scheduler::EvaluateParamsResponse {
log_loss: ret.log_loss,
@ -372,7 +374,11 @@ impl crate::services::BackendSchedulerService for Backend {
enable_short_term: true,
num_relearning_steps: None,
})?;
Ok(ComputeFsrsParamsResponse { params, fsrs_items })
Ok(ComputeFsrsParamsResponse {
params,
fsrs_items,
health_check_passed: None,
})
}
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::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 {
fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> {
if let ValueRef::Integer(i) = value {
@ -747,6 +758,24 @@ impl super::SqliteStorage {
.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)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> {
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[] = [];
$: label = choices.find((c) => c.value === value)?.label;
</script>
<Select
bind:value
{label}
{disabled}
list={choices}
parser={(item) => ({
$: parser = (item) => ({
content: item.label,
value: 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;
let input: HTMLInputElement;
let focused = false;
export let focused = false;
let multiplier: number;
$: multiplier = percentage ? 100 : 1;
@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue}
bind:this={input}
on:blur={update}
on:change={update}
on:input={onInput}
on:focusin={() => (focused = true)}
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 FormatBold_ from "@mdi/svg/svg/format-bold.svg?component";
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?url";
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 mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ };
export const mdiFormatBold = { url: formatBold_, component: FormatBold_ };
export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ };
export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ };
export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ };
export const mdiGroup = { url: group_, component: Group_ };

View file

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

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
computeFsrsParams,
evaluateParams,
getRetentionWorkload,
setWantsAbort,
} from "@generated/backend";
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 ParamsSearchRow from "./ParamsSearchRow.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 openHelpModal: (String) => void;
export let onPresetChange: () => void;
export let newlyEnabled = false;
const config = state.currentConfig;
const defaults = state.defaults;
@ -39,18 +44,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: lastOptimizationWarning =
$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 computingParams = false;
let checkingParams = false;
const healthCheck = state.fsrsHealthCheck;
$: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning(
roundedRetention,
fsrsParams($config),
);
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
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);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
@ -67,23 +96,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder,
});
function getRetentionWarning(retention: number, params: number[]): string {
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100;
const days = Math.round(
(stability / factor) * (Math.pow(retention, 1 / decay) - 1),
);
if (days === 100) {
const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;
function getRetentionLongShortWarning(retention: number) {
if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooLow();
} else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooHigh();
} else {
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 {
if (retention < 0.7 || retention > 0.97) {
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";
} else {
return "alert-info";
@ -130,6 +180,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
currentParams: params,
numOfRelearningSteps: numOfRelearningStepsInDay,
healthCheck: $healthCheck,
});
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;
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
? tr.deckConfigFsrsParamsOptimal()
: tr.deckConfigFsrsParamsNoReviews();
setTimeout(() => alert(msg), 200);
} else {
}
if (!already_optimal) {
$config.fsrsParams6 = resp.params;
optimized = true;
}
if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total;
@ -180,9 +242,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
? $config.paramSearch
: defaultparamSearch;
const resp = await evaluateParams({
params: fsrsParams($config),
search,
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
numOfRelearningSteps: $config.relearnSteps.length,
});
if (computeParamsProgress) {
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}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<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}
/>
<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
class="btn {computingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!computingParams && computing}
@ -272,17 +352,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{tr.deckConfigOptimizeButton()}
{/if}
</button>
<button
class="btn {checkingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!checkingParams && computing}
on:click={() => checkParams()}
>
{#if checkingParams}
{tr.actionsCancel()}
{:else}
{tr.deckConfigEvaluateButton()}
{/if}
</button>
{#if false}
<!-- Can be re-enabled by some method in the future -->
<button
class="btn {checkingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!checkingParams && computing}
on:click={() => checkParams()}
>
{#if checkingParams}
{tr.actionsCancel()}
{:else}
{tr.deckConfigEvaluateButton()}
{/if}
</button>
{/if}
<div>
{#if computingParams || checkingParams}
{computeParamsProgressString}
@ -300,18 +383,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</button>
</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">
<button class="btn btn-primary" on:click={() => (showSimulator = true)}>
{tr.deckConfigFsrsSimulatorExperimental()}
@ -331,4 +402,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn {
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>

View file

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

View file

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

View file

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

View file

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

View file

@ -62,6 +62,7 @@ export class DeckOptionsState {
readonly applyAllParentLimits: Writable<boolean>;
readonly fsrs: Writable<boolean>;
readonly fsrsReschedule: Writable<boolean> = writable(false);
readonly fsrsHealthCheck: Writable<boolean>;
readonly daysSinceLastOptimization: Writable<number>;
readonly currentPresetName: Writable<string>;
/** Used to detect if there are any pending changes */
@ -103,6 +104,7 @@ export class DeckOptionsState {
this.newCardsIgnoreReviewLimit = writable(data.newCardsIgnoreReviewLimit);
this.applyAllParentLimits = writable(data.applyAllParentLimits);
this.fsrs = writable(data.fsrs);
this.fsrsHealthCheck = writable(data.fsrsHealthCheck);
this.daysSinceLastOptimization = writable(data.daysSinceLastFsrsOptimize);
// 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),
fsrs: get(this.fsrs),
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 { singleCallback } from "@tslib/typing";
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 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,
} from "./store";
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 {
alignTools,
@ -42,7 +42,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
zoomTools,
} from "./tools/more-tools";
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 { removeUnfinishedPolygon } from "./tools/tool-polygon";
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,
onWheelDragX,
} from "./tools/tool-zoom";
import { fillMask } from "./tools/tool-fill";
export let canvas;
export let iconSize;
export let activeTool = "cursor";
export let activeTool: ActiveTool = "cursor";
let showAlignTools = false;
let leftPos = 82;
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 shiftKey = "Shift";
let removeHandlers: Callback;
let colourRef: HTMLInputElement | undefined;
const colour = writable(SHAPE_MASK_COLOR);
function onClick(event: MouseEvent) {
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();
enableSelectable(canvas, true);
// 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);
});
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>
<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}
{@const active = activeTool == tool.id}
<IconButton
class="tool-icon-button {activeTool == tool.id ? 'active-tool' : ''}"
{iconSize}
class="tool-icon-button {active ? 'active-tool' : ''} {tool.id}"
iconSize={iconSize * (tool["iconSizeMult"] ?? 1)}
tooltip="{tool.tooltip()} ({getPlatformString(tool.shortcut)})"
active={activeTool === tool.id}
{active}
on:click={() => {
activeTool = tool.id;
handleToolChanges(activeTool);
handleToolChanges(activeTool, true);
}}
>
<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}
on:action={() => {
activeTool = tool.id;
handleToolChanges(activeTool);
handleToolChanges(activeTool, true);
}}
/>
{/if}
@ -551,6 +574,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
padding-bottom: 100px;
}
:global(.fill-mask svg) {
fill: var(--fill-tool-colour) !important;
stroke: black;
stroke-width: 1px;
}
:global([dir="rtl"] .tool-bar-container) {
left: unset;
right: 2px;

View file

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

View file

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

View file

@ -19,11 +19,12 @@ export class Text extends Shape {
text = "",
scaleX = 1,
scaleY = 1,
fill = TEXT_COLOR,
fontSize,
...rest
}: ConstructorParams<Text> = {}) {
super(rest);
this.fill = TEXT_COLOR;
this.fill = fill;
this.text = text;
this.scaleX = scaleX;
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
scale: floatToDisplay(this.scaleX),
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);
};
/** 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 activeObject = canvas.getActiveObject();
if (!activeObject) {

View file

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

View file

@ -6,6 +6,7 @@ import * as tr from "@generated/ftl";
import {
mdiCursorDefaultOutline,
mdiEllipseOutline,
mdiFormatColorFill,
mdiRectangleOutline,
mdiTextBox,
mdiVectorPolygonVariant,
@ -14,6 +15,7 @@ import {
import {
cursorKeyCombination,
ellipseKeyCombination,
fillKeyCombination,
polygonKeyCombination,
rectangleKeyCombination,
textKeyCombination,
@ -50,4 +52,13 @@ export const tools = [
tooltip: tr.editingImageOcclusionTextTool,
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, {
fill: SHAPE_MASK_COLOR,
fill: polygon.fill ?? SHAPE_MASK_COLOR,
objectCaching: false,
stroke: BORDER_COLOR,
strokeWidth: 1,