diff --git a/docs/settings-anki-template-cache-status-decisions.md b/docs/settings-anki-template-cache-status-decisions.md new file mode 100644 index 0000000..92626d4 --- /dev/null +++ b/docs/settings-anki-template-cache-status-decisions.md @@ -0,0 +1,108 @@ +# 设置页 Anki 模板缓存状态实现决策 + +本文锁定本轮“设置页模板缓存状态 + 后台轻量 freshness 检测”的仓库兼容实现方案。 + +## 1. 作用域决策 + +- 只改设置页 card 1 的状态显示与轻量检测。 +- 不改同步主线。 +- 不新增按需字段读取。 +- 保留现有 manual full refresh 行为。 + +## 2. 缓存状态模型决策 + +- 在 `PluginSettingTab` 中新增实例级状态: + - `noteTypeCacheCheckStatus: "idle" | "checking" | "same" | "changed" | "failed"` + - `noteTypeCacheCheckPromise: Promise | null` + - `detectedAnkiNoteTypeCache: string[] | null` +- 另增: + - `cardTypeStatusOverride: UserFacingMessage | null` + +## 3. 统一状态计算决策 + +- 不再直接渲染原来的 `cardTypeStatus` 字段。 +- 改为通过 `getCardTypeStatusMessage()` 统一计算。 +- 优先级固定为: + 1. `ankiConfigLoading === true` + 2. `cardTypeStatusOverride` + 3. 缓存状态: + - 无缓存 -> `cacheEmpty` + - 缓存 + changed -> `cacheChanged` + - 缓存 + failed -> `cacheCheckFailed` + - 缓存 + idle/checking/same -> `cacheSummary` + +## 4. 后台检测启动决策 + +- `renderCardTypesCard()` 每次渲染时调用 `maybeStartNoteTypeCacheCheck()`。 +- 但该方法必须短路以下情况: + - `ankiConfigLoading === true` + - `ankiNoteTypeCache.length === 0` + - `noteTypeCacheCheckPromise !== null` + - `noteTypeCacheCheckStatus !== "idle"` +- 这样同一 settings tab 生命周期只会发起一次后台检测。 + +## 5. 轻量检测语义决策 + +- 后台检测只调用 `listNoteModels()`。 +- 不调用: + - `getModelFieldNamesByModelNames()` + - `getNoteModelDetails()` + - `modelTemplates` +- 比较逻辑使用规范化后的名称列表: + - trim + - 过滤空字符串 + - 去重 + - 排序 +- 比较相等 -> `same` +- 比较不等 -> `changed` +- 调用失败 -> `failed` +- 检测结果不写入 `data.json`。 + +## 6. override 与缓存状态交互决策 + +- 手动 full refresh 成功时: + - `cardTypeStatusOverride = loadedSummary` + - `noteTypeCacheCheckStatus = "same"` + - `detectedAnkiNoteTypeCache = 最新 modelNames` +- 手动 full refresh 失败时: + - `cardTypeStatusOverride = failedLoad` + - 保留已有缓存和字段映射 +- 本地 validation/save 失败时: + - `cardTypeStatusOverride = failedSave` 或具体错误 +- 普通自动保存成功时: + - 清空 `cardTypeStatusOverride` + +## 7. 生命周期重置决策 + +- `hide()` 时重置: + - `noteTypeCacheCheckStatus = "idle"` + - `noteTypeCacheCheckPromise = null` + - `detectedAnkiNoteTypeCache = null` + - `cardTypeStatusOverride = null` +- 这样每次重新打开设置页都允许重新做一次轻量检测。 + +## 8. manual full refresh 决策 + +- 保持当前完整流程: + - `listNoteModels()` + - `getModelFieldNamesByModelNames()` + - `updateSettings({ ankiNoteTypeCache, ankiModelFieldCache })` + - `hydrateVisibleCardTypeCaches()` + - `syncQaGroupMappingFromCache()` +- 成功后 card 1 继续局部刷新。 +- 失败后继续用缓存渲染下拉和字段。 + +## 9. 受控偏离 + +- 偏离 1:当前仓库的“保存失败”有两类。 + - 设置页内可见的 validate 失败,仍会进入 `cardTypeStatusOverride` + - `plugin.updateSettings()` 内部持久化失败只通过 NoticeService 通知,当前设置页拿不到错误回传 +- 因此本轮只能稳定覆盖“校验失败”和“手动刷新失败”的 override,不额外改 plugin 层错误合同。 + +- 偏离 2:后台检测中的 `checking` 不单独显示 loading 文案。 + - 仍显示 `cacheSummary` + - 原因:计划要求检测不阻塞 UI,且 checking 与 same 的用户文案相同 + +- 偏离 3:`detectedAnkiNoteTypeCache` 仅作为实例内调试/状态记录,不参与渲染计数。 + - 渲染中的数量仍来自持久缓存 `ankiNoteTypeCache` + - 原因:目标文案强调“当前使用缓存的 x 个模板” \ No newline at end of file diff --git a/docs/settings-anki-template-cache-status-gap-report.md b/docs/settings-anki-template-cache-status-gap-report.md new file mode 100644 index 0000000..5725331 --- /dev/null +++ b/docs/settings-anki-template-cache-status-gap-report.md @@ -0,0 +1,131 @@ +# 设置页 Anki 模板缓存状态差距审计 + +本文基于当前仓库真实实现,审计“设置页模板缓存状态显示 + 后台轻量 freshness 检测”的落地差距。 + +## 1. 当前设置页已是 cache-first 下拉,但不是 cache-first 状态 + +- 入口在 [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts)。 +- `renderCardTypesCard()` 打开时会先执行 `hydrateVisibleCardTypeCaches()`。 +- note type 下拉来自: + - `availableNoteModels`,若为空则退回 `settings.ankiNoteTypeCache` +- 字段下拉来自: + - `settings.ankiModelFieldCache` 水合出的 `loadedModelDetails` + - 再经 `getCurrentMappingForConfig()` 生成 draft mapping + +这说明缓存实际上已经参与首屏渲染,当前问题不是“缓存没被用”,而是“状态文案没有反映缓存可用性”。 + +## 2. 当前首屏状态文本仍固定为 idle + +- 当前类内状态是: + - `cardTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE` +- `NOTE_TYPE_STATUS_IDLE` 指向 `settings.mapping.status.idle`。 +- `renderCardTypesCard()` 直接渲染 `renderUserFacingMessage(this.cardTypeStatus)`。 +- `cardTypeStatus` 只会在以下情况变化: + - `saveCardTypeConfig()` 校验失败 + - `loadAnkiCardTypeConfig()` 开始 / 成功 / 失败 + +结果:即使已有 `ankiNoteTypeCache` 和 `ankiModelFieldCache`,首次打开设置页仍显示“请先从 Anki 刷新”语义,而不是缓存摘要。 + +## 3. 当前设置页打开时不会做任何后台 freshness 检测 + +- `display()` -> `renderCard("card-types")` -> `renderCardTypesCard()` 的当前路径里没有自动调用: + - `listNoteModels()` + - `getModelFieldNamesByModelNames()` + - `getNoteModelDetails()` +- 唯一主动访问 Anki 的入口仍是点击“手动读取 Anki 里的模板配置”。 + +这与目标方案的差距是:当前没有“只跑一次 modelNames 的轻量后台检测”。 + +## 4. 当前完整刷新流程已经符合“手动触发全量字段读取”约束 + +- `loadAnkiCardTypeConfig()` 当前真实流程是: + - `listNoteModels()` 一次 + - `getModelFieldNamesByModelNames()` 一次 + - `updateSettings({ ankiNoteTypeCache, ankiModelFieldCache })` + - `hydrateVisibleCardTypeCaches()` + - `syncQaGroupMappingFromCache()` + - `renderCard("card-types")` +- 当前不会在设置页打开时读取所有字段。 + +这意味着本轮应保留该完整刷新流程,只新增轻量 `listNoteModels()` 检测,不改 full refresh 语义。 + +## 5. 当前 card 1 已具备局部刷新壳层,可直接复用 + +- `display()` 只在首次进入时 `containerEl.empty()` 和初始化 card shells。 +- 后续都是 `renderCard(cardId)` 的局部刷新。 +- `loadAnkiCardTypeConfig()`、`saveCardTypeConfig()`、`saveFieldMapping()` 等都只刷新 `card-types` 卡片。 + +这与目标方案一致:后台检测完成后只需 `renderCard("card-types")`,不需要整页重建。 + +## 6. 当前没有“一次性检测状态”或“override 状态”的分层模型 + +- 当前只有一个 `cardTypeStatus` 字段,同时承载: + - idle + - manual refresh loading + - manual refresh success + - manual refresh failure + - validation failure +- 没有单独的: + - cache freshness check 状态 + - status override + - detected live note type list + - one-shot lifecycle promise + +结果:如果直接把后台检测结果写回 `cardTypeStatus`,会和手动刷新成功/失败状态互相覆盖。 + +## 7. 当前没有防重入机制来保证“一次生命周期只检测一次” + +- 现在 `renderCard("card-types")` 会在很多交互后被反复调用: + - display 初始渲染 + - toggle/heading/note type 变更 + - QA Group warning acceptance + - manual refresh 前后 +- 但类内没有任何 `Promise` 或 `status` 标记来阻止重复启动 live 检测。 + +因此如果要加后台检测,必须把“是否已检测过/是否检测中”挂在 settings tab 实例上,而不是放在 render 逻辑里临时判断。 + +## 8. 当前测试已证明缓存渲染存在,但未覆盖状态与后台检测 + +- [src/presentation/settings/PluginSettingTab.test.ts](src/presentation/settings/PluginSettingTab.test.ts) 当前已经覆盖: + - `ankiNoteTypeCache` 渲染 note type 下拉 + - `ankiModelFieldCache` 渲染字段下拉 + - 手动完整刷新只触发 `listNoteModels()` + `getModelFieldNamesByModelNames()` + - `getNoteModelDetails()` 在设置页刷新路径中不应被调用 +- 但未覆盖: + - 有缓存时首屏状态摘要 + - 首屏后台 `listNoteModels()` 轻量检测 + - 检测 changed / failed 分支 + - 无缓存时不启动后台检测 + - 手动 refresh 成功后清除 changed 提示 + +## 9. 当前 i18n 里缺少缓存状态分层文案 + +- 当前 card types 相关文案已有: + - `loadedSummary` + - `failedLoad` + - `failedSave` + - `loadAnki.loading` +- 但没有: + - `cacheEmpty` + - `cacheSummary` + - `cacheChanged` + - `cacheCheckFailed` + +结果:即使实现了 cache-aware status,也没有现成 i18n key 可用。 + +## 10. 结论 + +当前仓库与目标方案的差距主要集中在状态模型,不在缓存渲染主链: + +1. 缓存已经用于下拉和字段渲染,但状态文本仍是固定 idle。 +2. 设置页打开时没有任何轻量 `modelNames` freshness check。 +3. 当前缺少 cache status 与 manual refresh status 的分层模型。 +4. 当前缺少防止重复后台检测的实例级状态。 +5. 当前测试和 i18n 都还不知道这套缓存状态语义。 + +因此最安全的仓库兼容路径是: + +1. 保留现有缓存渲染和 manual full refresh 流程。 +2. 新增实例级的一次性 `listNoteModels()` 后台检测状态。 +3. 用统一的 `getCardTypeStatusMessage()` 计算首屏状态与 override 状态。 +4. 让后台检测只影响 card 1 的状态提示,不触碰 `data.json` 和字段缓存。 \ No newline at end of file diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 6e4688d..d0e1d5b 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -223,6 +223,10 @@ export const en = { autoComposedAnswer: "Auto composed", qaGroupManagedNoteType: "{{modelName}} (managed automatically)", statusLabel: "Status: ", + cacheEmpty: "There are no cached Anki note templates yet. Read the Anki template config manually.", + cacheSummary: "Currently using {{noteTypeCount}} cached note templates with {{configuredCount}} selected card modes configured.", + cacheChanged: "Anki currently reports {{liveCount}} note templates, which differs from the {{cachedCount}} cached templates on this page. The UI is still using the cache with {{configuredCount}} selected card modes configured; read the Anki template config manually to refresh.", + cacheCheckFailed: "Still using {{noteTypeCount}} cached note templates with {{configuredCount}} selected card modes configured. The background check for the latest template list failed; read the Anki template config manually if you need a refresh.", 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.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index 3e781ce..269619e 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -221,6 +221,10 @@ export const zh = { autoComposedAnswer: "自动拼接", qaGroupManagedNoteType: "{{modelName}}(自动维护)", statusLabel: "状态:", + cacheEmpty: "当前没有已缓存的 Anki 笔记模板。请手动读取 Anki 里的模板配置。", + cacheSummary: "当前使用缓存的 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式。", + cacheChanged: "检测到 Anki 当前有 {{liveCount}} 个笔记模板,和本页缓存的 {{cachedCount}} 个模板不一致。当前仍在使用缓存,已选择并配置 {{configuredCount}} 个卡片模式;如需更新请手动读取 Anki 里的模板配置。", + cacheCheckFailed: "当前继续使用缓存的 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式;后台检查最新模板列表失败,如需更新请手动读取 Anki 里的模板配置。", loadedSummary: "已获取 {{noteTypeCount}} 个笔记模板,已选择并配置 {{configuredCount}} 个卡片模式。", loadedFieldsStatus: "已刷新 {{count}} 个当前所选模板的字段。", failedLoad: "从 Anki 读取模板或字段失败。", diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 0ca5a02..c99ab6c 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -289,6 +289,8 @@ class FakePlugin { public getNoteModelDetailsCalls = 0; public listFolderTreeCalls = 0; public insertDeckTemplateCalls = 0; + public listNoteModelsError: Error | null = null; + public noteModels = ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME]; public settings = normalizePluginSettings({ ...DEFAULT_SETTINGS, qaNoteType: "Custom Basic", @@ -337,7 +339,11 @@ class FakePlugin { async listNoteModels(): Promise { this.listNoteModelsCalls += 1; - return ["Basic", "Custom Basic", "Cloze", "Custom Cloze", "Semantic QA", QA_GROUP_USER_MODEL_NAME]; + if (this.listNoteModelsError) { + throw this.listNoteModelsError; + } + + return [...this.noteModels]; } async getModelFieldNamesByModelNames(modelNames: string[]): Promise> { @@ -703,9 +709,11 @@ describe("PluginSettingTab", () => { }, }, }); + cachedPlugin.noteModels = ["Basic", "Cached Basic", "Cached Cloze"]; const cachedTab = new AnkiHeadingSyncSettingTab(cachedPlugin as never); cachedTab.display(); + await flushPromises(); const basicNoteTypeSelect = queryByDataset(cachedTab.containerEl, "cardTypeNoteType", "basic"); expect(collectOptionValues(basicNoteTypeSelect)).toEqual(["Basic", "Cached Basic", "Cached Cloze"]); @@ -713,7 +721,118 @@ describe("PluginSettingTab", () => { 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); + expect(cachedPlugin.listNoteModelsCalls).toBe(1); + expect(cachedPlugin.getModelFieldNamesByModelNamesCalls).toBe(0); + expect(cachedPlugin.getNoteModelDetailsCalls).toBe(0); + expect(collectTexts(cachedTab.containerEl).some((text) => text.includes("当前使用缓存的 3 个笔记模板,已选择并配置 1 个卡片模式。"))).toBe(true); + }); + + it("shows cache-empty status and skips background checking when there is no cached note type", () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + + expect(plugin.listNoteModelsCalls).toBe(0); + expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); + expect(collectTexts(tab.containerEl).some((text) => text.includes("当前没有已缓存的 Anki 笔记模板"))).toBe(true); + }); + + it("warns when the live note type list differs from the cached list without loading fields", async () => { + const plugin = new FakePlugin(); + plugin.settings = normalizePluginSettings({ + ...plugin.settings, + ankiNoteTypeCache: ["Basic", "Cached Basic", "Cached Cloze"], + ankiModelFieldCache: { + "Cached Basic": { + fieldNames: ["Front", "Back", "Hint"], + loadedAt: 123, + }, + }, + cardTypeConfigs: { + ...plugin.settings.cardTypeConfigs, + basic: { + ...plugin.settings.cardTypeConfigs.basic, + noteType: "Cached Basic", + }, + }, + }); + plugin.noteModels = ["Basic", "Cached Basic", "Cached Cloze", "Live Only"]; + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + await flushPromises(); + + expect(plugin.listNoteModelsCalls).toBe(1); + expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); + expect(plugin.getNoteModelDetailsCalls).toBe(0); + expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 3 个模板不一致"))).toBe(true); + }); + + it("shows cache-check failure while keeping cached dropdowns when the lightweight check fails", async () => { + const plugin = new FakePlugin(); + plugin.settings = normalizePluginSettings({ + ...plugin.settings, + ankiNoteTypeCache: ["Basic", "Cached Basic"], + ankiModelFieldCache: { + "Cached Basic": { + fieldNames: ["Front", "Back", "Hint"], + loadedAt: 123, + }, + }, + cardTypeConfigs: { + ...plugin.settings.cardTypeConfigs, + basic: { + ...plugin.settings.cardTypeConfigs.basic, + noteType: "Cached Basic", + }, + }, + }); + plugin.listNoteModelsError = new Error("offline"); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + await flushPromises(); + + expect(plugin.listNoteModelsCalls).toBe(1); + expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(0); + expect(collectTexts(tab.containerEl).some((text) => text.includes("后台检查最新模板列表失败"))).toBe(true); + expect(collectOptionValues(queryByDataset(tab.containerEl, "cardTypeNoteType", "basic"))).toEqual(["Basic", "Cached Basic"]); + }); + + it("manual refresh overrides the stale-cache warning with the latest loaded summary", async () => { + const plugin = new FakePlugin(); + plugin.settings = normalizePluginSettings({ + ...plugin.settings, + ankiNoteTypeCache: ["Basic", "Cached Basic"], + ankiModelFieldCache: { + "Cached Basic": { + fieldNames: ["Front", "Back", "Hint"], + loadedAt: 123, + }, + }, + cardTypeConfigs: { + ...plugin.settings.cardTypeConfigs, + basic: { + ...plugin.settings.cardTypeConfigs.basic, + noteType: "Cached Basic", + }, + }, + }); + plugin.noteModels = ["Basic", "Cached Basic", "Custom Cloze"]; + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + + tab.display(); + await flushPromises(); + expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 2 个模板不一致"))).toBe(true); + + await queryByDataset(tab.containerEl, "cardTypesRefresh", "true").trigger("click"); + await flushPromises(); + + expect(plugin.listNoteModelsCalls).toBe(2); + expect(plugin.getModelFieldNamesByModelNamesCalls).toBe(1); + expect(collectTexts(tab.containerEl).some((text) => text.includes("已获取 3 个笔记模板,已选择并配置 2 个卡片模式。"))).toBe(true); + expect(collectTexts(tab.containerEl).some((text) => text.includes("和本页缓存的 2 个模板不一致"))).toBe(false); }); it("switching note type immediately reuses cached field names", async () => { diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 5f30278..818a399 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -32,13 +32,13 @@ import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSelectionNode } from "./FolderScopeTree"; -const NOTE_TYPE_STATUS_IDLE: UserFacingMessage = { key: "settings.mapping.status.idle" }; 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]; +type NoteTypeCacheCheckStatus = "idle" | "checking" | "same" | "changed" | "failed"; interface SettingsCardShell { cardEl: HTMLElement; @@ -63,8 +63,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private folderTreeLoadPromise: Promise | null = null; private hasLoadedFolderTree = false; private displayInitialized = false; - private cardTypeStatus: UserFacingMessage = NOTE_TYPE_STATUS_IDLE; private ankiConfigLoading = false; + private cardTypeStatusOverride: UserFacingMessage | null = null; + private noteTypeCacheCheckStatus: NoteTypeCacheCheckStatus = "idle"; + private noteTypeCacheCheckPromise: Promise | null = null; + private detectedAnkiNoteTypeCache: string[] | null = null; + private noteTypeCacheCheckToken = 0; constructor(plugin: AnkiHeadingSyncPlugin) { super(plugin.app, plugin); @@ -78,6 +82,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { this.displayInitialized = false; this.cardShells.clear(); this.clearDebouncedTextSaves(); + this.cardTypeStatusOverride = null; + this.noteTypeCacheCheckStatus = "idle"; + this.noteTypeCacheCheckPromise = null; + this.detectedAnkiNoteTypeCache = null; + this.noteTypeCacheCheckToken += 1; } display(): void { @@ -174,6 +183,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private renderCardTypesCard(containerEl: HTMLElement): void { this.hydrateVisibleCardTypeCaches(); + this.maybeStartNoteTypeCacheCheck(); containerEl.createEl("p", { text: t("settings.cards.cardTypes.desc") }); const actionRow = containerEl.createDiv(); @@ -191,7 +201,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { loadButton.disabled = this.ankiConfigLoading; loadButton.addEventListener("click", () => this.loadAnkiCardTypeConfig()); actionRow.createEl("span", { - text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.cardTypeStatus)}`, + text: `${t("settings.cards.cardTypes.statusLabel")}${renderUserFacingMessage(this.getCardTypeStatusMessage())}`, }); const cardTypeList = containerEl.createDiv(); @@ -662,12 +672,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { cardTypeConfigs: nextCardTypeConfigs, })); } catch (error) { - this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedSave"); + this.cardTypeStatusOverride = toUserFacingMessage(error, "settings.cards.cardTypes.failedSave"); this.renderCard("card-types"); return false; } - this.cardTypeStatus = NOTE_TYPE_STATUS_IDLE; + this.cardTypeStatusOverride = null; await this.plugin.updateSettings({ cardTypeConfigs: nextCardTypeConfigs }); if (configId === "qa-group" && typeof partialConfig.noteType === "string") { @@ -706,6 +716,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { ...partialMapping, } as NoteModelFieldMapping; + this.cardTypeStatusOverride = null; this.draftMappings[mappingKey] = nextMapping; await this.plugin.updateSettings({ noteFieldMappings: { @@ -720,13 +731,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { } private async loadAnkiCardTypeConfig(): Promise { + this.noteTypeCacheCheckToken += 1; + this.noteTypeCacheCheckPromise = null; + this.cardTypeStatusOverride = null; + this.noteTypeCacheCheckStatus = this.resolveManualRefreshCacheStatus(); this.ankiConfigLoading = true; - this.cardTypeStatus = { key: "settings.cards.cardTypes.loadAnki.loading" }; this.renderCard("card-types"); try { const noteModels = await this.plugin.listNoteModels(); - const nextAvailableNoteModels = Array.from(new Set(noteModels)).sort((left, right) => left.localeCompare(right)); + const nextAvailableNoteModels = normalizeNoteTypeNames(noteModels); this.availableNoteModels.splice(0, this.availableNoteModels.length, ...nextAvailableNoteModels); const fieldNamesByModelName = await this.plugin.getModelFieldNamesByModelNames(nextAvailableNoteModels); @@ -748,7 +762,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { await this.syncQaGroupMappingFromCache(this.plugin.settings.cardTypeConfigs["qa-group"].noteType, ankiModelFieldCache); const configuredCount = this.countConfiguredCardTypes(ankiModelFieldCache); - this.cardTypeStatus = { + this.detectedAnkiNoteTypeCache = [...nextAvailableNoteModels]; + this.noteTypeCacheCheckStatus = "same"; + this.cardTypeStatusOverride = { key: "settings.cards.cardTypes.loadedSummary", params: { noteTypeCount: this.availableNoteModels.length, @@ -756,13 +772,110 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }, }; } catch (error) { - this.cardTypeStatus = toUserFacingMessage(error, "settings.cards.cardTypes.failedLoad"); + this.cardTypeStatusOverride = toUserFacingMessage(error, "settings.cards.cardTypes.failedLoad"); } finally { this.ankiConfigLoading = false; this.renderCard("card-types"); } } + private maybeStartNoteTypeCacheCheck(): void { + if (this.ankiConfigLoading || this.plugin.settings.ankiNoteTypeCache.length === 0) { + return; + } + + if (this.noteTypeCacheCheckStatus !== "idle" || this.noteTypeCacheCheckPromise) { + return; + } + + this.noteTypeCacheCheckStatus = "checking"; + const token = ++this.noteTypeCacheCheckToken; + const checkPromise = this.checkNoteTypeCacheFreshness(token).finally(() => { + if (this.noteTypeCacheCheckPromise === checkPromise) { + this.noteTypeCacheCheckPromise = null; + } + + if (token === this.noteTypeCacheCheckToken) { + this.renderCard("card-types"); + } + }); + + this.noteTypeCacheCheckPromise = checkPromise; + } + + private async checkNoteTypeCacheFreshness(token: number): Promise { + try { + const liveNoteModels = normalizeNoteTypeNames(await this.plugin.listNoteModels()); + if (token !== this.noteTypeCacheCheckToken) { + return; + } + + this.detectedAnkiNoteTypeCache = liveNoteModels; + this.noteTypeCacheCheckStatus = areStringArraysEqual(this.plugin.settings.ankiNoteTypeCache, liveNoteModels) + ? "same" + : "changed"; + } catch { + if (token !== this.noteTypeCacheCheckToken) { + return; + } + + this.noteTypeCacheCheckStatus = "failed"; + } + } + + private getCardTypeStatusMessage(): UserFacingMessage { + if (this.ankiConfigLoading) { + return { key: "settings.cards.cardTypes.loadAnki.loading" }; + } + + if (this.cardTypeStatusOverride) { + return this.cardTypeStatusOverride; + } + + const cachedNoteTypeCount = this.plugin.settings.ankiNoteTypeCache.length; + if (cachedNoteTypeCount === 0) { + return { key: "settings.cards.cardTypes.cacheEmpty" }; + } + + const configuredCount = this.countConfiguredCardTypes(this.plugin.settings.ankiModelFieldCache); + if (this.noteTypeCacheCheckStatus === "changed") { + return { + key: "settings.cards.cardTypes.cacheChanged", + params: { + cachedCount: cachedNoteTypeCount, + liveCount: this.detectedAnkiNoteTypeCache?.length ?? cachedNoteTypeCount, + configuredCount, + }, + }; + } + + if (this.noteTypeCacheCheckStatus === "failed") { + return { + key: "settings.cards.cardTypes.cacheCheckFailed", + params: { + noteTypeCount: cachedNoteTypeCount, + configuredCount, + }, + }; + } + + return { + key: "settings.cards.cardTypes.cacheSummary", + params: { + noteTypeCount: cachedNoteTypeCount, + configuredCount, + }, + }; + } + + private resolveManualRefreshCacheStatus(): NoteTypeCacheCheckStatus { + if (this.noteTypeCacheCheckStatus === "changed" || this.noteTypeCacheCheckStatus === "failed") { + return this.noteTypeCacheCheckStatus; + } + + return this.plugin.settings.ankiNoteTypeCache.length > 0 ? "same" : "idle"; + } + private hydrateVisibleCardTypeCaches( ankiModelFieldCache: AnkiHeadingSyncPlugin["settings"]["ankiModelFieldCache"] = this.plugin.settings.ankiModelFieldCache, ): void { @@ -954,6 +1067,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { loadedFieldNames: [...mapping.loadedFieldNames], }; + this.cardTypeStatusOverride = null; this.draftMappings[mappingKey] = nextMapping; await this.plugin.updateSettings({ noteFieldMappings: { @@ -1345,6 +1459,31 @@ function areMappingsEqual(left: NoteModelFieldMapping | undefined, right: NoteMo return JSON.stringify(left) === JSON.stringify(right); } +function normalizeNoteTypeNames(noteModels: string[]): string[] { + const normalizedNoteModels = new Set(); + + for (const noteModel of noteModels) { + if (typeof noteModel !== "string") { + continue; + } + + const trimmedNoteModel = noteModel.trim(); + if (trimmedNoteModel.length > 0) { + normalizedNoteModels.add(trimmedNoteModel); + } + } + + return [...normalizedNoteModels].sort((left, right) => left.localeCompare(right)); +} + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +} + function getScopeModeTreeDescription(scopeMode: ScopeMode): string { if (scopeMode === "include") { return t("settings.scope.treeDescription.include");