mirror of
https://github.com/ankitects/anki.git
synced 2026-01-13 22:13:58 -05:00
Merge branch 'main' into Fix/remove-the-lower-limit-of-interval-when-set-due-date
This commit is contained in:
commit
1f51591c53
49 changed files with 728 additions and 214 deletions
2
.version
2
.version
|
|
@ -1 +1 @@
|
||||||
25.05
|
25.06
|
||||||
|
|
|
||||||
|
|
@ -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
14
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" => {
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(¶ms))?;
|
let optimized_fsrs = FSRS::new(Some(¶ms))?;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
85
rslib/src/storage/card/get_costs_for_retention.sql
Normal file
85
rslib/src/storage/card/get_costs_for_retention.sql
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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_ };
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'}"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
28
ts/routes/image-occlusion/tools/tool-fill.ts
Normal file
28
ts/routes/image-occlusion/tools/tool-fill.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue