diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl index e7c23784997..d71bbb81712 100644 --- a/ftl/core/preferences.ftl +++ b/ftl/core/preferences.ftl @@ -84,6 +84,7 @@ preferences-ankihub-not-logged-in = Not currently logged in to AnkiHub. preferences-ankiweb-intro = AnkiWeb is a free service that lets you keep your flashcard data in sync across your devices, and provides a way to recover the data if your device breaks or is lost. preferences-ankihub-intro = AnkiHub provides collaborative deck editing and additional study tools. A paid subscription is required to access certain features. preferences-third-party-description = Third-party services are unaffiliated with and not endorsed by Anki. Use of these services may require payment. +preferences-labs = Labs ## URL scheme related preferences-url-schemes = URL Schemes diff --git a/proto/anki/config.proto b/proto/anki/config.proto index ea115f0fc81..354229789ee 100644 --- a/proto/anki/config.proto +++ b/proto/anki/config.proto @@ -66,6 +66,12 @@ message ConfigKey { } } +enum ExperimentalFeatureFlag { + TEST_FLAG = 0; + SVELTE_EDITOR = 1; + SVELTE_REVIEWER = 2; +} + message GetConfigBoolRequest { ConfigKey.Bool key = 1; } diff --git a/pylib/anki/config.py b/pylib/anki/config.py index 775557c848f..c72df83e79d 100644 --- a/pylib/anki/config.py +++ b/pylib/anki/config.py @@ -32,11 +32,14 @@ from anki.utils import from_json_bytes, to_json_bytes Config = config_pb2.ConfigKey +ExperimentFlag = config_pb2.ExperimentalFeatureFlag class ConfigManager: def __init__(self, col: anki.collection.Collection): self.col = col.weakref() + # Saved when the collection is loaded to prevent changes before restart. + self._experiments = self._get_experiments_dirty() def get_immutable(self, key: str) -> Any: try: @@ -44,6 +47,16 @@ def get_immutable(self, key: str) -> Any: except NotFoundError as exc: raise KeyError from exc + def experiment_enabled(self, key: ExperimentFlag.ValueType) -> bool: + return self._experiments.get(str(key), False) + + def _get_experiments_dirty(self) -> dict[str, bool]: + """This fetches the experiments in the state that they are saved in the database. + This should not be used to check if an experiment is enabled because this will update immediately without a restart. + + Use "experiment_enabled" to fetch an active experiment instead.""" + return self.get_immutable("experimentalFeatures") + def set(self, key: str, val: Any) -> None: self.col._backend.set_config_json_no_undo( key=key, diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui index 67cd67a5feb..c97c3cadc84 100644 --- a/qt/aqt/forms/preferences.ui +++ b/qt/aqt/forms/preferences.ui @@ -6,8 +6,8 @@ 0 0 - 636 - 638 + 750 + 710 @@ -1236,6 +1236,25 @@ + + + preferences_labs + + + + 0 + + + 0 + + + 0 + + + 0 + + + @@ -1306,7 +1325,6 @@ syncAnkiHubLogout syncAnkiHubLogin buttonBox - tabWidget diff --git a/qt/aqt/main.py b/qt/aqt/main.py index d25d5a79cb7..eecda929978 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -27,6 +27,7 @@ from anki._legacy import deprecated from anki.buildinfo import version as version_str from anki.collection import Collection, Config, GithubRelease, OpChanges, UndoStatus +from anki.config import ExperimentFlag from anki.decks import DeckDict, DeckId from anki.hooks import runHook from anki.notes import NoteId @@ -527,6 +528,8 @@ def _onsuccess(synced: bool) -> None: onsuccess() if not self.safeMode: self.maybe_check_for_addon_updates(self.setup_auto_update) + if self.col.conf.experiment_enabled(ExperimentFlag.TEST_FLAG): + showInfo('You have the "ping" experiment enabled') last_day_cutoff = self.col.sched.day_cutoff diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 91c2a751ed3..5791aef27c0 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -422,6 +422,7 @@ def is_sveltekit_page(path: str) -> bool: "import-csv", "import-page", "image-occlusion", + "preferences", ] @@ -765,6 +766,9 @@ def save_custom_colours() -> bytes: # DeckConfigService "get_ignored_before_count", "get_retention_workload", + # ConfigService + "set_config_json", + "get_config_json", ] diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py index f7f616cfa29..3ebc2c0bd71 100644 --- a/qt/aqt/preferences.py +++ b/qt/aqt/preferences.py @@ -32,6 +32,7 @@ showWarning, tr, ) +from aqt.webview import AnkiWebView, AnkiWebViewKind class Preferences(QDialog): @@ -61,6 +62,8 @@ def __init__(self, mw: AnkiQt) -> None: qconnect( self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES) ) + + self._setup_webview() self.silentlyClose = True self.setup_collection() self.setup_profile() @@ -68,6 +71,13 @@ def __init__(self, mw: AnkiQt) -> None: self.setup_configurable_answer_keys() self.show() + def _setup_webview(self) -> None: + self.web = AnkiWebView(kind=AnkiWebViewKind.PREFERENCES) + layout = self.form.labsTab.layout() + assert layout is not None + layout.addWidget(self.web) + self.web.load_sveltekit_page("preferences") + def setup_configurable_answer_keys(self): """ Create a group box in Preferences with widgets that let the user edit answer keys. @@ -393,6 +403,10 @@ def update_global(self) -> None: self.mw.pm.setUiScale(newScale) restart_required = True + conf = self.mw.col.conf + if conf._get_experiments_dirty() != conf._experiments: + restart_required = True + if restart_required: showInfo(tr.preferences_changes_will_take_effect_when_you()) diff --git a/qt/aqt/webview.py b/qt/aqt/webview.py index e5b346b4655..c7fd8165796 100644 --- a/qt/aqt/webview.py +++ b/qt/aqt/webview.py @@ -59,6 +59,7 @@ class AnkiWebViewKind(Enum): FIELDS = "fields" IMPORT_LOG = "import log" IMPORT_ANKI_PACKAGE = "anki package import" + PREFERENCES = "preferences" class AuthInterceptor(QWebEngineUrlRequestInterceptor): @@ -142,6 +143,7 @@ def _profileForPage(self, kind: AnkiWebViewKind) -> QWebEngineProfile: AnkiWebViewKind.IMPORT_ANKI_PACKAGE, AnkiWebViewKind.IMPORT_CSV, AnkiWebViewKind.IMPORT_LOG, + AnkiWebViewKind.PREFERENCES, ) global _profile_with_api_access, _profile_without_api_access diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index b6e81ce2ad7..3b731067472 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -57,8 +57,14 @@ impl From for StringKey { impl crate::services::ConfigService for Collection { fn get_config_json(&mut self, input: generic::String) -> Result { - let val: Option = self.get_config_optional(input.val.as_str()); - val.or_not_found(input.val) + let key = input.val.as_str(); + let val: Option = self.get_config_optional(key); + let default = match key { + "experimentalFeatures" => Some(serde_json::from_str("{}").unwrap()), + _ => None, + }; + val.or(default) + .or_not_found(key) .and_then(|v| serde_json::to_vec(&v).map_err(Into::into)) .map(Into::into) } diff --git a/ts/lib/components/Switch.svelte b/ts/lib/components/Switch.svelte index d48f46fb338..343cddf3baa 100644 --- a/ts/lib/components/Switch.svelte +++ b/ts/lib/components/Switch.svelte @@ -20,6 +20,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html class:nightMode={$pageTheme.isDark} bind:checked={value} {disabled} + on:input /> diff --git a/ts/routes/preferences/+page.svelte b/ts/routes/preferences/+page.svelte new file mode 100644 index 00000000000..a5e1200a16b --- /dev/null +++ b/ts/routes/preferences/+page.svelte @@ -0,0 +1,61 @@ + + + +
+ +
+ ⚠️ Experimental Features +
+ These features may change, break, or be removed without notice, use at your own + risk. +
+
+ +
+ + +
+
+ + diff --git a/ts/routes/preferences/+page.ts b/ts/routes/preferences/+page.ts new file mode 100644 index 00000000000..03d19aa0ee8 --- /dev/null +++ b/ts/routes/preferences/+page.ts @@ -0,0 +1,16 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { autoSavingPrefs } from "$lib/sveltelib/preferences"; +import type { PageLoad } from "./$types"; +import { getConfigJsonObject, setConfigJsonObject } from "./json"; + +const CONFIG_KEY = "experimentalFeatures"; + +export const load = (async () => { + const labPerfs = await autoSavingPrefs( + () => getConfigJsonObject(CONFIG_KEY), + ($config) => setConfigJsonObject(CONFIG_KEY, $config), + ); + + return { labPerfs }; +}) satisfies PageLoad; diff --git a/ts/routes/preferences/LabItem.svelte b/ts/routes/preferences/LabItem.svelte new file mode 100644 index 00000000000..ca285b977e9 --- /dev/null +++ b/ts/routes/preferences/LabItem.svelte @@ -0,0 +1,47 @@ + + + + + + diff --git a/ts/routes/preferences/json.ts b/ts/routes/preferences/json.ts new file mode 100644 index 00000000000..7444bbcdcbb --- /dev/null +++ b/ts/routes/preferences/json.ts @@ -0,0 +1,26 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import { getConfigJson, setConfigJson } from "@generated/backend"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export async function getConfigJsonObject(key: string) { + const resp = await getConfigJson({ val: key }); + try { + const json_string = decoder.decode(resp.json); + if (json_string.length > 0) { + return JSON.parse(json_string); + } else { + return {}; + } + } catch (e) { + console.error("Resetting experiment config due to error: ", e); + return {}; + } +} + +export async function setConfigJsonObject(key: string, value: any, undoable = false) { + value = JSON.stringify(value); + return await setConfigJson({ key, valueJson: encoder.encode(value), undoable }); +}