Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ftl/core/preferences.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions proto/anki/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ message ConfigKey {
}
}

enum ExperimentalFeatureFlag {
TEST_FLAG = 0;
SVELTE_EDITOR = 1;
SVELTE_REVIEWER = 2;
}

message GetConfigBoolRequest {
ConfigKey.Bool key = 1;
}
Expand Down
13 changes: 13 additions & 0 deletions pylib/anki/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,31 @@
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:
return from_json_bytes(self.col._backend.get_config_json(key))
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,
Expand Down
24 changes: 21 additions & 3 deletions qt/aqt/forms/preferences.ui
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>636</width>
<height>638</height>
<width>750</width>
<height>710</height>
</rect>
</property>
<property name="windowTitle">
Expand Down Expand Up @@ -1236,6 +1236,25 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="labsTab">
<attribute name="title">
<string>preferences_labs</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_7">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
</layout>
</widget>
</widget>
</item>
<item>
Expand Down Expand Up @@ -1306,7 +1325,6 @@
<tabstop>syncAnkiHubLogout</tabstop>
<tabstop>syncAnkiHubLogin</tabstop>
<tabstop>buttonBox</tabstop>
<tabstop>tabWidget</tabstop>
</tabstops>
<resources/>
<connections>
Expand Down
3 changes: 3 additions & 0 deletions qt/aqt/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions qt/aqt/mediasrv.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def is_sveltekit_page(path: str) -> bool:
"import-csv",
"import-page",
"image-occlusion",
"preferences",
]


Expand Down Expand Up @@ -765,6 +766,9 @@ def save_custom_colours() -> bytes:
# DeckConfigService
"get_ignored_before_count",
"get_retention_workload",
# ConfigService
"set_config_json",
"get_config_json",
]


Expand Down
14 changes: 14 additions & 0 deletions qt/aqt/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
showWarning,
tr,
)
from aqt.webview import AnkiWebView, AnkiWebViewKind


class Preferences(QDialog):
Expand Down Expand Up @@ -61,13 +62,22 @@ 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()
self.setup_global()
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.
Expand Down Expand Up @@ -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())

Expand Down
2 changes: 2 additions & 0 deletions qt/aqt/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class AnkiWebViewKind(Enum):
FIELDS = "fields"
IMPORT_LOG = "import log"
IMPORT_ANKI_PACKAGE = "anki package import"
PREFERENCES = "preferences"


class AuthInterceptor(QWebEngineUrlRequestInterceptor):
Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions rslib/src/backend/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,14 @@ impl From<StringKeyProto> for StringKey {

impl crate::services::ConfigService for Collection {
fn get_config_json(&mut self, input: generic::String) -> Result<generic::Json> {
let val: Option<Value> = self.get_config_optional(input.val.as_str());
val.or_not_found(input.val)
let key = input.val.as_str();
let val: Option<Value> = 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)
}
Expand Down
1 change: 1 addition & 0 deletions ts/lib/components/Switch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
</div>

Expand Down
61 changes: 61 additions & 0 deletions ts/routes/preferences/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Row from "$lib/components/Row.svelte";
import "../deck-options/deck-options-base.scss";
import LabItem from "./LabItem.svelte";
import type { PreferenceStore } from "$lib/sveltelib/preferences";
import { ExperimentalFeatureFlag } from "@generated/anki/config_pb";

export let data: { labPerfs: PreferenceStore<any> };
const labPerfs = data.labPerfs;
</script>

<div class="container">
<Row>
<div class="col-12 alert alert-warning mb-0">
<b>⚠️ Experimental Features</b>
<br />
These features may change, break, or be removed without notice, use at your own
risk.
</div>
</Row>

<div class="lab-grid">
<LabItem
title="Svelte note editor"
description="Replaces the legacy editor with a new Svelte-based implementation. May affect addon compatibility."
key={ExperimentalFeatureFlag.SVELTE_EDITOR}
{labPerfs}
></LabItem>
<LabItem
title="Ping"
description="Enable this experiment and see an alert every time you load this profile. Used for testing the experiment interface."
key={ExperimentalFeatureFlag.TEST_FLAG}
{labPerfs}
></LabItem>
</div>
</div>

<style>
.lab-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1em;
}

.container {
display: flex;
flex-direction: column;
gap: 1em;
margin: 1em;
align-items: center;
}

:global(body) {
background-color: var(--canvas-elevated) !important;
font-size: 13px;
}
</style>
16 changes: 16 additions & 0 deletions ts/routes/preferences/+page.ts
Original file line number Diff line number Diff line change
@@ -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;
47 changes: 47 additions & 0 deletions ts/routes/preferences/LabItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Switch from "$lib/components/Switch.svelte";
import type { PreferenceStore } from "$lib/sveltelib/preferences";
import type { ExperimentalFeatureFlag } from "@generated/anki/config_pb";

export let title: string;
export let description = "";
export let key: ExperimentalFeatureFlag;
export let labPerfs: PreferenceStore<any>;
let value = $labPerfs[key];

function onInput(e: Event) {
const target = e.target as HTMLInputElement;
$labPerfs = { ...$labPerfs, [key]: target.checked };
}
</script>

<label>
<div class="header">
<b>{title}</b>
<div class="switch">
<Switch id={title} bind:value on:input={onInput}></Switch>
</div>
</div>
{description}
</label>

<style>
label {
padding: 1em;
background-color: var(--canvas);
border-radius: 1em;
}
div {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.25em;
}
.switch {
font-size: 1.25rem;
}
</style>
26 changes: 26 additions & 0 deletions ts/routes/preferences/json.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading