From 06492948d94a5694e202be8e2f04f5e46c7a0dc2 Mon Sep 17 00:00:00 2001 From: Dusk Date: Fri, 24 Apr 2026 14:59:22 +0800 Subject: [PATCH] fix(settings): cache Anki note templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 优化设置页卡片类型布局,隐藏语义问答题与 AnkiConnect URL;允许多级列表问答题从 Anki 模板中选择笔记类型和字段。 中文: 将手动读取到的 Anki 笔记模板列表缓存到 data.json,设置页重新打开后下拉菜单可直接使用上次读取结果。 English: Refine the settings card-type layout, hide Semantic Q&A and the AnkiConnect URL, and let nested-list Q&A use selectable Anki note types and fields. English: Cache loaded Anki note templates in data.json so settings dropdowns can reuse the last loaded list after reopening. --- .../config/NoteModelFieldMapping.ts | 8 +- src/application/config/PluginSettings.test.ts | 35 ++- src/application/config/PluginSettings.ts | 50 +++- .../services/NoteFieldMappingService.ts | 18 +- src/presentation/i18n/messages/en.ts | 17 +- src/presentation/i18n/messages/zh.ts | 17 +- .../settings/PluginSettingTab.test.ts | 87 ++++++- src/presentation/settings/PluginSettingTab.ts | 234 +++++++++++------- 8 files changed, 340 insertions(+), 126 deletions(-) diff --git a/src/application/config/NoteModelFieldMapping.ts b/src/application/config/NoteModelFieldMapping.ts index 0579f5b..6df26d5 100644 --- a/src/application/config/NoteModelFieldMapping.ts +++ b/src/application/config/NoteModelFieldMapping.ts @@ -1,7 +1,9 @@ import type { CardType } from "@/domain/card/entities/RenderedFields"; +export type NoteModelFieldMappingCardType = CardType | "qa-group"; + export interface NoteModelFieldMapping { - cardType: CardType; + cardType: NoteModelFieldMappingCardType; modelName: string; loadedFieldNames: string[]; titleField?: string; @@ -10,6 +12,6 @@ export interface NoteModelFieldMapping { loadedAt: number; } -export function createNoteFieldMappingKey(cardType: CardType, modelName: string): string { +export function createNoteFieldMappingKey(cardType: NoteModelFieldMappingCardType, modelName: string): string { return `${cardType}:${modelName}`; -} \ No newline at end of file +} diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index e2ea246..3ed9c39 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -80,6 +80,7 @@ describe("PluginSettings", () => { expect(DEFAULT_SETTINGS.obsidianBacklinkPlacement).toBe("answer-last-line"); expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true); + expect(DEFAULT_SETTINGS.ankiNoteTypeCache).toEqual([]); expect(DEFAULT_SETTINGS.cardTypeConfigs).toEqual({ basic: { enabled: true, @@ -108,6 +109,34 @@ describe("PluginSettings", () => { }); }); + it("normalizes cached Anki note types on load and save paths", () => { + const settings = mergePluginSettings({ + ankiNoteTypeCache: [" Custom Basic ", "", "Cloze", "Custom Basic"], + }); + + expect(settings.ankiNoteTypeCache).toEqual(["Cloze", "Custom Basic"]); + expect(normalizePluginSettings({ + ...DEFAULT_SETTINGS, + ankiNoteTypeCache: ["Basic", " ", "Cloze", "Basic"], + }).ankiNoteTypeCache).toEqual(["Basic", "Cloze"]); + }); + + it("rejects invalid cached Anki note type lists", () => { + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiNoteTypeCache: "Basic" as never, + }); + }, "errors.settings.ankiNoteTypeCacheArray"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiNoteTypeCache: ["Basic", 1] as never, + }); + }, "errors.settings.ankiNoteTypeCacheStrings"); + }); + it("fills new backlink defaults when loading legacy snapshots", () => { const settings = mergePluginSettings({ qaNoteType: "Legacy Basic", @@ -195,7 +224,7 @@ describe("PluginSettings", () => { })).not.toThrow(); }); - it("writes managed QA Group model back during normalization", () => { + it("preserves user-selected QA Group model during normalization", () => { const settings = mergePluginSettings({ cardTypeConfigs: { ...DEFAULT_SETTINGS.cardTypeConfigs, @@ -206,7 +235,7 @@ describe("PluginSettings", () => { }, }); - expect(settings.cardTypeConfigs["qa-group"].noteType).toBe(QA_GROUP_MODEL_NAME); + expect(settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom QA Group"); }); it("rejects invalid module 5 enum values", () => { @@ -231,4 +260,4 @@ describe("PluginSettings", () => { }); }, "errors.settings.cardAnswerCutoffModeInvalid"); }); -}); \ No newline at end of file +}); diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 939a5b7..47af0c2 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -40,6 +40,7 @@ export interface PluginSettings { semanticQaNoteType: string; cardTypeConfigs: CardTypeConfigs; noteFieldMappings: Record; + ankiNoteTypeCache: string[]; defaultDeck: string; fileDeckEnabled: boolean; fileDeckMarker: string; @@ -69,6 +70,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { semanticQaNoteType: DEFAULT_SEMANTIC_QA_NOTE_TYPE, cardTypeConfigs: createDefaultCardTypeConfigs(), noteFieldMappings: {}, + ankiNoteTypeCache: [], defaultDeck: "Obsidian", fileDeckEnabled: false, fileDeckMarker: "TARGET DECK", @@ -114,6 +116,7 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting ...settings, ...legacySettings, cardTypeConfigs, + ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache), obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel), }; } @@ -140,6 +143,7 @@ export function validatePluginSettings(settings: PluginSettings): void { } validateNoteFieldMappings(settings.noteFieldMappings); + validateAnkiNoteTypeCache(settings.ankiNoteTypeCache); if (!settings.defaultDeck.trim()) { throw new PluginUserError("errors.settings.defaultDeckRequired"); @@ -193,6 +197,18 @@ export function validatePluginSettings(settings: PluginSettings): void { } } +function validateAnkiNoteTypeCache(ankiNoteTypeCache: string[]): void { + if (!Array.isArray(ankiNoteTypeCache)) { + throw new PluginUserError("errors.settings.ankiNoteTypeCacheArray"); + } + + for (const noteType of ankiNoteTypeCache) { + if (typeof noteType !== "string") { + throw new PluginUserError("errors.settings.ankiNoteTypeCacheStrings"); + } + } +} + function validateFolderList(folderList: string[], label: "include" | "exclude"): void { if (!Array.isArray(folderList)) { throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray"); @@ -234,6 +250,10 @@ export function validateCardTypeConfigs(cardTypeConfigs: CardTypeConfigs): void throw new PluginUserError("errors.settings.qaNoteTypeRequired"); } + if (configId === "qa-group" && !config.noteType.trim()) { + throw new PluginUserError("errors.settings.qaNoteTypeRequired"); + } + if (configId === "cloze" && !config.noteType.trim()) { throw new PluginUserError("errors.settings.clozeNoteTypeRequired"); } @@ -264,7 +284,7 @@ function validateNoteFieldMappings(noteFieldMappings: Record ...defaults["qa-group"], headingLevel: sanitizeHeadingLevel(settings.qaHeadingLevel, defaults["qa-group"].headingLevel), extraMarker: sanitizeMarker(settings.qaGroupMarker, defaults["qa-group"].extraMarker), - noteType: QA_GROUP_MODEL_NAME, + noteType: sanitizeNoteType(settings.cardTypeConfigs?.["qa-group"]?.noteType ?? QA_GROUP_MODEL_NAME, defaults["qa-group"].noteType), }, cloze: { ...defaults.cloze, @@ -358,9 +378,7 @@ function mergeCardTypeConfigs( enabled: typeof mergedConfig.enabled === "boolean" ? mergedConfig.enabled : fallbackConfig.enabled, headingLevel: sanitizeHeadingLevel(mergedConfig.headingLevel, fallbackConfig.headingLevel), extraMarker: sanitizeMarker(mergedConfig.extraMarker, fallbackConfig.extraMarker), - noteType: configId === "qa-group" - ? QA_GROUP_MODEL_NAME - : sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType), + noteType: sanitizeNoteType(mergedConfig.noteType, fallbackConfig.noteType), }; } @@ -398,4 +416,24 @@ function sanitizeNoteType(value: string | undefined, fallback: string): string { const trimmedValue = value.trim(); return trimmedValue.length > 0 ? trimmedValue : fallback; -} \ No newline at end of file +} + +function normalizeAnkiNoteTypeCache(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const noteTypeSet = new Set(); + for (const noteType of value) { + if (typeof noteType !== "string") { + continue; + } + + const trimmedNoteType = noteType.trim(); + if (trimmedNoteType.length > 0) { + noteTypeSet.add(trimmedNoteType); + } + } + + return [...noteTypeSet].sort((left, right) => left.localeCompare(right)); +} diff --git a/src/application/services/NoteFieldMappingService.ts b/src/application/services/NoteFieldMappingService.ts index 60a8867..bfe08dd 100644 --- a/src/application/services/NoteFieldMappingService.ts +++ b/src/application/services/NoteFieldMappingService.ts @@ -1,4 +1,4 @@ -import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; import { PluginUserError } from "@/application/errors/PluginUserError"; import { isBasicLikeCardType, type CardType, type RenderedFields } from "@/domain/card/entities/RenderedFields"; @@ -33,8 +33,8 @@ export class NoteFieldMappingService { return this.mapCloze(card, noteModelDetails, mapping); } - suggest(cardType: CardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping { - return isBasicLikeCardType(cardType) + suggest(cardType: NoteModelFieldMappingCardType, modelName: string, fieldNames: string[], loadedAt = Date.now()): NoteModelFieldMapping { + return isBasicLikeMappingCardType(cardType) ? { cardType, modelName, @@ -57,7 +57,7 @@ export class NoteFieldMappingService { validateMapping(mapping: NoteModelFieldMapping, noteModelDetails: NoteModelDetails): void { const availableFields = new Set(noteModelDetails.fieldNames); - if (isBasicLikeCardType(mapping.cardType)) { + if (isBasicLikeMappingCardType(mapping.cardType)) { if (!mapping.titleField || !mapping.bodyField) { throw new PluginUserError(getIncompleteSavedMappingKey(mapping.cardType), { modelName: mapping.modelName, @@ -151,6 +151,10 @@ export class NoteFieldMappingService { } } +function isBasicLikeMappingCardType(cardType: NoteModelFieldMappingCardType): cardType is Extract { + return cardType === "qa-group" || cardType === "basic" || cardType === "semantic-qa"; +} + function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.missingSavedMapping.basic" | "errors.noteFieldMapping.missingSavedMapping.cloze" | "errors.noteFieldMapping.missingSavedMapping.semanticQa" { if (cardType === "cloze") { return "errors.noteFieldMapping.missingSavedMapping.cloze"; @@ -161,7 +165,7 @@ function getMissingSavedMappingKey(cardType: CardType): "errors.noteFieldMapping : "errors.noteFieldMapping.missingSavedMapping.basic"; } -function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" { +function getIncompleteSavedMappingKey(cardType: NoteModelFieldMappingCardType): "errors.noteFieldMapping.incompleteSavedMapping.basic" | "errors.noteFieldMapping.incompleteSavedMapping.cloze" | "errors.noteFieldMapping.incompleteSavedMapping.semanticQa" { if (cardType === "cloze") { return "errors.noteFieldMapping.incompleteSavedMapping.cloze"; } @@ -171,7 +175,7 @@ function getIncompleteSavedMappingKey(cardType: CardType): "errors.noteFieldMapp : "errors.noteFieldMapping.incompleteSavedMapping.basic"; } -function getTitleBodyMustDifferKey(cardType: Extract): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" { +function getTitleBodyMustDifferKey(cardType: Extract): "errors.noteFieldMapping.titleBodyMustDiffer.basic" | "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" { return cardType === "semantic-qa" ? "errors.noteFieldMapping.titleBodyMustDiffer.semanticQa" : "errors.noteFieldMapping.titleBodyMustDiffer.basic"; @@ -179,4 +183,4 @@ function getTitleBodyMustDifferKey(cardType: Extract preferredNames.some((preferredName) => preferredName.toLowerCase() === fieldName.toLowerCase())); -} \ No newline at end of file +} diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 2da1760..533226c 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -215,13 +215,15 @@ export const en = { cards: { cardTypes: { title: "Card types and basics", - desc: "Edit the recognition table directly. Changes save automatically and the table drives runtime recognition.", + desc: "Edit recognition rules directly. Changes save automatically.", advancedHint: "Advanced: keep AnkiConnect URL here without promoting it to a separate status card.", markerPlaceholder: "/", fieldsUnavailable: "-- Read fields first --", autoManagedField: "Managed automatically", autoComposedAnswer: "Auto composed", qaGroupManagedNoteType: "{{modelName}} (managed automatically)", + statusLabel: "Status: ", + loadedSummary: "Loaded {{noteTypeCount}} note templates and configured {{configuredCount}} selected card modes.", loadedFieldsStatus: "Refreshed fields for {{count}} selected note types.", failedLoad: "Failed to load note types or fields from Anki.", failedSave: "Failed to save card type settings.", @@ -238,6 +240,15 @@ export const en = { questionField: "Question field", answerField: "Answer field", }, + labels: { + headingLevel: "Card marker:", + extraMarker: "Extra marker:", + noteType: "Anki note type:", + questionField: "Question field:", + answerField: "Answer field:", + mainField: "Main field:", + fields: "Fields:", + }, rows: { basic: "Q&A (regular paragraph)", qaGroup: "Q&A (nested list)", @@ -354,6 +365,8 @@ export const en = { excludeFoldersArray: "Exclude folders must be an array.", excludeFoldersStrings: "Exclude folders must only contain strings.", ankiConnectUrlRequired: "AnkiConnect URL is required.", + ankiNoteTypeCacheArray: "Anki note type cache must be an array.", + ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.", noteFieldMappingsObject: "Note field mappings must be an object.", noteFieldMappingsCardType: "Note field mappings must use a supported card type.", noteFieldMappingsModelName: "Note field mappings must include a model name.", @@ -398,4 +411,4 @@ export const en = { }, } satisfies MessageTree; -export default en; \ No newline at end of file +export default en; diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 35fee9a..ab12b96 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -213,13 +213,15 @@ export const zh = { cards: { cardTypes: { title: "卡片类型及基础设置", - desc: "直接编辑识别表格。修改后自动保存,运行时识别也以这张表为准。", + desc: "直接编辑识别规则,修改后自动保存。", advancedHint: "高级项:将 AnkiConnect URL 保留在这里,不单独做连接状态卡片。", markerPlaceholder: "/", fieldsUnavailable: "-- 请先读取字段 --", autoManagedField: "自动维护", autoComposedAnswer: "自动拼接", qaGroupManagedNoteType: "{{modelName}}(自动维护)", + statusLabel: "状态:", + loadedSummary: "已获取 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式。", loadedFieldsStatus: "已刷新 {{count}} 个当前所选模板的字段。", failedLoad: "从 Anki 读取模板或字段失败。", failedSave: "保存卡片类型设置失败。", @@ -236,6 +238,15 @@ export const zh = { questionField: "问题字段", answerField: "答案字段", }, + labels: { + headingLevel: "卡片标记:", + extraMarker: "额外标记:", + noteType: "Anki 笔记模板:", + questionField: "问题字段:", + answerField: "答案字段:", + mainField: "主字段:", + fields: "字段:", + }, rows: { basic: "问答题(常规段落形式)", qaGroup: "问答题(多级列表形式)", @@ -352,6 +363,8 @@ export const zh = { excludeFoldersArray: "排除文件夹必须是数组。", excludeFoldersStrings: "排除文件夹列表中只能包含字符串。", ankiConnectUrlRequired: "AnkiConnect URL 不能为空。", + ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。", + ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。", noteFieldMappingsObject: "字段映射必须是对象。", noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。", noteFieldMappingsModelName: "字段映射必须包含模型名称。", @@ -396,4 +409,4 @@ export const zh = { }, } satisfies typeof en; -export default zh; \ No newline at end of file +export default zh; diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 11b66f4..95dbf6b 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -276,7 +276,8 @@ import { AnkiHeadingSyncSettingTab } from "./PluginSettingTab"; type FakeContainer = InstanceType["containerEl"]; type FakeSettingInstance = InstanceType; type FakeToggleInstance = { triggerChange(value: boolean): Promise }; -type QueryRoot = FakeContainer | HTMLElement; +type FakeElementInstance = InstanceType; +type QueryRoot = FakeContainer | FakeElementInstance | HTMLElement; class FakePlugin { public readonly app = {}; @@ -441,6 +442,12 @@ function collectTexts(container: QueryRoot): string[] { .filter((text): text is string => Boolean(text)); } +function collectOptionValues(selectEl: FakeElementInstance): string[] { + return selectEl.children + .filter((element) => element.tag === "option") + .map((element) => element.value); +} + function getEmptyCallCount(container: QueryRoot): number { return asFakeContainer(container).emptyCallCount; } @@ -475,22 +482,24 @@ describe("PluginSettingTab", () => { expect(queryByDataset(tab.containerEl, "settingsCardBody", "deck").style.display).toBe("none"); }); - it("renders card 1 table with four rows and the required columns", () => { + it("renders card 1 as three readable card type blocks", () => { const plugin = new FakePlugin(); const tab = new AnkiHeadingSyncSettingTab(plugin as never); tab.display(); - const headerTexts = findElements(tab.containerEl, (element) => element.tag === "th").map((element) => element.textContent); - expect(headerTexts).toEqual(["启用", "卡片类型", "卡片标记", "额外标记", "Anki 笔记模板", "问题字段", "答案字段"]); + expect(queryByDataset(tab.containerEl, "cardTypeList", "true")).toBeDefined(); + expect(findElements(tab.containerEl, (element) => element.tag === "th")).toHaveLength(0); + expect(() => findSetting(tab.containerEl, "AnkiConnect URL")).toThrow("Setting not found"); - const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined).map((row) => row.children[1]?.textContent); + const rowLabels = findElements(tab.containerEl, (element) => element.dataset.cardTypeConfig !== undefined) + .map((row) => collectTexts(row).find((text) => text.includes("题"))); expect(rowLabels).toEqual([ "问答题(常规段落形式)", "问答题(多级列表形式)", "填空题", - "语义问答题", ]); + expect(() => queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa")).toThrow("Element not found"); }); it("auto saves toggle, heading, note type and field mapping edits", async () => { @@ -516,6 +525,11 @@ describe("PluginSettingTab", () => { await basicNoteType.trigger("change"); expect(plugin.settings.cardTypeConfigs.basic.noteType).toBe("Basic"); + const qaGroupNoteType = queryByDataset(tab.containerEl, "cardTypeNoteType", "qa-group"); + qaGroupNoteType.value = "Custom Basic"; + await qaGroupNoteType.trigger("change"); + expect(plugin.settings.cardTypeConfigs["qa-group"].noteType).toBe("Custom Basic"); + await flushPromises(); const basicQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic"); basicQuestionField.value = "Hint"; @@ -528,6 +542,18 @@ describe("PluginSettingTab", () => { titleField: "Hint", bodyField: "Body", })); + + const qaGroupQuestionField = queryByDataset(tab.containerEl, "cardTypeQuestionField", "qa-group"); + qaGroupQuestionField.value = "Title"; + await qaGroupQuestionField.trigger("change"); + const qaGroupAnswerField = queryByDataset(tab.containerEl, "cardTypeAnswerField", "qa-group"); + qaGroupAnswerField.value = "Body"; + await qaGroupAnswerField.trigger("change"); + + expect(plugin.settings.noteFieldMappings[createNoteFieldMappingKey("qa-group", plugin.settings.cardTypeConfigs["qa-group"].noteType)]).toEqual(expect.objectContaining({ + titleField: "Title", + bodyField: "Body", + })); }); it("debounces text input saves instead of saving every keystroke", async () => { @@ -537,10 +563,10 @@ describe("PluginSettingTab", () => { tab.display(); - const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "semantic-qa"); - markerInput.value = "#semantic-a"; + const markerInput = queryByDataset(tab.containerEl, "cardTypeMarker", "cloze"); + markerInput.value = "#cloze-a"; await markerInput.trigger("input"); - markerInput.value = "#semantic-ab"; + markerInput.value = "#cloze-ab"; await markerInput.trigger("input"); expect(plugin.updateCalls).toHaveLength(0); @@ -551,7 +577,7 @@ describe("PluginSettingTab", () => { vi.advanceTimersByTime(1); await flushPromises(); - expect(plugin.settings.cardTypeConfigs["semantic-qa"].extraMarker).toBe("#semantic-ab"); + expect(plugin.settings.cardTypeConfigs.cloze.extraMarker).toBe("#cloze-ab"); expect(plugin.updateCalls).toHaveLength(1); }); @@ -597,6 +623,45 @@ describe("PluginSettingTab", () => { expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); + it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); + await flushPromises(); + + expect(plugin.settings.ankiNoteTypeCache).toEqual([ + "Basic", + "Cloze", + "Custom Basic", + "Custom Cloze", + QA_GROUP_MODEL_NAME, + "Semantic QA", + ]); + expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true); + + const cachedPlugin = new FakePlugin(); + cachedPlugin.settings = normalizePluginSettings({ + ...cachedPlugin.settings, + ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"], + cardTypeConfigs: { + ...cachedPlugin.settings.cardTypeConfigs, + basic: { + ...cachedPlugin.settings.cardTypeConfigs.basic, + noteType: "Cached Basic", + }, + }, + }); + const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never); + + cachedTab.display(); + + const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic"); + expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]); + expect(cachedPlugin.listNoteModelsCalls).toBe(0); + }); + it("folder tree expand and check refresh only the scope card", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ @@ -637,4 +702,4 @@ describe("PluginSettingTab", () => { expect(plugin.settings.fileDeckEnabled).toBe(true); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); }); -}); \ No newline at end of file +}); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index bea7563..77735f1 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -1,8 +1,6 @@ import { PluginSettingTab, Setting } from "obsidian"; -import { QA_GROUP_MODEL_NAME } from "@/application/config/ManagedNoteModels"; import { - CARD_TYPE_CONFIG_IDS, DEFAULT_OBSIDIAN_BACKLINK_LABEL, normalizePluginSettings, type CardAnswerCutoffMode, @@ -13,12 +11,11 @@ import { type ScopeMode, validatePluginSettings, } from "@/application/config/PluginSettings"; -import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +import { createNoteFieldMappingKey, type NoteModelFieldMapping, type NoteModelFieldMappingCardType } from "@/application/config/NoteModelFieldMapping"; import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; import { renderUserFacingMessage, toUserFacingMessage, type UserFacingMessage } from "@/application/errors/PluginUserError"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; -import type { CardType } from "@/domain/card/entities/RenderedFields"; import { t } from "@/presentation/i18n"; import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; @@ -28,6 +25,7 @@ const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status const FOLDER_TREE_STATUS_LOADING: UserFacingMessage = { key: "settings.scope.loading" }; const TEXT_SAVE_DEBOUNCE_MS = 500; const SETTINGS_CARD_ORDER = ["card-types", "sync-content", "scope", "deck", "commands"] as const; +const VISIBLE_CARD_TYPE_CONFIG_IDS = ["basic", "qa-group", "cloze"] as const; type SettingsCardId = (typeof SETTINGS_CARD_ORDER)[number]; @@ -166,6 +164,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") }); const actionRow = containerEl.createDiv(); + actionRow.style.display = "flex"; + actionRow.style.flexDirection = "column"; + actionRow.style.alignItems = "flex-start"; + actionRow.style.gap = "8px"; const loadButton = actionRow.createEl("button", { text: this.ankiConfigLoading ? t("settings.cards.cardTypes.loadAnki.loading") @@ -177,52 +179,38 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { loadButton.addEventListener("click", () => { void this.loadAnkiCardTypeConfig(); }); - actionRow.createEl("span", { text: renderUserFacingMessage(this.cardTypeStatus) }); + actionRow.createEl("span", { + text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.cardTypeStatus)}`, + }); - const table = containerEl.createEl("table"); - table.dataset.cardTypeTable = "true"; - const headerRow = table.createEl("tr"); - for (const columnLabel of [ - t("settings.cards.cardTypes.columns.enabled"), - t("settings.cards.cardTypes.columns.type"), - t("settings.cards.cardTypes.columns.headingLevel"), - t("settings.cards.cardTypes.columns.extraMarker"), - t("settings.cards.cardTypes.columns.noteType"), - t("settings.cards.cardTypes.columns.questionField"), - t("settings.cards.cardTypes.columns.answerField"), - ]) { - headerRow.createEl("th", { text: columnLabel }); + const cardTypeList = containerEl.createDiv(); + cardTypeList.dataset.cardTypeList = "true"; + cardTypeList.style.display = "flex"; + cardTypeList.style.flexDirection = "column"; + cardTypeList.style.gap = "14px"; + + for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) { + this.renderCardTypeBlock(cardTypeList, configId); } - - for (const configId of CARD_TYPE_CONFIG_IDS) { - this.renderCardTypeRow(table, configId); - } - - containerEl.createEl("p", { text: t("settings.cards.cardTypes.advancedHint") }); - new Setting(containerEl) - .setName(t("settings.ankiConnectUrl.name")) - .setDesc(t("settings.ankiConnectUrl.desc")) - .addText((text) => { - text - .setPlaceholder(t("settings.ankiConnectUrl.placeholder")) - .setValue(this.getDraftValue("anki-connect-url", this.plugin.settings.ankiConnectUrl)) - .onChange((value) => { - this.scheduleDebouncedTextSave("anki-connect-url", value, async (draftValue) => { - const nextValue = draftValue.trim() || this.plugin.settings.ankiConnectUrl; - await this.plugin.updateSettings({ ankiConnectUrl: nextValue }); - this.textDraftValues.delete("anki-connect-url"); - }); - }); - }); } - private renderCardTypeRow(tableEl: HTMLElement, configId: CardTypeConfigId): void { + private renderCardTypeBlock(containerEl: HTMLElement, configId: CardTypeConfigId): void { const config = this.plugin.settings.cardTypeConfigs[configId]; - const row = tableEl.createEl("tr"); - row.dataset.cardTypeConfig = configId; + const blockEl = containerEl.createDiv(); + blockEl.dataset.cardTypeConfig = configId; + blockEl.style.display = "flex"; + blockEl.style.flexDirection = "column"; + blockEl.style.gap = "8px"; + blockEl.style.padding = "12px"; + blockEl.style.border = "1px solid var(--background-modifier-border)"; + blockEl.style.borderRadius = "8px"; - const enabledCell = row.createEl("td"); - const enabledCheckbox = enabledCell.createEl("input") as HTMLInputElement; + const headerRow = blockEl.createDiv(); + headerRow.style.display = "flex"; + headerRow.style.alignItems = "center"; + headerRow.style.gap = "10px"; + + const enabledCheckbox = headerRow.createEl("input") as HTMLInputElement; enabledCheckbox.type = "checkbox"; enabledCheckbox.checked = config.enabled; enabledCheckbox.dataset.cardTypeEnabled = configId; @@ -230,11 +218,19 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { void this.saveCardTypeConfig(configId, { enabled: enabledCheckbox.checked }); }); - row.createEl("td", { text: this.getCardTypeLabel(configId) }); + const titleEl = headerRow.createEl("strong", { text: this.getCardTypeLabel(configId) }); + titleEl.style.fontSize = "var(--font-ui-medium)"; - const headingCell = row.createEl("td"); - const headingSelect = this.createSelect(headingCell, `card-type-heading:${configId}`); + const recognitionRow = blockEl.createDiv(); + recognitionRow.style.display = "flex"; + recognitionRow.style.flexWrap = "wrap"; + recognitionRow.style.alignItems = "center"; + recognitionRow.style.gap = "10px 14px"; + + const headingGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.headingLevel")); + const headingSelect = this.createSelect(headingGroup, `card-type-heading:${configId}`); headingSelect.dataset.cardTypeHeading = configId; + headingSelect.style.width = "88px"; for (let level = 1; level <= 6; level += 1) { this.appendOption(headingSelect, String(level), `H${level}`); } @@ -243,11 +239,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { void this.saveCardTypeConfig(configId, { headingLevel: Number(headingSelect.value) }); }); - const markerCell = row.createEl("td"); - const markerInput = markerCell.createEl("input") as HTMLInputElement; + const markerGroup = this.createInlineControlGroup(recognitionRow, t("settings.cards.cardTypes.labels.extraMarker")); + const markerInput = markerGroup.createEl("input") as HTMLInputElement; markerInput.type = "text"; markerInput.dataset.cardTypeMarker = configId; markerInput.placeholder = t("settings.cards.cardTypes.markerPlaceholder"); + markerInput.style.width = "220px"; markerInput.value = this.getDraftValue(`card-type-marker:${configId}`, config.extraMarker); markerInput.addEventListener("input", () => { this.scheduleDebouncedTextSave(`card-type-marker:${configId}`, markerInput.value, async (draftValue) => { @@ -258,59 +255,57 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }); }); - const noteTypeCell = row.createEl("td"); - if (configId === "qa-group") { - const noteTypeText = noteTypeCell.createEl("span", { - text: t("settings.cards.cardTypes.qaGroupManagedNoteType", { modelName: QA_GROUP_MODEL_NAME }), - }); - noteTypeText.dataset.cardTypeNoteType = configId; - } else { - const noteTypeSelect = this.createSelect(noteTypeCell, `card-type-note-type:${configId}`); - noteTypeSelect.dataset.cardTypeNoteType = configId; - for (const noteModel of this.getSelectableNoteModels(config.noteType)) { - this.appendOption(noteTypeSelect, noteModel, noteModel); - } - noteTypeSelect.value = config.noteType; - noteTypeSelect.disabled = this.ankiConfigLoading; - noteTypeSelect.addEventListener("change", () => { - void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value }); - }); - } + const ankiRow = blockEl.createDiv(); + ankiRow.style.display = "grid"; + ankiRow.style.gridTemplateColumns = "max-content 260px max-content 160px max-content 160px"; + ankiRow.style.alignItems = "center"; + ankiRow.style.columnGap = "12px"; + ankiRow.style.rowGap = "8px"; + ankiRow.style.overflow = "hidden"; - this.renderCardTypeFieldCells(row, configId); + const noteTypeGroup = this.createGridControlSlot(ankiRow, t("settings.cards.cardTypes.labels.noteType")); + const noteTypeSelect = this.createSelect(noteTypeGroup, `card-type-note-type:${configId}`); + noteTypeSelect.dataset.cardTypeNoteType = configId; + this.applyEllipsisWidth(noteTypeSelect, "260px"); + for (const noteModel of this.getSelectableNoteModels(config.noteType)) { + this.appendOption(noteTypeSelect, noteModel, noteModel); + } + noteTypeSelect.value = config.noteType; + noteTypeSelect.disabled = this.ankiConfigLoading; + noteTypeSelect.addEventListener("change", () => { + void this.saveCardTypeConfig(configId, { noteType: noteTypeSelect.value }); + }); + + this.renderCardTypeFieldControls(ankiRow, configId); } - private renderCardTypeFieldCells(rowEl: HTMLElement, configId: CardTypeConfigId): void { - const questionCell = rowEl.createEl("td"); - const answerCell = rowEl.createEl("td"); - - if (configId === "qa-group") { - questionCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") }); - answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoManagedField") }); - return; - } - + private renderCardTypeFieldControls(containerEl: HTMLElement, configId: CardTypeConfigId): void { const mapping = this.getCurrentMappingForConfig(configId); if (configId === "cloze") { - const mainFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`); + const mainFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.mainField")); + const mainFieldSelect = this.createFieldSelect(mainFieldGroup, `card-type-question-field:${configId}`); mainFieldSelect.dataset.cardTypeQuestionField = configId; + this.applyEllipsisWidth(mainFieldSelect, "180px"); this.populateFieldSelect(mainFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.mainField); mainFieldSelect.addEventListener("change", () => { void this.saveFieldMapping(configId, { mainField: mainFieldSelect.value || undefined }); }); - answerCell.createEl("span", { text: t("settings.cards.cardTypes.autoComposedAnswer") }); return; } - const titleFieldSelect = this.createFieldSelect(questionCell, `card-type-question-field:${configId}`); + const titleFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.questionField")); + const titleFieldSelect = this.createFieldSelect(titleFieldGroup, `card-type-question-field:${configId}`); titleFieldSelect.dataset.cardTypeQuestionField = configId; + this.applyEllipsisWidth(titleFieldSelect, "160px"); this.populateFieldSelect(titleFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.titleField); titleFieldSelect.addEventListener("change", () => { void this.saveFieldMapping(configId, { titleField: titleFieldSelect.value || undefined }); }); - const bodyFieldSelect = this.createFieldSelect(answerCell, `card-type-answer-field:${configId}`); + const bodyFieldGroup = this.createGridControlSlot(containerEl, t("settings.cards.cardTypes.labels.answerField")); + const bodyFieldSelect = this.createFieldSelect(bodyFieldGroup, `card-type-answer-field:${configId}`); bodyFieldSelect.dataset.cardTypeAnswerField = configId; + this.applyEllipsisWidth(bodyFieldSelect, "160px"); this.populateFieldSelect(bodyFieldSelect, mapping?.loadedFieldNames ?? [], mapping?.bodyField); bodyFieldSelect.addEventListener("change", () => { void this.saveFieldMapping(configId, { bodyField: bodyFieldSelect.value || undefined }); @@ -606,7 +601,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { return true; } - private async saveFieldMapping(configId: Exclude, partialMapping: Partial): Promise { + private async saveFieldMapping(configId: CardTypeConfigId, partialMapping: Partial): Promise { const mapping = this.getCurrentMappingForConfig(configId); const runtimeCardType = this.getRuntimeCardType(configId); const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; @@ -637,20 +632,32 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { try { const noteModels = await this.plugin.listNoteModels(); this.availableNoteModels.splice(0, this.availableNoteModels.length, ...[...noteModels].sort((left, right) => left.localeCompare(right))); - const selectedConfigs = CARD_TYPE_CONFIG_IDS.filter((configId) => configId !== "qa-group") as Array>; + await this.plugin.updateSettings({ ankiNoteTypeCache: this.getAvailableNoteModels() }); + const selectedConfigs = [...VISIBLE_CARD_TYPE_CONFIG_IDS]; + let configuredCount = 0; for (const configId of selectedConfigs) { const runtimeCardType = this.getRuntimeCardType(configId); const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); - const modelDetails = await this.plugin.getNoteModelDetails(modelName); + let modelDetails: NoteModelDetails; + try { + modelDetails = await this.plugin.getNoteModelDetails(modelName); + } catch { + continue; + } this.loadedModelDetails[mappingKey] = modelDetails; this.seedDraftMapping(runtimeCardType, modelName, modelDetails); + configuredCount += 1; } this.cardTypeStatus = { - rawMessage: `${t("settings.mapping.status.loadedCount", { count: this.availableNoteModels.length })} ${t("settings.cards.cardTypes.loadedFieldsStatus", { count: selectedConfigs.length })}`, + key: "settings.cards.cardTypes.loadedSummary", + params: { + noteTypeCount: this.availableNoteModels.length, + configuredCount, + }, }; } catch (error) { this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedLoad"); @@ -660,7 +667,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } } - private seedDraftMapping(runtimeCardType: CardType, modelName: string, modelDetails: NoteModelDetails): void { + private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails): void { const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey]; @@ -677,7 +684,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private getSelectableNoteModels(selectedModelName: string): string[] { - const noteModels = this.availableNoteModels.length > 0 ? [...this.availableNoteModels] : []; + const noteModels = this.availableNoteModels.length > 0 + ? this.getAvailableNoteModels() + : [...this.plugin.settings.ankiNoteTypeCache]; if (!noteModels.includes(selectedModelName)) { noteModels.unshift(selectedModelName); } @@ -685,7 +694,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { return noteModels; } - private getCurrentMappingForConfig(configId: Exclude): NoteModelFieldMapping | undefined { + private getAvailableNoteModels(): string[] { + return [...this.availableNoteModels]; + } + + private getCurrentMappingForConfig(configId: CardTypeConfigId): NoteModelFieldMapping | undefined { const runtimeCardType = this.getRuntimeCardType(configId); const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); @@ -711,7 +724,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }; } - private createFallbackMapping(runtimeCardType: CardType, modelName: string): NoteModelFieldMapping { + private createFallbackMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string): NoteModelFieldMapping { const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); const modelDetails = this.loadedModelDetails[mappingKey]; @@ -727,7 +740,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }; } - private getRuntimeCardType(configId: Exclude): CardType { + private getRuntimeCardType(configId: CardTypeConfigId): NoteModelFieldMapping["cardType"] { + if (configId === "qa-group") { + return "qa-group"; + } + return configId === "cloze" ? "cloze" : configId === "semantic-qa" ? "semantic-qa" : "basic"; } @@ -825,6 +842,39 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } } + private createInlineControlGroup(containerEl: HTMLElement, label: string): HTMLElement { + const groupEl = containerEl.createEl("label"); + groupEl.style.display = "inline-flex"; + groupEl.style.alignItems = "center"; + groupEl.style.gap = "6px"; + groupEl.style.flexWrap = "nowrap"; + + const labelEl = groupEl.createEl("span", { text: label }); + labelEl.style.whiteSpace = "nowrap"; + + return groupEl; + } + + private createGridControlSlot(containerEl: HTMLElement, label: string): HTMLElement { + const labelEl = containerEl.createEl("span", { text: label }); + labelEl.style.whiteSpace = "nowrap"; + + const slotEl = containerEl.createDiv(); + slotEl.style.minWidth = "0"; + slotEl.style.overflow = "hidden"; + + return slotEl; + } + + private applyEllipsisWidth(element: HTMLElement, width: string): void { + element.style.width = width; + element.style.maxWidth = width; + element.style.minWidth = width; + element.style.overflow = "hidden"; + element.style.textOverflow = "ellipsis"; + element.style.whiteSpace = "nowrap"; + } + private scheduleDebouncedTextSave(key: string, value: string, saveAction: (draftValue: string) => Promise): void { this.textDraftValues.set(key, value); @@ -958,4 +1008,4 @@ function getScopeModeTreeDescription(scopeMode: ScopeMode): string { } return t("settings.scope.treeDescription.exclude"); -} \ No newline at end of file +}