From e2a4d6041c1848c8e4172f7a579388a46893d607 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 24 Apr 2021 10:14:54 +1000 Subject: [PATCH] basic support for add-ons in new deck config screen - expose the data as a writable store - currently only supports raw HTML; example to come - fix changes not marking a deck config as modified - the data is currently packed into the deckconfig object, but we may move these to a separate store in the collection config in the future, like is done with decks/notetypes --- qt/aqt/deckoptions.py | 7 +++- qt/tools/genhooks_gui.py | 13 ++++++- ts/deckconfig/Addons.svelte | 21 +++++++++++ ts/deckconfig/BUILD.bazel | 1 + ts/deckconfig/ConfigEditor.svelte | 2 + ts/deckconfig/DeckConfigPage.svelte | 18 +++++++++ ts/deckconfig/HtmlAddon.svelte | 14 +++++++ ts/deckconfig/index.ts | 4 +- ts/deckconfig/lib.test.ts | 53 +++++++++++++++++++++++++++ ts/deckconfig/lib.ts | 57 +++++++++++++++++++++++++++-- 10 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 ts/deckconfig/Addons.svelte create mode 100644 ts/deckconfig/HtmlAddon.svelte diff --git a/qt/aqt/deckoptions.py b/qt/aqt/deckoptions.py index 2499bdcc8..276fe067a 100644 --- a/qt/aqt/deckoptions.py +++ b/qt/aqt/deckoptions.py @@ -4,6 +4,7 @@ from __future__ import annotations import aqt +from aqt import gui_hooks from aqt.qt import * from aqt.utils import addCloseShortcut, disable_help_button, restoreGeom, saveGeom from aqt.webview import AnkiWebView @@ -38,7 +39,11 @@ class DeckOptionsDialog(QDialog): self.setLayout(layout) deck_id = self.mw.col.decks.get_current_id() - self.web.eval(f"anki.deckConfig(document.getElementById('main'), {deck_id});") + self.web.eval( + f"""const $deckOptions = anki.deckConfig( + document.getElementById('main'), {deck_id});""" + ) + gui_hooks.deck_options_did_load(self) def reject(self) -> None: self.web = None diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 3d611a988..346ba9c25 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -218,8 +218,8 @@ hooks = [ content.stats += "\n
my html
" """, ), - # Deck options - ################### + # Deck options (legacy screen) + ############################### Hook( name="deck_conf_did_setup_ui_form", args=["deck_conf: aqt.deckconf.DeckConf"], @@ -287,6 +287,15 @@ hooks = [ ], doc="Called before config group is renamed", ), + # Deck options (new screen) + ############################ + Hook( + name="deck_options_did_load", + args=[ + "deck_options: aqt.deckoptions.DeckOptionsDialog", + ], + doc="Can be used to inject extra options into the config screen", + ), # Filtered deck options ################### Hook( diff --git a/ts/deckconfig/Addons.svelte b/ts/deckconfig/Addons.svelte new file mode 100644 index 000000000..0cb69b9af --- /dev/null +++ b/ts/deckconfig/Addons.svelte @@ -0,0 +1,21 @@ + + + +{#if $components.length} +
+

Add-ons

+ + {#each $components as addon} + + {/each} +
+{/if} diff --git a/ts/deckconfig/BUILD.bazel b/ts/deckconfig/BUILD.bazel index 4c464a618..1f3c69437 100644 --- a/ts/deckconfig/BUILD.bazel +++ b/ts/deckconfig/BUILD.bazel @@ -65,6 +65,7 @@ ts_library( "//ts:image_module_support", "//ts/lib", "//ts/lib:backend_proto", + "//ts/sveltelib", "@npm//lodash-es", "@npm//svelte", ], diff --git a/ts/deckconfig/ConfigEditor.svelte b/ts/deckconfig/ConfigEditor.svelte index c01f1420b..c540d27d4 100644 --- a/ts/deckconfig/ConfigEditor.svelte +++ b/ts/deckconfig/ConfigEditor.svelte @@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ReviewOptions from "./ReviewOptions.svelte"; import LapseOptions from "./LapseOptions.svelte"; import GeneralOptions from "./GeneralOptions.svelte"; + import Addons from "./Addons.svelte"; import type { DeckConfigState } from "./lib"; export let state: DeckConfigState; @@ -29,4 +30,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + diff --git a/ts/deckconfig/DeckConfigPage.svelte b/ts/deckconfig/DeckConfigPage.svelte index 596ef2624..3762f0cf1 100644 --- a/ts/deckconfig/DeckConfigPage.svelte +++ b/ts/deckconfig/DeckConfigPage.svelte @@ -8,8 +8,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import type { DeckConfigState } from "./lib"; import { onMount, onDestroy } from "svelte"; import { registerShortcut } from "lib/shortcuts"; + import type { Writable } from "svelte/store"; + import HtmlAddon from "./HtmlAddon.svelte"; export let state: DeckConfigState; + let addons = state.addonComponents; + + export function auxData(): Writable> { + return state.currentAuxData; + } + + export function addHtmlAddon(html: string, mounted: () => void): void { + $addons = [ + ...$addons, + { + component: HtmlAddon, + html, + mounted, + }, + ]; + } let registerCleanup: () => void; onMount(() => { diff --git a/ts/deckconfig/HtmlAddon.svelte b/ts/deckconfig/HtmlAddon.svelte new file mode 100644 index 000000000..70a8578b7 --- /dev/null +++ b/ts/deckconfig/HtmlAddon.svelte @@ -0,0 +1,14 @@ + + + +{@html html} diff --git a/ts/deckconfig/index.ts b/ts/deckconfig/index.ts index db4de5fae..58695377c 100644 --- a/ts/deckconfig/index.ts +++ b/ts/deckconfig/index.ts @@ -9,14 +9,14 @@ import DeckConfigPage from "./DeckConfigPage.svelte"; export async function deckConfig( target: HTMLDivElement, deckId: number -): Promise { +): Promise { checkNightMode(); await setupI18n({ modules: [ModuleName.SCHEDULING, ModuleName.ACTIONS, ModuleName.DECK_CONFIG], }); const info = await getDeckConfigInfo(deckId); const state = new DeckConfigState(deckId, info); - new DeckConfigPage({ + return new DeckConfigPage({ target, props: { state }, }); diff --git a/ts/deckconfig/lib.test.ts b/ts/deckconfig/lib.test.ts index 39db17e22..be129a521 100644 --- a/ts/deckconfig/lib.test.ts +++ b/ts/deckconfig/lib.test.ts @@ -33,6 +33,8 @@ const exampleData = { leechAction: "LEECH_ACTION_TAG_ONLY", leechThreshold: 8, capAnswerTimeToSecs: 60, + other: + "eyJuZXciOnsic2VwYXJhdGUiOnRydWV9LCJyZXYiOnsiZnV6eiI6MC4wNSwibWluU3BhY2UiOjF9fQ==", }, }, useCount: 1, @@ -276,3 +278,54 @@ test("saving", () => { expect(out.removedConfigIds).toStrictEqual([1618570764780]); expect(out.configs.map((c) => c.name)).toStrictEqual(["Default"]); }); + +test("aux data", () => { + const state = startingState(); + expect(get(state.currentAuxData)).toStrictEqual({}); + state.currentAuxData.update((val) => { + return { ...val, hello: "world" }; + }); + + // check default + state.setCurrentIndex(0); + expect(get(state.currentAuxData)).toStrictEqual({ + new: { + separate: true, + }, + rev: { + fuzz: 0.05, + minSpace: 1, + }, + }); + state.currentAuxData.update((val) => { + return { ...val, defaultAddition: true }; + }); + + // ensure changes serialize + const out = state.dataForSaving(true); + expect(out.configs.length).toBe(2); + const json = out.configs.map( + (c) => + JSON.parse(new TextDecoder().decode((c.config as any).other)) as Record< + string, + unknown + > + ); + expect(json).toStrictEqual([ + // other deck comes first + { + hello: "world", + }, + // default is selected, so comes last + { + defaultAddition: true, + new: { + separate: true, + }, + rev: { + fuzz: 0.05, + minSpace: 1, + }, + }, + ]); +}); diff --git a/ts/deckconfig/lib.ts b/ts/deckconfig/lib.ts index 7e69eb1f0..0a639ef3b 100644 --- a/ts/deckconfig/lib.ts +++ b/ts/deckconfig/lib.ts @@ -10,6 +10,7 @@ import { postRequest } from "lib/postrequest"; import { Writable, writable, get, Readable, readable } from "svelte/store"; import { isEqual, cloneDeep } from "lodash-es"; import * as tr from "lib/i18n"; +import type { DynamicSvelteComponent } from "sveltelib/dynamicComponent"; export async function getDeckConfigInfo( deckId: number @@ -50,10 +51,12 @@ export interface ConfigListEntry { type ConfigInner = pb.BackendProto.DeckConfig.Config; export class DeckConfigState { readonly currentConfig: Writable; + readonly currentAuxData: Writable>; readonly configList: Readable; readonly parentLimits: Readable; readonly currentDeck: pb.BackendProto.DeckConfigsForUpdate.CurrentDeck; readonly defaults: ConfigInner; + readonly addonComponents: Writable; private targetDeckId: number; private configs: ConfigWithCount[]; @@ -69,8 +72,9 @@ export class DeckConfigState { this.currentDeck = data.currentDeck as pb.BackendProto.DeckConfigsForUpdate.CurrentDeck; this.defaults = data.defaults!.config! as ConfigInner; this.configs = data.allConfig.map((config) => { + const configInner = config.config as pb.BackendProto.DeckConfig; return { - config: config.config as pb.BackendProto.DeckConfig, + config: configInner, useCount: config.useCount!, }; }); @@ -83,6 +87,7 @@ export class DeckConfigState { // selected one at display time this.configs[this.selectedIdx].useCount -= 1; this.currentConfig = writable(this.getCurrentConfig()); + this.currentAuxData = writable(this.getCurrentAuxData()); this.configList = readable(this.getConfigList(), (set) => { this.configListSetter = set; return; @@ -92,6 +97,7 @@ export class DeckConfigState { return; }); this.schemaModified = data.schemaModified; + this.addonComponents = writable([]); // create a temporary subscription to force our setters to be set immediately, // so unit tests don't get stale results @@ -100,6 +106,7 @@ export class DeckConfigState { // update our state when the current config is changed this.currentConfig.subscribe((val) => this.onCurrentConfigChanged(val)); + this.currentAuxData.subscribe((val) => this.onCurrentAuxDataChanged(val)); } setCurrentIndex(index: number): void { @@ -192,13 +199,27 @@ export class DeckConfigState { } private onCurrentConfigChanged(config: ConfigInner): void { - if (!isEqual(config, this.configs[this.selectedIdx].config.config)) { - this.configs[this.selectedIdx].config.config = config; - this.configs[this.selectedIdx].config.mtimeSecs = 0; + const configOuter = this.configs[this.selectedIdx].config; + if (!isEqual(config, configOuter.config)) { + configOuter.config = config; + if (configOuter.id) { + this.modifiedConfigs.add(configOuter.id); + } } this.parentLimitsSetter?.(this.getParentLimits()); } + private onCurrentAuxDataChanged(data: Record): void { + const current = this.getCurrentAuxData(); + if (!isEqual(current, data)) { + this.currentConfig.update((config) => { + const asBytes = new TextEncoder().encode(JSON.stringify(data)); + config.other = asBytes; + return config; + }); + } + } + private ensureNewNameUnique(name: string): string { const idx = this.configs.findIndex((e) => e.config.name === name); if (idx !== -1) { @@ -210,6 +231,7 @@ export class DeckConfigState { private updateCurrentConfig(): void { this.currentConfig.set(this.getCurrentConfig()); + this.currentAuxData.set(this.getCurrentAuxData()); this.parentLimitsSetter?.(this.getParentLimits()); } @@ -222,6 +244,12 @@ export class DeckConfigState { return cloneDeep(this.configs[this.selectedIdx].config.config as ConfigInner); } + /// Extra data associated with current config (for add-ons) + private getCurrentAuxData(): Record { + const conf = this.configs[this.selectedIdx].config.config as ConfigInner; + return bytesToObject(conf.other); + } + private getConfigList(): ConfigListEntry[] { const list: ConfigListEntry[] = this.configs.map((c, idx) => { const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0); @@ -258,3 +286,24 @@ export class DeckConfigState { }; } } + +function bytesToObject(bytes: Uint8Array): Record { + if (!bytes.length) { + return {} as Record; + } + + let obj: Record; + try { + obj = JSON.parse(new TextDecoder().decode(bytes)) as Record; + } catch (err) { + console.log(`invalid json in deck config`); + return {} as Record; + } + + if (obj.constructor !== Object) { + console.log(`invalid object in deck config`); + return {} as Record; + } + + return obj; +}