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
This commit is contained in:
Damien Elmes 2021-04-24 10:14:54 +10:00
parent c71b684a94
commit e2a4d6041c
10 changed files with 181 additions and 9 deletions

View File

@ -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

View File

@ -218,8 +218,8 @@ hooks = [
content.stats += "\n<div>my html</div>"
""",
),
# 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(

View File

@ -0,0 +1,21 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
// import * as tr from "lib/i18n";
import type { DeckConfigState } from "./lib";
export let state: DeckConfigState;
let components = state.addonComponents;
</script>
{#if $components.length}
<div>
<h2>Add-ons</h2>
{#each $components as addon}
<svelte:component this={addon.component} {state} {...addon} />
{/each}
</div>
{/if}

View File

@ -65,6 +65,7 @@ ts_library(
"//ts:image_module_support",
"//ts/lib",
"//ts/lib:backend_proto",
"//ts/sveltelib",
"@npm//lodash-es",
"@npm//svelte",
],

View File

@ -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
<ReviewOptions {state} />
<LapseOptions {state} />
<GeneralOptions {state} />
<Addons {state} />
</div>

View File

@ -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<Record<string, unknown>> {
return state.currentAuxData;
}
export function addHtmlAddon(html: string, mounted: () => void): void {
$addons = [
...$addons,
{
component: HtmlAddon,
html,
mounted,
},
];
}
let registerCleanup: () => void;
onMount(() => {

View File

@ -0,0 +1,14 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { onMount } from "svelte";
export let html: string;
export let mounted: () => void;
onMount(mounted);
</script>
{@html html}

View File

@ -9,14 +9,14 @@ import DeckConfigPage from "./DeckConfigPage.svelte";
export async function deckConfig(
target: HTMLDivElement,
deckId: number
): Promise<void> {
): Promise<DeckConfigPage> {
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 },
});

View File

@ -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,
},
},
]);
});

View File

@ -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<ConfigInner>;
readonly currentAuxData: Writable<Record<string, unknown>>;
readonly configList: Readable<ConfigListEntry[]>;
readonly parentLimits: Readable<ParentLimits>;
readonly currentDeck: pb.BackendProto.DeckConfigsForUpdate.CurrentDeck;
readonly defaults: ConfigInner;
readonly addonComponents: Writable<DynamicSvelteComponent[]>;
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<string, unknown>): 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<string, unknown> {
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<string, unknown> {
if (!bytes.length) {
return {} as Record<string, unknown>;
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
} catch (err) {
console.log(`invalid json in deck config`);
return {} as Record<string, unknown>;
}
if (obj.constructor !== Object) {
console.log(`invalid object in deck config`);
return {} as Record<string, unknown>;
}
return obj;
}