From f29bcb743b78f81f45b912769b3618a266256634 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Tue, 27 May 2025 04:07:21 +0100 Subject: [PATCH 01/26] Feat/Desired retention warning improvements (#3995) * Feat/90% desired retention warning * Update ftl/core/deck-config.ftl * show on newly enabled * Show warning on focus * Never hide warning * Display relative change * Add: Separate warning for too long and short * Revert unchanged text changes * interval -> workload * Remove dead code * fsrs-rs/@L-M-Sherlock's workload calculation * Added: delay * CONSTANT_CASE * Fix: optimized state * Removed "Processing" * Remove dead code * 1 digit precision * bump fsrs-rs * typo * Apply suggestions from code review Co-authored-by: Damien Elmes * Improve rounding * improve comment * rounding <1% * decrease rounding precision * bump ts-fsrs * use actual cost values * ./check * typo * include relearning * change factor wording * simplify sql * ./check * Apply suggestions from code review Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * Fix: missing search_cids * @dae's style patch * Fix: Doesn't update on arrow keys change * force two lines * center two lines --------- Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> --- ftl/core/deck-config.ftl | 17 ++-- proto/anki/deck_config.proto | 13 +++ qt/aqt/mediasrv.py | 1 + rslib/src/deckconfig/service.rs | 34 +++++++ .../storage/card/get_costs_for_retention.sql | 49 ++++++++++ rslib/src/storage/card/mod.rs | 14 +++ ts/lib/components/SpinBox.svelte | 3 +- ts/routes/deck-options/FsrsOptions.svelte | 90 +++++++++++++++---- .../deck-options/FsrsOptionsOuter.svelte | 5 ++ ts/routes/deck-options/SpinBoxFloatRow.svelte | 3 +- 10 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 rslib/src/storage/card/get_costs_for_retention.sql diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 12512acb0..d16ce45bc 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -470,11 +470,12 @@ deck-config-compute-optimal-retention-tooltip4 = willing to invest more study time to achieve it. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. deck-config-please-save-your-changes-first = Please save your changes first. -deck-config-a-100-day-interval = - { $days -> - [one] A 100 day interval will become { $days } day. - *[other] A 100 day interval will become { $days } days. - } +deck-config-workload-factor-change = Approximate workload: {$factor}x + (compared to {$previousDR}% desired retention) +deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you. +deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals. +deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals. + deck-config-percent-of-reviews = { $reviews -> [one] { $pct }% of { $reviews } review @@ -512,6 +513,12 @@ deck-config-fsrs-simulator-radio-memorized = Memorized ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. +deck-config-a-100-day-interval = + { $days -> + [one] A 100 day interval will become { $days } day. + *[other] A 100 day interval will become { $days } days. + } + deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 48cb71479..bb9ead778 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -25,6 +25,8 @@ service DeckConfigService { returns (collection.OpChanges); rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) returns (GetIgnoredBeforeCountResponse); + rpc GetRetentionWorkload(GetRetentionWorkloadRequest) + returns (GetRetentionWorkloadResponse); } // Implicitly includes any of the above methods that are not listed in the @@ -35,6 +37,17 @@ message DeckConfigId { int64 dcid = 1; } +message GetRetentionWorkloadRequest { + repeated float w = 1; + string search = 2; + float before = 3; + float after = 4; +} + +message GetRetentionWorkloadResponse { + float factor = 1; +} + message GetIgnoredBeforeCountRequest { string ignore_revlogs_before_date = 1; string search = 2; diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 203f23ef9..cc7c4c2dd 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -659,6 +659,7 @@ exposed_backend_list = [ "simulate_fsrs_review", # DeckConfigService "get_ignored_before_count", + "get_retention_workload", ] diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index 7ac08902e..516132763 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -96,6 +96,40 @@ impl crate::services::DeckConfigService for Collection { total: guard.cards.try_into().unwrap_or(0), }) } + + fn get_retention_workload( + &mut self, + input: anki_proto::deck_config::GetRetentionWorkloadRequest, + ) -> Result { + const LEARN_SPAN: usize = 1000; + + let guard = + self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; + let (pass_cost, fail_cost, learn_cost) = guard.col.storage.get_costs_for_retention()?; + + let before = fsrs::expected_workload( + &input.w, + input.before, + LEARN_SPAN, + pass_cost, + fail_cost, + 0., + input.before, + )? + learn_cost; + let after = fsrs::expected_workload( + &input.w, + input.after, + LEARN_SPAN, + pass_cost, + fail_cost, + 0., + input.after, + )? + learn_cost; + + Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { + factor: after / before, + }) + } } impl From for anki_proto::deck_config::DeckConfig { diff --git a/rslib/src/storage/card/get_costs_for_retention.sql b/rslib/src/storage/card/get_costs_for_retention.sql new file mode 100644 index 000000000..811ca3050 --- /dev/null +++ b/rslib/src/storage/card/get_costs_for_retention.sql @@ -0,0 +1,49 @@ +WITH searched_revlogs AS ( + SELECT * + 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 +), +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 +) +SELECT * +FROM average_pass, + average_fail, + average_learn; \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 353537f90..bef3251de 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -747,6 +747,20 @@ impl super::SqliteStorage { .get(0)?) } + pub(crate) fn get_costs_for_retention(&self) -> Result<(f32, f32, f32)> { + 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(( + row.get(0).unwrap_or(7000.), + row.get(1).unwrap_or(23_000.), + row.get(2).unwrap_or(30_000.), + )) + } + #[cfg(test)] pub(crate) fn get_all_cards(&self) -> Vec { self.db diff --git a/ts/lib/components/SpinBox.svelte b/ts/lib/components/SpinBox.svelte index a6a1a0b20..f52845a4d 100644 --- a/ts/lib/components/SpinBox.svelte +++ b/ts/lib/components/SpinBox.svelte @@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let percentage = false; let input: HTMLInputElement; - let focused = false; + export let focused = false; let multiplier: number; $: multiplier = percentage ? 100 : 1; @@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html value={stringValue} bind:this={input} on:blur={update} + on:change={update} on:input={onInput} on:focusin={() => (focused = true)} on:focusout={() => (focused = false)} diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 356437cb9..7b2318e00 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { computeFsrsParams, evaluateParams, + getRetentionWorkload, setWantsAbort, } from "@generated/backend"; import * as tr from "@generated/ftl"; @@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte"; import SimulatorModal from "./SimulatorModal.svelte"; - import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb"; + import { + GetRetentionWorkloadRequest, + UpdateDeckConfigsMode, + } from "@generated/anki/deck_config_pb"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; export let onPresetChange: () => void; + export let newlyEnabled = false; const config = state.currentConfig; const defaults = state.defaults; @@ -39,6 +44,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; + let desiredRetentionFocused = false; + let desiredRetentionEverFocused = false; + let optimized = false; + const startingDesiredRetention = $config.desiredRetention.toFixed(2); + $: if (desiredRetentionFocused) { + desiredRetentionEverFocused = true; + } + $: showDesiredRetentionTooltip = + newlyEnabled || desiredRetentionEverFocused || optimized; let computeParamsProgress: ComputeParamsProgress | undefined; let computingParams = false; @@ -47,10 +61,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: roundedRetention = Number($config.desiredRetention.toFixed(2)); - $: desiredRetentionWarning = getRetentionWarning( - roundedRetention, - fsrsParams($config), - ); + $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); + + let timeoutId: ReturnType | undefined = undefined; + const WORKLOAD_UPDATE_DELAY_MS = 100; + + let desiredRetentionChangeInfo = ""; + $: { + clearTimeout(timeoutId); + if (showDesiredRetentionTooltip) { + timeoutId = setTimeout(() => { + getRetentionChangeInfo(roundedRetention, fsrsParams($config)); + }, WORKLOAD_UPDATE_DELAY_MS); + } else { + desiredRetentionChangeInfo = ""; + } + } + $: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; @@ -67,23 +94,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html reviewOrder: $config.reviewOrder, }); - function getRetentionWarning(retention: number, params: number[]): string { - const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5 - const factor = 0.9 ** (1 / decay) - 1; - const stability = 100; - const days = Math.round( - (stability / factor) * (Math.pow(retention, 1 / decay) - 1), - ); - if (days === 100) { + const DESIRED_RETENTION_LOW_THRESHOLD = 0.8; + const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95; + + function getRetentionLongShortWarning(retention: number) { + if (retention < DESIRED_RETENTION_LOW_THRESHOLD) { + return tr.deckConfigDesiredRetentionTooLow(); + } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) { + return tr.deckConfigDesiredRetentionTooHigh(); + } else { return ""; } - return tr.deckConfigA100DayInterval({ days }); + } + + async function getRetentionChangeInfo(retention: number, params: number[]) { + if (+startingDesiredRetention == roundedRetention) { + desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged(); + return; + } + const request = new GetRetentionWorkloadRequest({ + w: params, + search: defaultparamSearch, + before: +startingDesiredRetention, + after: retention, + }); + const resp = await getRetentionWorkload(request); + desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({ + factor: resp.factor.toFixed(2), + previousDr: (+startingDesiredRetention * 100).toString(), + }); } function getRetentionWarningClass(retention: number): string { if (retention < 0.7 || retention > 0.97) { return "alert-danger"; - } else if (retention < 0.8 || retention > 0.95) { + } else if ( + retention < DESIRED_RETENTION_LOW_THRESHOLD || + retention > DESIRED_RETENTION_HIGH_THRESHOLD + ) { return "alert-warning"; } else { return "alert-info"; @@ -146,6 +194,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html setTimeout(() => alert(msg), 200); } else { $config.fsrsParams6 = resp.params; + optimized = true; } if (computeParamsProgress) { computeParamsProgress.current = computeParamsProgress.total; @@ -237,12 +286,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html min={0.7} max={0.99} percentage={true} + bind:focused={desiredRetentionFocused} > openHelpModal("desiredRetention")}> {tr.deckConfigDesiredRetention()} +
@@ -331,4 +382,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html .btn { margin-bottom: 0.375rem; } + + :global(.two-line) { + white-space: pre-wrap; + min-height: calc(2ch + 30px); + box-sizing: content-box; + display: flex; + align-content: center; + flex-wrap: wrap; + } diff --git a/ts/routes/deck-options/FsrsOptionsOuter.svelte b/ts/routes/deck-options/FsrsOptionsOuter.svelte index d347cca50..ca22ce5af 100644 --- a/ts/routes/deck-options/FsrsOptionsOuter.svelte +++ b/ts/routes/deck-options/FsrsOptionsOuter.svelte @@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let onPresetChange: () => void; const fsrs = state.fsrs; + let newlyEnabled = false; + $: if (!$fsrs) { + newlyEnabled = true; + } const settings = { fsrs: { @@ -94,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {#if $fsrs} openHelpModal(Object.keys(settings).indexOf(key))} {onPresetChange} diff --git a/ts/routes/deck-options/SpinBoxFloatRow.svelte b/ts/routes/deck-options/SpinBoxFloatRow.svelte index f431c0df1..5aa93bd30 100644 --- a/ts/routes/deck-options/SpinBoxFloatRow.svelte +++ b/ts/routes/deck-options/SpinBoxFloatRow.svelte @@ -15,6 +15,7 @@ export let max = 9999; export let step = 0.01; export let percentage = false; + export let focused = false; @@ -23,7 +24,7 @@ - + From 7a8b4a193f9dac647208eddc3808fc1985c2a6fc Mon Sep 17 00:00:00 2001 From: Marvin Kopf Date: Fri, 30 May 2025 08:05:06 +0200 Subject: [PATCH 02/26] offload mpv callback registration to background thread to avoid UI blocking (#4038) Instantiating `MPV(MPVBase)` triggers multiple synchronous `command()` calls to the mpv process during callback registration. These calls block the main thread and degrade startup performance. This change defers registration via `taskman.run_in_background`. --- CONTRIBUTORS | 2 +- qt/aqt/mpv.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e8814bf93..068760e6b 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -230,7 +230,7 @@ KolbyML Adnane Taghi Spiritual Father Emmanuel Ferdman - +Marvin Kopf ******************** The text of the 3 clause BSD license follows: diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index 60ea21290..329a95538 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -41,6 +41,7 @@ import time from queue import Empty, Full, Queue from shutil import which +import aqt from anki.utils import is_mac, is_win @@ -444,7 +445,7 @@ class MPV(MPVBase): super().__init__(*args, **kwargs) - self._register_callbacks() + aqt.mw.taskman.run_in_background(self._register_callbacks, None) def _register_callbacks(self): self._callbacks = {} From 14b8a8ad0de381a86e44b409d334bedb90a4488c Mon Sep 17 00:00:00 2001 From: Abdo Date: Fri, 30 May 2025 09:05:36 +0300 Subject: [PATCH 03/26] Fix new card sort order not reacting to changes in gather order (#4039) --- ts/lib/components/EnumSelector.svelte | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ts/lib/components/EnumSelector.svelte b/ts/lib/components/EnumSelector.svelte index 328946bd2..8a711e9c0 100644 --- a/ts/lib/components/EnumSelector.svelte +++ b/ts/lib/components/EnumSelector.svelte @@ -20,16 +20,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let disabledChoices: T[] = []; $: label = choices.find((c) => c.value === value)?.label; - - - From f9f089416225d1af2a10378552461505b0188058 Mon Sep 17 00:00:00 2001 From: llama Date: Fri, 30 May 2025 14:35:06 +0800 Subject: [PATCH 04/26] Add left margin to browser when sidebar is closed (#4040) * add left margin to browser when sidebar is closed * listen for event instead of explicit user action * refresh sidebar on visibility change * Add a margin on macOS even when not collapsed --------- Co-authored-by: Damien Elmes --- qt/aqt/browser/browser.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 260746514..49f289f93 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -53,6 +53,7 @@ from aqt.operations.tag import ( from aqt.qt import * from aqt.sound import av_player from aqt.switch import Switch +from aqt.theme import WidgetStyle from aqt.undo import UndoActionsInfo from aqt.utils import ( HelpPage, @@ -170,6 +171,7 @@ class Browser(QMainWindow): if self.height() != 0: self.aspect_ratio = self.width() / self.height() self.set_layout(self.mw.pm.browser_layout(), True) + self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden()) # disable undo/redo self.on_undo_state_change(mw.undo_actions_info()) # legacy alias @@ -726,6 +728,7 @@ class Browser(QMainWindow): self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) + qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange) grid = QGridLayout() grid.addWidget(self.sidebar.searchBar, 0, 0) grid.addWidget(self.sidebar.toolbar, 0, 1) @@ -745,9 +748,17 @@ class Browser(QMainWindow): self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) def showSidebar(self, show: bool = True) -> None: - want_visible = not self.sidebarDockWidget.isVisible() self.sidebarDockWidget.setVisible(show) - if want_visible and show: + + def onSidebarVisibilityChange(self, visible): + margins = self.form.verticalLayout_3.contentsMargins() + skip_left_margin = not visible and not ( + is_mac and aqt.mw.pm.get_widget_style() == WidgetStyle.NATIVE + ) + margins.setLeft(0 if skip_left_margin else margins.right()) + self.form.verticalLayout_3.setContentsMargins(margins) + + if visible: self.sidebar.refresh() def focusSidebar(self) -> None: From 6cdebd763815766dd8023664324a64f2229bd223 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 30 May 2025 22:48:31 +0700 Subject: [PATCH 05/26] Fix inverted margin logic https://github.com/ankitects/anki/pull/4040#issuecomment-2921626962 --- qt/aqt/browser/browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 49f289f93..fec163a1f 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -752,7 +752,7 @@ class Browser(QMainWindow): def onSidebarVisibilityChange(self, visible): margins = self.form.verticalLayout_3.contentsMargins() - skip_left_margin = not visible and not ( + 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()) From 757247d424ed9b24a0fdd49f959fddbf5d35dde6 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 31 May 2025 16:00:31 +0700 Subject: [PATCH 06/26] Use more secure API key https://github.com/ankitects/anki/pull/3925#discussion_r2051494659 --- qt/aqt/mediasrv.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index cc7c4c2dd..a38790728 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -7,9 +7,8 @@ import enum import logging import mimetypes import os -import random import re -import string +import secrets import sys import threading import traceback @@ -765,7 +764,7 @@ def legacy_page_data() -> Response: return _text_response(HTTPStatus.NOT_FOUND, "page not found") -_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32)) +_APIKEY = secrets.token_urlsafe(32) def _have_api_access() -> bool: From 96ff27d1fb7a11a5bbf2eb2c846c8f0cc74705ef Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 1 Jun 2025 13:16:04 +0700 Subject: [PATCH 07/26] Ensure media files are passed relative to the media folder (#4041) We were (partially) doing this for MpvManager, but not for Windows' SimpleMpvPlayer. By passing a media file starting with a special scheme, a malicious actor could have caused a file to be written to the filesystem on Windows. Thanks once again to Michael Lappas for the report. --- pylib/anki/media.py | 2 +- pylib/anki/sound.py | 10 ++++++++++ qt/aqt/sound.py | 10 +++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pylib/anki/media.py b/pylib/anki/media.py index 5d8653a97..8ba5d432c 100644 --- a/pylib/anki/media.py +++ b/pylib/anki/media.py @@ -76,7 +76,7 @@ class MediaManager(DeprecatedNamesMixin): return self.col._backend.strip_av_tags(text) def _extract_filenames(self, text: str) -> list[str]: - "This only exists do support a legacy function; do not use." + "This only exists to support a legacy function; do not use." out = self.col._backend.extract_av_tags(text=text, question_side=True) return [ x.filename diff --git a/pylib/anki/sound.py b/pylib/anki/sound.py index 3d375f716..99a8906f3 100644 --- a/pylib/anki/sound.py +++ b/pylib/anki/sound.py @@ -9,10 +9,13 @@ These can be accessed via eg card.question_av_tags() from __future__ import annotations +import os import re from dataclasses import dataclass from typing import Union +from anki import hooks + @dataclass class TTSTag: @@ -38,6 +41,13 @@ class SoundOrVideoTag: filename: str + def path(self, media_folder: str) -> str: + "Prepend the media folder to the filename." + # Ensure filename doesn't reference parent folder + filename = os.path.basename(self.filename) + filename = hooks.media_file_filter(filename) + return os.path.join(media_folder, filename) + # note this does not include image tags, which are handled with HTML. AVTag = Union[SoundOrVideoTag, TTSTag] diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 11f957a84..5ee281e56 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -23,7 +23,6 @@ from markdown import markdown import aqt import aqt.mpv import aqt.qt -from anki import hooks from anki.cards import Card from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.utils import is_lin, is_mac, is_win, namedtmp @@ -327,7 +326,7 @@ class SimpleProcessPlayer(Player): # pylint: disable=abstract-method def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) self._process = subprocess.Popen( - self.args + ["--", tag.filename], + self.args + ["--", tag.path(self._media_folder)], env=self.env, cwd=self._media_folder, stdout=subprocess.DEVNULL, @@ -453,8 +452,7 @@ class MpvManager(MPV, SoundOrVideoPlayer): def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: assert isinstance(tag, SoundOrVideoTag) self._on_done = on_done - filename = hooks.media_file_filter(tag.filename) - path = os.path.join(self.media_folder, filename) + path = tag.path(self.media_folder) if self.mpv_version is None or self.mpv_version >= (0, 38, 0): self.command("loadfile", path, "replace", -1, "pause=no") @@ -506,10 +504,8 @@ class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer): def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) - filename = hooks.media_file_filter(tag.filename) - self._process = subprocess.Popen( - self.args + ["--", filename], + self.args + ["--", tag.path(self.media_folder)], env=self.env, cwd=self.media_folder, stdin=subprocess.PIPE, From f81a9bfdfb66fad607e32ed29cfb19b3015e716c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 1 Jun 2025 13:16:28 +0700 Subject: [PATCH 08/26] Fix mpv being left around on abrupt termination (#4042) Closes #4015 --- qt/aqt/mpv.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index 329a95538..74155814c 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -69,6 +69,7 @@ if is_win: # pylint: disable=import-error import pywintypes import win32file # pytype: disable=import-error + import win32job import win32pipe import winerror @@ -131,6 +132,22 @@ class MPVBase: def _start_process(self): """Start the mpv process.""" self._proc = subprocess.Popen(self.argv, env=self.popenEnv) + if is_win: + # Ensure mpv gets terminated if Anki closes abruptly. + self._job = win32job.CreateJobObject(None, "") + extended_info = win32job.QueryInformationJobObject( + self._job, win32job.JobObjectExtendedLimitInformation + ) + extended_info["BasicLimitInformation"][ + "LimitFlags" + ] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + win32job.SetInformationJobObject( + self._job, + win32job.JobObjectExtendedLimitInformation, + extended_info, + ) + handle = self._proc._handle # pylint: disable=no-member + win32job.AssignProcessToJobObject(self._job, handle) def _stop_process(self): """Stop the mpv process.""" From 06c0e4c14a5c2d05a4b38b51a6ee956e2583baae Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Sun, 1 Jun 2025 07:18:35 +0100 Subject: [PATCH 09/26] Fix/CMRR style (#4043) --- ts/routes/deck-options/SimulatorModal.svelte | 56 +++++++++----------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/ts/routes/deck-options/SimulatorModal.svelte b/ts/routes/deck-options/SimulatorModal.svelte index 070bed4fa..64b712560 100644 --- a/ts/routes/deck-options/SimulatorModal.svelte +++ b/ts/routes/deck-options/SimulatorModal.svelte @@ -383,38 +383,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} -
-
- {tr.deckConfigComputeOptimalRetention()} - - - {#if optimalRetention} - {estimatedRetention(optimalRetention)} - {#if optimalRetention - $config.desiredRetention >= 0.01} - - {/if} - {/if} - +
+ {tr.deckConfigComputeOptimalRetention()} +
-
+ + + {#if optimalRetention} + {estimatedRetention(optimalRetention)} + {#if optimalRetention - $config.desiredRetention >= 0.01} + + {/if} + {/if} + + {#if computingRetention} +
{computeRetentionProgressString}
+ {/if} + + {#if false} + + + {/if}
{#if computingParams || checkingParams} {computeParamsProgressString} @@ -351,18 +383,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
- - openHelpModal("rescheduleCardsOnChange")}> - - - - - {#if $fsrsReschedule} - - {/if} -
-