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;