mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: 优化设置页模板缓存状态 / improve settings template cache status
- 设置页在有缓存时立即显示缓存摘要,并在打开时只做一次轻量 note type freshness 检查 - 手动读取仍负责完整模板与字段刷新,不会在页面打开时读取所有字段 - Add tests and docs for cache summary, stale-cache warning, and failed background checks
This commit is contained in:
parent
d46ddb431d
commit
ffad94dd18
6 changed files with 516 additions and 11 deletions
108
docs/settings-anki-template-cache-status-decisions.md
Normal file
108
docs/settings-anki-template-cache-status-decisions.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 设置页 Anki 模板缓存状态实现决策
|
||||
|
||||
本文锁定本轮“设置页模板缓存状态 + 后台轻量 freshness 检测”的仓库兼容实现方案。
|
||||
|
||||
## 1. 作用域决策
|
||||
|
||||
- 只改设置页 card 1 的状态显示与轻量检测。
|
||||
- 不改同步主线。
|
||||
- 不新增按需字段读取。
|
||||
- 保留现有 manual full refresh 行为。
|
||||
|
||||
## 2. 缓存状态模型决策
|
||||
|
||||
- 在 `PluginSettingTab` 中新增实例级状态:
|
||||
- `noteTypeCacheCheckStatus: "idle" | "checking" | "same" | "changed" | "failed"`
|
||||
- `noteTypeCacheCheckPromise: Promise<void> | 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 个模板”
|
||||
131
docs/settings-anki-template-cache-status-gap-report.md
Normal file
131
docs/settings-anki-template-cache-status-gap-report.md
Normal file
|
|
@ -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` 和字段缓存。
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 读取模板或字段失败。",
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
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<Record<string, string[]>> {
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<void> | 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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
|
||||
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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue