From 55277aa90aa7c1f04a39f01107735015f61d621f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 20 Apr 2021 19:50:05 +1000 Subject: [PATCH] implement deck config saving on JS end --- pylib/anki/_backend/genbackend.py | 2 +- pylib/anki/decks.py | 4 +++ qt/aqt/mediasrv.py | 25 +++++++++++++++- qt/aqt/operations/deck.py | 8 ++++- ts/deckconfig/OptionsDropdown.svelte | 14 +++++++-- ts/deckconfig/index.ts | 2 +- ts/deckconfig/lib.test.ts | 45 +++++++++++++++++++++++++++- ts/deckconfig/lib.ts | 42 +++++++++++++++++++++++--- 8 files changed, 131 insertions(+), 11 deletions(-) diff --git a/pylib/anki/_backend/genbackend.py b/pylib/anki/_backend/genbackend.py index 02bceda48..7c79a3ebb 100755 --- a/pylib/anki/_backend/genbackend.py +++ b/pylib/anki/_backend/genbackend.py @@ -34,7 +34,7 @@ LABEL_REQUIRED = 2 LABEL_REPEATED = 3 # messages we don't want to unroll in codegen -SKIP_UNROLL_INPUT = {"TranslateString", "SetPreferences"} +SKIP_UNROLL_INPUT = {"TranslateString", "SetPreferences", "UpdateDeckConfigs"} SKIP_UNROLL_OUTPUT = {"GetPreferences"} SKIP_DECODE = {"Graphs", "GetGraphPreferences"} diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index cc5d81bf4..75673f36f 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -23,6 +23,7 @@ DeckNameId = _pb.DeckNameId FilteredDeckConfig = _pb.Deck.Filtered DeckCollapseScope = _pb.SetDeckCollapsedIn.Scope DeckConfigsForUpdate = _pb.DeckConfigsForUpdate +UpdateDeckConfigs = _pb.UpdateDeckConfigsIn # legacy code may pass this in as the type argument to .id() defaultDeck = 0 @@ -328,6 +329,9 @@ class DeckManager: def get_deck_configs_for_update(self, deck_id: DeckId) -> DeckConfigsForUpdate: return self.col._backend.get_deck_configs_for_update(deck_id) + def update_deck_configs(self, input: UpdateDeckConfigs) -> OpChanges: + return self.col._backend.update_deck_configs(input=input) + def all_config(self) -> List[DeckConfigDict]: "A list of all deck config." return list(from_json_bytes(self.col._backend.all_deck_config_legacy())) diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 9d7f8f7e7..9d7530afe 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -20,8 +20,10 @@ from waitress.server import create_server import aqt from anki import hooks -from anki.collection import GraphPreferences +from anki.collection import GraphPreferences, OpChanges +from anki.decks import UpdateDeckConfigs from anki.utils import devMode, from_json_bytes +from aqt.operations.deck import update_deck_configs from aqt.qt import * from aqt.utils import aqt_data_folder @@ -283,11 +285,32 @@ def deck_configs_for_update() -> bytes: ).SerializeToString() +def update_deck_configs_request() -> bytes: + # the regular change tracking machinery expects to be started on the main + # thread and uses a callback on success, so we need to run this op on + # main, and return immediately from the web request + + input = UpdateDeckConfigs() + input.ParseFromString(request.data) + + def on_success(changes: OpChanges) -> None: + print("done", changes) + + def handle_on_main() -> None: + update_deck_configs(parent=aqt.mw, input=input).success( + on_success + ).run_in_background() + + aqt.mw.taskman.run_on_main(handle_on_main) + return b"" + + post_handlers = { "graphData": graph_data, "graphPreferences": graph_preferences, "setGraphPreferences": set_graph_preferences, "deckConfigsForUpdate": deck_configs_for_update, + "updateDeckConfigs": update_deck_configs_request, # pylint: disable=unnecessary-lambda "i18nResources": i18n_resources, "congratsInfo": congrats_info, diff --git a/qt/aqt/operations/deck.py b/qt/aqt/operations/deck.py index a8d69bd89..52e4aa148 100644 --- a/qt/aqt/operations/deck.py +++ b/qt/aqt/operations/deck.py @@ -6,7 +6,7 @@ from __future__ import annotations from typing import Optional, Sequence from anki.collection import OpChanges, OpChangesWithCount, OpChangesWithId -from anki.decks import DeckCollapseScope, DeckId +from anki.decks import DeckCollapseScope, DeckId, UpdateDeckConfigs from aqt import QWidget from aqt.operations import CollectionOp from aqt.utils import getOnlyText, tooltip, tr @@ -80,3 +80,9 @@ def set_deck_collapsed( def set_current_deck(*, parent: QWidget, deck_id: DeckId) -> CollectionOp[OpChanges]: return CollectionOp(parent, lambda col: col.decks.set_current(deck_id)) + + +def update_deck_configs( + *, parent: QWidget, input: UpdateDeckConfigs +) -> CollectionOp[OpChanges]: + return CollectionOp(parent, lambda col: col.decks.update_deck_configs(input)) diff --git a/ts/deckconfig/OptionsDropdown.svelte b/ts/deckconfig/OptionsDropdown.svelte index 8700d8dc4..155e18142 100644 --- a/ts/deckconfig/OptionsDropdown.svelte +++ b/ts/deckconfig/OptionsDropdown.svelte @@ -54,6 +54,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } }, 100); } + + function save(applyToChildDecks: boolean): void { + state.save(applyToChildDecks); + }
- +
diff --git a/ts/deckconfig/index.ts b/ts/deckconfig/index.ts index 6cf9e49bd..4589bec96 100644 --- a/ts/deckconfig/index.ts +++ b/ts/deckconfig/index.ts @@ -15,7 +15,7 @@ export async function deckConfig( modules: [ModuleName.SCHEDULING, ModuleName.ACTIONS, ModuleName.DECK_CONFIG], }); const info = await getDeckConfigInfo(deckId); - const state = new DeckConfigState(info); + const state = new DeckConfigState(deckId, info); new DeckConfigPage({ target, props: { state }, diff --git a/ts/deckconfig/lib.test.ts b/ts/deckconfig/lib.test.ts index 88ca21f4f..0efd39dba 100644 --- a/ts/deckconfig/lib.test.ts +++ b/ts/deckconfig/lib.test.ts @@ -92,6 +92,7 @@ const exampleData = { function startingState(): DeckConfigState { return new DeckConfigState( + 123, pb.BackendProto.DeckConfigsForUpdate.fromObject(exampleData) ); } @@ -198,7 +199,8 @@ test("deck list", () => { expect(get(state.currentConfig).newPerDay).toBe(10); // only the pre-existing deck should be listed for removal - expect((state as any).removedConfigs).toStrictEqual([1618570764780]); + const out = state.dataForSaving(false); + expect(out.removedConfigIds).toStrictEqual([1618570764780]); }); test("duplicate name", () => { @@ -233,3 +235,44 @@ test("parent counts", () => { }); expect(get(state.parentLimits)).toStrictEqual({ newCards: 123, reviews: 200 }); }); + +test("saving", () => { + let state = startingState(); + let out = state.dataForSaving(false); + expect(out.removedConfigIds).toStrictEqual([]); + expect(out.targetDeckId).toBe(123); + // in no-changes case, currently selected config should + // be returned + expect(out.configs.length).toBe(1); + expect(out.configs[0].name).toBe("another one"); + expect(out.applyToChildren).toBe(false); + + // rename, then change current deck + state.setCurrentName("zzz"); + out = state.dataForSaving(true); + state.setCurrentIndex(0); + + // renamed deck should be in changes, with current deck as last element + out = state.dataForSaving(true); + expect(out.configs.map((c) => c.name)).toStrictEqual(["zzz", "Default"]); + expect(out.applyToChildren).toBe(true); + + // start again, adding new deck + state = startingState(); + state.addConfig("hello"); + + // deleting it should not change removedConfigs + state.removeCurrentConfig(); + out = state.dataForSaving(true); + expect(out.removedConfigIds).toStrictEqual([]); + + // select the other non-default deck & remove + state.setCurrentIndex(1); + state.removeCurrentConfig(); + + // should be listed in removedConfigs, and modified should + // only contain Default, which is the new current deck + out = state.dataForSaving(true); + expect(out.removedConfigIds).toStrictEqual([1618570764780]); + expect(out.configs.map((c) => c.name)).toStrictEqual(["Default"]); +}); diff --git a/ts/deckconfig/lib.ts b/ts/deckconfig/lib.ts index 045dcf11a..d050d7f39 100644 --- a/ts/deckconfig/lib.ts +++ b/ts/deckconfig/lib.ts @@ -19,6 +19,14 @@ export async function getDeckConfigInfo( ); } +export async function saveDeckConfig( + input: pb.BackendProto.UpdateDeckConfigsIn +): Promise { + const data: Uint8Array = pb.BackendProto.UpdateDeckConfigsIn.encode(input).finish(); + await postRequest("/_anki/updateDeckConfigs", data); + return; +} + export type DeckConfigId = number; export interface ConfigWithCount { @@ -47,6 +55,7 @@ export class DeckConfigState { readonly currentDeck: pb.BackendProto.DeckConfigsForUpdate.CurrentDeck; readonly defaults: ConfigInner; + private targetDeckId: number; private configs: ConfigWithCount[]; private selectedIdx: number; private configListSetter!: (val: ConfigListEntry[]) => void; @@ -55,7 +64,8 @@ export class DeckConfigState { private removedConfigs: DeckConfigId[] = []; private schemaModified: boolean; - constructor(data: pb.BackendProto.DeckConfigsForUpdate) { + constructor(targetDeckId: number, data: pb.BackendProto.DeckConfigsForUpdate) { + this.targetDeckId = targetDeckId; this.currentDeck = data.currentDeck as pb.BackendProto.DeckConfigsForUpdate.CurrentDeck; this.defaults = data.defaults!.config! as ConfigInner; this.configs = data.allConfig.map((config) => { @@ -117,7 +127,6 @@ export class DeckConfigState { } /// Adds a new config, making it current. - /// not already a new config. addConfig(name: string): void { const uniqueName = this.ensureNewNameUnique(name); const config = pb.BackendProto.DeckConfig.create({ @@ -144,9 +153,8 @@ export class DeckConfigState { removeCurrentConfig(): void { const currentId = this.configs[this.selectedIdx].config.id; if (currentId === 1) { - throw "can't remove default config"; + throw Error("can't remove default config"); } - if (currentId !== 0) { this.removedConfigs.push(currentId); this.schemaModified = true; @@ -157,6 +165,32 @@ export class DeckConfigState { this.updateConfigList(); } + dataForSaving(applyToChildren: boolean): pb.BackendProto.UpdateDeckConfigsIn { + const modifiedConfigsExcludingCurrent = this.configs + .map((c) => c.config) + .filter((c, idx) => { + return ( + idx !== this.selectedIdx && + (c.id === 0 || this.modifiedConfigs.has(c.id)) + ); + }); + const configs = [ + ...modifiedConfigsExcludingCurrent, + // current must come last, even if unmodified + this.configs[this.selectedIdx].config, + ]; + return pb.BackendProto.UpdateDeckConfigsIn.create({ + targetDeckId: this.targetDeckId, + removedConfigIds: this.removedConfigs, + configs, + applyToChildren, + }); + } + + async save(applyToChildren: boolean): Promise { + await saveDeckConfig(this.dataForSaving(applyToChildren)); + } + private onCurrentConfigChanged(config: ConfigInner): void { if (!isEqual(config, this.configs[this.selectedIdx].config.config)) { this.configs[this.selectedIdx].config.config = config;