From 043a2071490d0af483d1bf1cc352bd43afe1b52c Mon Sep 17 00:00:00 2001 From: Dusk Date: Fri, 24 Apr 2026 18:38:10 +0800 Subject: [PATCH] perf(settings): batch cache Anki model fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 设置页手动读取 Anki 模板时改为批量读取字段并缓存到 data.json,重新打开设置页可直接使用缓存字段。 中文: 修复字段缓存 loadedAt 类型收窄和测试 fake 缺少批量字段接口导致的 production build 失败。 English: Batch-load Anki model fields for settings, cache them in data.json, and reuse cached fields when reopening the settings page. English: Fix production build failures by narrowing cached loadedAt values and adding the batch field API to test fakes. --- src/application/config/PluginSettings.test.ts | 108 +++++++++++++++++ src/application/config/PluginSettings.ts | 86 +++++++++++++ src/application/ports/AnkiGateway.ts | 1 + .../anki/AnkiConnectGateway.test.ts | 68 +++++++++++ src/infrastructure/anki/AnkiConnectGateway.ts | 52 ++++++++ .../DataJsonPluginConfigRepository.test.ts | 33 +++++ src/presentation/AnkiHeadingSyncPlugin.ts | 4 + src/presentation/i18n/messages/en.ts | 5 + src/presentation/i18n/messages/zh.ts | 5 + .../settings/PluginSettingTab.test.ts | 113 +++++++++++++++--- src/presentation/settings/PluginSettingTab.ts | 83 +++++++++---- src/test-support/manualSyncFakes.ts | 9 +- 12 files changed, 529 insertions(+), 38 deletions(-) diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 3ed9c39..20123e9 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -81,6 +81,7 @@ describe("PluginSettings", () => { expect(DEFAULT_SETTINGS.syncObsidianTagsToAnki).toBe(true); expect(DEFAULT_SETTINGS.keepPureTagLinesInCardBody).toBe(true); expect(DEFAULT_SETTINGS.ankiNoteTypeCache).toEqual([]); + expect(DEFAULT_SETTINGS.ankiModelFieldCache).toEqual({}); expect(DEFAULT_SETTINGS.cardTypeConfigs).toEqual({ basic: { enabled: true, @@ -121,6 +122,59 @@ describe("PluginSettings", () => { }).ankiNoteTypeCache).toEqual(["Basic", "Cloze"]); }); + it("normalizes cached Anki model fields on load and save paths", () => { + const settings = mergePluginSettings({ + ankiModelFieldCache: { + " Custom Basic ": { + fieldNames: [" Front ", "", "Back", "Front"], + loadedAt: 123, + }, + Cloze: { + fieldNames: [" Text ", "Extra", "Text"], + loadedAt: Number.NaN, + }, + " ": { + fieldNames: ["Ignored"], + loadedAt: 999, + }, + }, + }); + + expect(settings.ankiModelFieldCache).toEqual({ + Cloze: { + fieldNames: ["Text", "Extra"], + loadedAt: 0, + }, + "Custom Basic": { + fieldNames: ["Front", "Back"], + loadedAt: 123, + }, + }); + + expect(normalizePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: { + fieldNames: [" Title ", "Body", "Title"], + loadedAt: 456, + }, + "Custom Cloze": { + fieldNames: "Text" as never, + loadedAt: "invalid" as never, + }, + }, + }).ankiModelFieldCache).toEqual({ + Basic: { + fieldNames: ["Title", "Body"], + loadedAt: 456, + }, + "Custom Cloze": { + fieldNames: [], + loadedAt: 0, + }, + }); + }); + it("rejects invalid cached Anki note type lists", () => { expectPluginUserError(() => { validatePluginSettings({ @@ -137,6 +191,60 @@ describe("PluginSettings", () => { }, "errors.settings.ankiNoteTypeCacheStrings"); }); + it("rejects invalid cached Anki model field maps", () => { + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: "Basic" as never, + }); + }, "errors.settings.ankiModelFieldCacheObject"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: [] as never, + }, + }); + }, "errors.settings.ankiModelFieldCacheEntryObject"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: { + fieldNames: "Front" as never, + loadedAt: 1, + }, + }, + }); + }, "errors.settings.ankiModelFieldCacheFieldNamesArray"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: { + fieldNames: ["Front", 1] as never, + loadedAt: 1, + }, + }, + }); + }, "errors.settings.ankiModelFieldCacheFieldNamesStrings"); + + expectPluginUserError(() => { + validatePluginSettings({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: { + fieldNames: ["Front"], + loadedAt: "invalid" as never, + }, + }, + }); + }, "errors.settings.ankiModelFieldCacheLoadedAt"); + }); + it("fills new backlink defaults when loading legacy snapshots", () => { const settings = mergePluginSettings({ qaNoteType: "Legacy Basic", diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 47af0c2..c4de1da 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -19,6 +19,13 @@ export interface CardTypeConfig { export type CardTypeConfigs = Record; +export interface AnkiModelFieldCacheEntry { + fieldNames: string[]; + loadedAt: number; +} + +export type AnkiModelFieldCache = Record; + export const DEFAULT_OBSIDIAN_BACKLINK_LABEL = "Open in Obsidian"; const DEFAULT_BASIC_HEADING_LEVEL = 4; @@ -41,6 +48,7 @@ export interface PluginSettings { cardTypeConfigs: CardTypeConfigs; noteFieldMappings: Record; ankiNoteTypeCache: string[]; + ankiModelFieldCache: AnkiModelFieldCache; defaultDeck: string; fileDeckEnabled: boolean; fileDeckMarker: string; @@ -71,6 +79,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { cardTypeConfigs: createDefaultCardTypeConfigs(), noteFieldMappings: {}, ankiNoteTypeCache: [], + ankiModelFieldCache: {}, defaultDeck: "Obsidian", fileDeckEnabled: false, fileDeckMarker: "TARGET DECK", @@ -117,6 +126,7 @@ export function normalizePluginSettings(settings: PluginSettings): PluginSetting ...legacySettings, cardTypeConfigs, ankiNoteTypeCache: normalizeAnkiNoteTypeCache(settings.ankiNoteTypeCache), + ankiModelFieldCache: normalizeAnkiModelFieldCache(settings.ankiModelFieldCache), obsidianBacklinkLabel: normalizeObsidianBacklinkLabel(settings.obsidianBacklinkLabel), }; } @@ -130,6 +140,7 @@ export function mergePluginSettings(settings?: Partial | null): ...partialSettings, cardTypeConfigs, noteFieldMappings: partialSettings.noteFieldMappings ?? DEFAULT_SETTINGS.noteFieldMappings, + ankiModelFieldCache: partialSettings.ankiModelFieldCache ?? DEFAULT_SETTINGS.ankiModelFieldCache, includeFolders: partialSettings.includeFolders ?? DEFAULT_SETTINGS.includeFolders, excludeFolders: partialSettings.excludeFolders ?? DEFAULT_SETTINGS.excludeFolders, }); @@ -144,6 +155,7 @@ export function validatePluginSettings(settings: PluginSettings): void { validateNoteFieldMappings(settings.noteFieldMappings); validateAnkiNoteTypeCache(settings.ankiNoteTypeCache); + validateAnkiModelFieldCache(settings.ankiModelFieldCache); if (!settings.defaultDeck.trim()) { throw new PluginUserError("errors.settings.defaultDeckRequired"); @@ -209,6 +221,32 @@ function validateAnkiNoteTypeCache(ankiNoteTypeCache: string[]): void { } } +function validateAnkiModelFieldCache(ankiModelFieldCache: AnkiModelFieldCache): void { + if (!ankiModelFieldCache || typeof ankiModelFieldCache !== "object" || Array.isArray(ankiModelFieldCache)) { + throw new PluginUserError("errors.settings.ankiModelFieldCacheObject"); + } + + for (const cacheEntry of Object.values(ankiModelFieldCache)) { + if (!cacheEntry || typeof cacheEntry !== "object" || Array.isArray(cacheEntry)) { + throw new PluginUserError("errors.settings.ankiModelFieldCacheEntryObject"); + } + + if (!Array.isArray(cacheEntry.fieldNames)) { + throw new PluginUserError("errors.settings.ankiModelFieldCacheFieldNamesArray"); + } + + for (const fieldName of cacheEntry.fieldNames) { + if (typeof fieldName !== "string") { + throw new PluginUserError("errors.settings.ankiModelFieldCacheFieldNamesStrings"); + } + } + + if (!Number.isFinite(cacheEntry.loadedAt)) { + throw new PluginUserError("errors.settings.ankiModelFieldCacheLoadedAt"); + } + } +} + function validateFolderList(folderList: string[], label: "include" | "exclude"): void { if (!Array.isArray(folderList)) { throw new PluginUserError(label === "include" ? "errors.settings.includeFoldersArray" : "errors.settings.excludeFoldersArray"); @@ -437,3 +475,51 @@ function normalizeAnkiNoteTypeCache(value: unknown): string[] { return [...noteTypeSet].sort((left, right) => left.localeCompare(right)); } + +function normalizeAnkiModelFieldCache(value: unknown): AnkiModelFieldCache { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const normalizedEntries = Object.entries(value).flatMap(([modelName, cacheEntry]) => { + const trimmedModelName = modelName.trim(); + if (trimmedModelName.length === 0 || !cacheEntry || typeof cacheEntry !== "object" || Array.isArray(cacheEntry)) { + return []; + } + + const entry = cacheEntry as Partial; + const loadedAt = typeof entry.loadedAt === "number" && Number.isFinite(entry.loadedAt) ? entry.loadedAt : 0; + return [[trimmedModelName, { + fieldNames: normalizeModelFieldNames(entry.fieldNames), + loadedAt, + }] as const]; + }); + + normalizedEntries.sort(([left], [right]) => left.localeCompare(right)); + return Object.fromEntries(normalizedEntries); +} + +function normalizeModelFieldNames(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const normalizedFieldNames: string[] = []; + const seenFieldNames = new Set(); + + for (const fieldName of value) { + if (typeof fieldName !== "string") { + continue; + } + + const trimmedFieldName = fieldName.trim(); + if (trimmedFieldName.length === 0 || seenFieldNames.has(trimmedFieldName)) { + continue; + } + + seenFieldNames.add(trimmedFieldName); + normalizedFieldNames.push(trimmedFieldName); + } + + return normalizedFieldNames; +} diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 9ae2620..4180eeb 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -78,6 +78,7 @@ export interface AnkiGateway { export interface AnkiGroupGateway extends AnkiGateway { getModelFieldNames(modelName: string): Promise; + getModelFieldNamesByModelNames(modelNames: string[]): Promise>; getModelTemplates(modelName: string): Promise>; getModelStyling(modelName: string): Promise; createModel(input: CreateAnkiModelInput): Promise; diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index 63d75b8..a2d2cb8 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -34,6 +34,74 @@ describe("AnkiConnectGateway", () => { }); }); + it("loads model field names in one multi request and skips per-item errors", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: [ + { + error: null, + result: ["Front", "Back"], + }, + { + error: "missing model", + result: null, + }, + ["Text", "Extra"], + ], + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + const fieldNamesByModelName = await gateway.getModelFieldNamesByModelNames(["Basic", "Missing", "Cloze"]); + + expect(fieldNamesByModelName).toEqual({ + Basic: ["Front", "Back"], + Cloze: ["Text", "Extra"], + }); + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "modelFieldNames", + params: { + modelName: "Basic", + }, + }, + { + action: "modelFieldNames", + params: { + modelName: "Missing", + }, + }, + { + action: "modelFieldNames", + params: { + modelName: "Cloze", + }, + }, + ], + }, + }); + }); + + it("bubbles top-level multi errors when loading model field names", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: "AnkiConnect unavailable", + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + + await expect(gateway.getModelFieldNamesByModelNames(["Basic"])) + .rejects + .toThrow("AnkiConnect unavailable"); + }); + it("loads deck names from AnkiConnect", async () => { requestUrlMock.mockResolvedValue({ json: { diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 4b4f24f..105a8b4 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -9,6 +9,11 @@ interface AnkiResponse { result: T; } +interface AnkiMultiActionResponse { + error: string | null; + result: T; +} + interface NoteInfo { cards: number[]; fields?: Record; @@ -71,6 +76,32 @@ export class AnkiConnectGateway implements AnkiGroupGateway { return this.invoke("modelFieldNames", { modelName }); } + async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { + const uniqueModelNames = Array.from(new Set(modelNames + .map((modelName) => modelName.trim()) + .filter((modelName) => modelName.length > 0))); + + if (uniqueModelNames.length === 0) { + return {}; + } + + const results = await this.invoke | string[]>>("multi", { + actions: uniqueModelNames.map((modelName) => ({ + action: "modelFieldNames", + params: { modelName }, + })), + }); + + return uniqueModelNames.reduce>((fieldNamesByModelName, modelName, index) => { + const fieldNames = extractMultiModelFieldNames(results[index]); + if (fieldNames) { + fieldNamesByModelName[modelName] = fieldNames; + } + + return fieldNamesByModelName; + }, {}); + } + async getModelTemplates(modelName: string): Promise> { const templates = await this.invoke("modelTemplates", { modelName }); return Object.fromEntries(Object.entries(templates).map(([templateName, template]) => [templateName, { @@ -404,6 +435,27 @@ export class AnkiConnectGateway implements AnkiGroupGateway { } } +function extractMultiModelFieldNames(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + return value.filter((fieldName): fieldName is string => typeof fieldName === "string"); + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const multiActionResponse = value as Partial>; + if (multiActionResponse.error) { + return undefined; + } + + if (!Array.isArray(multiActionResponse.result)) { + return undefined; + } + + return multiActionResponse.result.filter((fieldName): fieldName is string => typeof fieldName === "string"); +} + function extractDeckNoteCount(rawStats: unknown, deckId: number | undefined): number | undefined { if (!rawStats || typeof rawStats !== "object") { return undefined; diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts index 5f8e562..ddb0d58 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -47,6 +47,7 @@ describe("DataJsonPluginConfigRepository", () => { expect(settings.semanticQaNoteType).toBe("Semantic QA"); expect(settings.obsidianBacklinkLabel).toBe("Open in Obsidian"); expect(settings.obsidianBacklinkPlacement).toBe("answer-last-line"); + expect(settings.ankiModelFieldCache).toEqual({}); }); it("normalizes blank backlink labels on load and save", async () => { @@ -103,6 +104,38 @@ describe("DataJsonPluginConfigRepository", () => { }); }); + it("persists cached Anki model fields across save and reload", async () => { + const store = new InMemoryPluginDataStore(); + const repository = new DataJsonPluginConfigRepository(store); + + await repository.save({ + ...DEFAULT_SETTINGS, + ankiModelFieldCache: { + Basic: { + fieldNames: ["Front", "Back"], + loadedAt: 123, + }, + Cloze: { + fieldNames: ["Text", "Extra"], + loadedAt: 456, + }, + }, + }); + + const reloaded = await repository.load(); + + expect(reloaded.ankiModelFieldCache).toEqual({ + Basic: { + fieldNames: ["Front", "Back"], + loadedAt: 123, + }, + Cloze: { + fieldNames: ["Text", "Extra"], + loadedAt: 456, + }, + }); + }); + it("persists semantic QA settings and mappings across save and reload", async () => { const store = new InMemoryPluginDataStore(); const repository = new DataJsonPluginConfigRepository(store); diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index 68aa5d3..1c03906 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -86,6 +86,10 @@ export default class AnkiHeadingSyncPlugin extends Plugin { return this.ankiGateway.listNoteModels(); } + async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { + return this.ankiGateway.getModelFieldNamesByModelNames(modelNames); + } + async getNoteModelDetails(modelName: string): Promise { return this.ankiGateway.getModelDetails(modelName); } diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 533226c..7bbb799 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -367,6 +367,11 @@ export const en = { ankiConnectUrlRequired: "AnkiConnect URL is required.", ankiNoteTypeCacheArray: "Anki note type cache must be an array.", ankiNoteTypeCacheStrings: "Anki note type cache can only contain strings.", + ankiModelFieldCacheObject: "Anki model field cache must be an object.", + ankiModelFieldCacheEntryObject: "Each Anki model field cache entry must be an object.", + ankiModelFieldCacheFieldNamesArray: "Anki model field cache fieldNames must be an array.", + ankiModelFieldCacheFieldNamesStrings: "Anki model field cache fieldNames can only contain strings.", + ankiModelFieldCacheLoadedAt: "Anki model field cache loadedAt must be a finite number.", 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.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index ab12b96..3d58078 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -365,6 +365,11 @@ export const zh = { ankiConnectUrlRequired: "AnkiConnect URL 不能为空。", ankiNoteTypeCacheArray: "Anki 笔记模板缓存必须是数组。", ankiNoteTypeCacheStrings: "Anki 笔记模板缓存中只能包含字符串。", + ankiModelFieldCacheObject: "Anki 模板字段缓存必须是对象。", + ankiModelFieldCacheEntryObject: "每个 Anki 模板字段缓存项都必须是对象。", + ankiModelFieldCacheFieldNamesArray: "Anki 模板字段缓存中的 fieldNames 必须是数组。", + ankiModelFieldCacheFieldNamesStrings: "Anki 模板字段缓存中的 fieldNames 只能包含字符串。", + ankiModelFieldCacheLoadedAt: "Anki 模板字段缓存中的 loadedAt 必须是有限数字。", noteFieldMappingsObject: "字段映射必须是对象。", noteFieldMappingsCardType: "字段映射必须使用受支持的卡片类型。", noteFieldMappingsModelName: "字段映射必须包含模型名称。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 95dbf6b..8f80488 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -283,6 +283,8 @@ class FakePlugin { public readonly app = {}; public readonly updateCalls: Array> = []; public listNoteModelsCalls = 0; + public getModelFieldNamesByModelNamesCalls = 0; + public getNoteModelDetailsCalls = 0; public listFolderTreeCalls = 0; public insertDeckTemplateCalls = 0; public settings = normalizePluginSettings({ @@ -336,24 +338,20 @@ class FakePlugin { return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_MODEL_NAME]; } + async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { + this.getModelFieldNamesByModelNamesCalls += 1; + + return modelNames.reduce>((fieldNamesByModelName, modelName) => { + fieldNamesByModelName[modelName] = this.getFieldNamesForModel(modelName); + return fieldNamesByModelName; + }, {}); + } + async getNoteModelDetails(modelName: string): Promise<{ fieldNames: string[]; isCloze: boolean }> { - if (modelName.includes("Cloze")) { - return { - fieldNames: ["Text", "Extra", "Hint"], - isCloze: true, - }; - } - - if (modelName === "Semantic QA") { - return { - fieldNames: ["Title", "Body", "Source"], - isCloze: false, - }; - } - + this.getNoteModelDetailsCalls += 1; return { - fieldNames: ["Title", "Body", "Hint"], - isCloze: false, + fieldNames: this.getFieldNamesForModel(modelName), + isCloze: modelName.includes("Cloze"), }; } @@ -365,6 +363,22 @@ class FakePlugin { async insertDeckTemplateToCurrentFile(): Promise { this.insertDeckTemplateCalls += 1; } + + private getFieldNamesForModel(modelName: string): string[] { + if (modelName.includes("Cloze")) { + return ["Text", "Extra", "Hint"]; + } + + if (modelName === "Semantic QA") { + return ["Title", "Body", "Source"]; + } + + if (modelName === "Basic") { + return ["Front", "Back", "Extra"]; + } + + return ["Title", "Body", "Hint"]; + } } function asFakeContainer(container: QueryRoot): FakeContainer { @@ -620,7 +634,10 @@ describe("PluginSettingTab", () => { await flushPromises(); expect(plugin.listNoteModelsCalls).toBe(1); + expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1); + expect(plugin.getNoteModelDetailsCalls).toBe(0); expect(getEmptyCallCount(tab.containerEl)).toBe(initialEmptyCount); + expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 6 个笔记模板,已选择并配置 3 个卡片模式。"))).toBe(true); }); it("persists loaded Anki note types and uses the cache before the next manual refresh", async () => { @@ -640,11 +657,32 @@ describe("PluginSettingTab", () => { "Semantic QA", ]); expect(plugin.updateCalls.some((call) => Array.isArray(call.ankiNoteTypeCache))).toBe(true); + expect(plugin.settings.ankiModelFieldCache).toEqual(expect.objectContaining({ + Basic: { + fieldNames: ["Front", "Back", "Extra"], + loadedAt: expect.any(Number), + }, + "Custom Basic": { + fieldNames: ["Title", "Body", "Hint"], + loadedAt: expect.any(Number), + }, + Cloze: { + fieldNames: ["Text", "Extra", "Hint"], + loadedAt: expect.any(Number), + }, + })); + expect(plugin.updateCalls.some((call) => typeof call.ankiModelFieldCache === "object" && call.ankiModelFieldCache !== null)).toBe(true); const cachedPlugin = new FakePlugin(); cachedPlugin.settings = normalizePluginSettings({ ...cachedPlugin.settings, ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"], + ankiModelFieldCache: { + "Cached Basic": { + fieldNames: ["Front", "Back", "Hint"], + loadedAt: 123, + }, + }, cardTypeConfigs: { ...cachedPlugin.settings.cardTypeConfigs, basic: { @@ -659,9 +697,52 @@ describe("PluginSettingTab", () => { const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic"); expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]); + const basicQuestionFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeQuestionField", "basic"); + const basicAnswerFieldSelect = queryByDataset(cachedTab.containerEl, "cardTypeAnswerField", "basic"); + expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Hint"]); + expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Hint"]); expect(cachedPlugin.listNoteModelsCalls).toBe(0); }); + it("switching note type immediately reuses cached field names", async () => { + const plugin = new FakePlugin(); + plugin.settings = normalizePluginSettings({ + ...plugin.settings, + ankiNoteTypeCache: ["Basic", "Custom Basic"], + ankiModelFieldCache: { + Basic: { + fieldNames: ["Front", "Back", "Extra"], + loadedAt: 100, + }, + "Custom Basic": { + fieldNames: ["Title", "Body", "Hint"], + loadedAt: 200, + }, + }, + cardTypeConfigs: { + ...plugin.settings.cardTypeConfigs, + basic: { + ...plugin.settings.cardTypeConfigs.basic, + noteType: "Custom Basic", + }, + }, + }); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + + const basicNoteTypeSelect = queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"); + basicNoteTypeSelect.value = "Basic"; + await basicNoteTypeSelect.trigger("change"); + await flushPromises(); + + const basicQuestionFieldSelect = queryByDataset(tab.containerEl, "cardTypeQuestionField", "basic"); + const basicAnswerFieldSelect = queryByDataset(tab.containerEl, "cardTypeAnswerField", "basic"); + expect(collectOptionValues(basicQuestionFieldSelect)).toEqual(["", "Front", "Back", "Extra"]); + expect(collectOptionValues(basicAnswerFieldSelect)).toEqual(["", "Front", "Back", "Extra"]); + expect(plugin.getNoteModelDetailsCalls).toBe(0); + }); + it("folder tree expand and check refresh only the scope card", async () => { const plugin = new FakePlugin(); plugin.settings = normalizePluginSettings({ diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 77735f1..a47de49 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -161,6 +161,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private renderCardTypesCard(containerEl: HTMLElement): void { + this.hydrateVisibleCardTypeCaches(); containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") }); const actionRow = containerEl.createDiv(); @@ -631,26 +632,26 @@ 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))); - await this.plugin.updateSettings({ ankiNoteTypeCache: this.getAvailableNoteModels() }); - const selectedConfigs = [...VISIBLE_CARD_TYPE_CONFIG_IDS]; - let configuredCount = 0; + const nextAvailableNoteModels = Array.from(new Set(noteModels)).sort((left, right) => left.localeCompare(right)); + this.availableNoteModels.splice(0, this.availableNoteModels.length, ...nextAvailableNoteModels); - for (const configId of selectedConfigs) { - const runtimeCardType = this.getRuntimeCardType(configId); - const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; - const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); - let modelDetails: NoteModelDetails; - try { - modelDetails = await this.plugin.getNoteModelDetails(modelName); - } catch { - continue; - } + const fieldNamesByModelName = await this.plugin.getModelFieldNamesByModelNames(nextAvailableNoteModels); + const loadedAt = Date.now(); + const ankiModelFieldCache = Object.entries(fieldNamesByModelName).reduce((cache, [modelName, fieldNames]) => { + cache[modelName] = { + fieldNames: [...fieldNames], + loadedAt, + }; + return cache; + }, {}); - this.loadedModelDetails[mappingKey] = modelDetails; - this.seedDraftMapping(runtimeCardType, modelName, modelDetails); - configuredCount += 1; - } + await this.plugin.updateSettings({ + ankiNoteTypeCache: this.getAvailableNoteModels(), + ankiModelFieldCache, + }); + + this.hydrateVisibleCardTypeCaches(ankiModelFieldCache); + const configuredCount = this.countConfiguredCardTypes(ankiModelFieldCache); this.cardTypeStatus = { key: "settings.cards.cardTypes.loadedSummary", @@ -667,7 +668,47 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } } - private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails): void { + private hydrateVisibleCardTypeCaches( + ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache, + ): void { + for (const configId of VISIBLE_CARD_TYPE_CONFIG_IDS) { + this.hydrateCardTypeCache(configId, ankiModelFieldCache); + } + } + + private hydrateCardTypeCache( + configId: CardTypeConfigId, + ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"], + ): void { + const runtimeCardType = this.getRuntimeCardType(configId); + const modelName = this.plugin.settings.cardTypeConfigs[configId].noteType; + const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); + const cacheEntry = ankiModelFieldCache[modelName]; + + if (!cacheEntry) { + return; + } + + const modelDetails = this.createCachedModelDetails(modelName, cacheEntry.fieldNames); + this.loadedModelDetails[mappingKey] = modelDetails; + this.seedDraftMapping(runtimeCardType, modelName, modelDetails, cacheEntry.loadedAt); + } + + private createCachedModelDetails(modelName: string, fieldNames: string[]): NoteModelDetails { + return { + fieldNames: [...fieldNames], + isCloze: modelName.toLowerCase().includes("cloze"), + }; + } + + private countConfiguredCardTypes(ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"]): number { + return VISIBLE_CARD_TYPE_CONFIG_IDS.reduce((configuredCount, configId) => { + const selectedModelName = this.plugin.settings.cardTypeConfigs[configId].noteType; + return configuredCount + (ankiModelFieldCache[selectedModelName] ? 1 : 0); + }, 0); + } + + private seedDraftMapping(runtimeCardType: NoteModelFieldMappingCardType, modelName: string, modelDetails: NoteModelDetails, loadedAt = Date.now()): void { const mappingKey = createNoteFieldMappingKey(runtimeCardType, modelName); const currentMapping = this.plugin.settings.noteFieldMappings[mappingKey] ?? this.draftMappings[mappingKey]; @@ -675,12 +716,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { this.draftMappings[mappingKey] = { ...currentMapping, loadedFieldNames: [...modelDetails.fieldNames], - loadedAt: Date.now(), + loadedAt, }; return; } - this.draftMappings[mappingKey] = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames); + this.draftMappings[mappingKey] = this.noteFieldMappingService.suggest(runtimeCardType, modelName, modelDetails.fieldNames, loadedAt); } private getSelectableNoteModels(selectedModelName: string): string[] { diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index a06f2a1..754ce76 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -187,6 +187,13 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { return this.modelDetailsByName[modelName]?.fieldNames ?? []; } + async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { + return Object.fromEntries(await Promise.all(modelNames.map(async (modelName) => [ + modelName, + await this.getModelFieldNames(modelName), + ]))); + } + async getModelTemplates(modelName: string): Promise> { return this.modelTemplatesByName[modelName] ?? {}; } @@ -391,4 +398,4 @@ function buildFolderTree(filePaths: string[]): FolderTreeNode[] { } return root.children; -} \ No newline at end of file +}