diff --git a/docs/note-type-migration-decisions.md b/docs/note-type-migration-decisions.md new file mode 100644 index 0000000..7ef8d80 --- /dev/null +++ b/docs/note-type-migration-decisions.md @@ -0,0 +1,171 @@ +# Anki Note Type 自动迁移实现决策 + +本文锁定本轮“同步时自动原地迁移 Anki note type”的仓库兼容实现方案。 + +## 1. 作用域决策 + +- 覆盖两条同步路径: + - 普通卡 AnkiBatchExecutor + - QA Group QaGroupSyncService +- 不改 PluginState schema。 +- 不引入“迁移失败时自动新建替代 note”的 fallback。 + +## 2. Gateway 合同决策 + +- 在 src/application/ports/AnkiGateway.ts 新增: + - UpdateAnkiNoteModelInput + - updateNoteModel(input) +- 合同保持最小化: + - noteId + - modelName + - fields +- 不把 source modelName 放进 gateway 入参。 + +原因:source model 是调用方上下文,不是 AnkiConnect action 的必要输入;保持合同和计划中的最小 payload 一致更稳妥。 + +## 3. AnkiConnect payload 决策 + +- updateNoteModel 固定调用 action = updateNoteModel。 +- params 结构固定为: + - note.id + - note.modelName + - note.fields +- 不从旧 note 读取字段并做转换。 + +## 4. 错误上下文决策 + +- Gateway 层只保证: + - action 名 + - noteId + - target modelName + - 原始 AnkiConnect reason +- source modelName 在服务层补上,并最终转成 PluginUserError。 + +这是对计划的受控偏离: + +- 计划要求 gateway 直接保留 fromModel/toModel。 +- 仓库现实是 gateway 合同并不知道当前旧模型来自哪条业务路径。 +- 因此 fromModel 放在调用层补齐,能保持接口最小且不引入只为错误文案存在的冗余入参。 + +## 5. QA Group note load 决策 + +- 新增显式加载结果类型: + - missing + - matching + - model-mismatch +- tryLoadQaGroupNote() 不再在 model mismatch 时直接抛普通 Error。 + +## 6. QA Group 迁移流程决策 + +- 先按当前 Markdown 和当前 QaGroupFieldMapping 生成目标 fields。 +- 容量校验继续使用: + - requiredSlots = 当前 block.items.length 或恢复后 resolvedItems.length + - targetCapacity = mapping.slots.length +- 如果已有 noteId: + - missing -> addNote + - matching -> 保持现有 updateNote 逻辑 + - model-mismatch -> updateNoteModel +- 迁移成功后: + - noteId 保持不变 + - tags 继续按当前 diff 同步 + - deck 继续按当前逻辑迁移 + - marker/state 继续按当前成功路径写回 +- 迁移失败后: + - 不 addNote + - 不删旧 note + - 不写 marker + - 不更新 syncedGroupBlocks + - 直接抛 PluginUserError + +## 7. QA Group 字段写入决策 + +- 继续复用现有 buildQaGroupNoteFields() 语义: + - titleField 写当前 stem + - 已映射 question/answer slot 先清空 + - 已使用 slot 写当前 Markdown 内容 + - legacy internal fields 仅在旧托管模型字段存在时清空 +- 不写: + - GroupId + - Src + - Sxx_Id + +## 8. 普通卡迁移流程决策 + +- AnkiBatchExecutor 新增 modelMigrationQueue。 +- 现有 toUpdate 分流规则改为: + - noteId 不存在或 summary 缺失 -> addQueue + - summary.modelName === renderedCard.noteModel -> updateQueue + - summary.modelName !== renderedCard.noteModel -> modelMigrationQueue +- modelMigrationQueue 仍复用当前 mapFields(),即: + - 当前 rendered noteModel + - 当前 NoteFieldMappingService.mapRenderedCard() +- 不允许为 mismatch 回退到 addNote。 + +## 9. 普通卡 deck/tag 决策 + +- 普通卡迁移成功后仍复用现有: + - tag diff -> syncNoteTags + - deck diff -> changeDecks +- 不单独为迁移写新的 deck/tag 流程。 + +## 10. 不支持跨卡片类型识别决策 + +- 不尝试判断旧 note 原先属于哪种 Obsidian 卡片类型。 +- 当前行为只看: + - 这次同步解析出的 cardType + - 当前设置选中的 noteModel + - 旧 note summary.modelName 是否相同 +- 如果用户误把错误 noteId 绑定到别的卡片类型: + - 字段映射或 cloze 兼容性校验会阻止同步 + +## 11. Unsupported fallback 决策 + +- 如果 updateNoteModel 调用失败且 reason 显示 action 不存在或不支持: + - 抛 errors.noteTypeMigration.unsupported +- 其它迁移失败: + - 抛 errors.noteTypeMigration.failed +- 两种失败都不回退到 addNote。 + +## 12. 统计决策 + +- 新增 migratedNoteTypes 统计。 +- 向下游透传: + - AnkiBatchExecutionResult + - QaGroupSyncExecutionResult + - ManualSyncResult + - NoticeService sync summary +- migratedNoteTypes 不替代 updated;迁移成功同时计入: + - updated + - migratedNoteTypes + +原因:迁移本质上也是一次内容更新,但单独暴露 migratedNoteTypes 更符合任务目标和用户心智。 + +## 13. 通知文案决策 + +- sync summary 增加 migratedNoteTypes。 +- 文案表达为“迁移模板”而非“迁移牌组”。 +- deck migration 继续保留现有 migratedDecks,避免混淆。 + +## 14. 用户错误文案决策 + +- 新增: + - errors.noteTypeMigration.failed + - errors.noteTypeMigration.unsupported +- failed 文案必须包含: + - noteId + - fromModel + - toModel + - reason +- unsupported 文案必须引导用户: + - 升级 AnkiConnect + - 或清理 noteId 后重同步 + +## 15. 与计划的受控偏离 + +- 偏离 1:source modelName 不放进 gateway 合同。 + - 原因:这是业务上下文,不是 transport 层必需输入。 +- 偏离 2:第一版不会增加单独的“迁移开始提示” notice。 + - 原因:当前 NoticeService 是结果汇总型通知,不适合在每张卡迁移前弹增量日志。 + - 保留在错误和最终 summary 中呈现迁移信息即可。 +- 偏离 3:普通卡迁移继续沿用现有 changeDecks 批处理,而不是把 deckName 塞进 updateNoteModel。 + - 原因:当前仓库 deck 迁移本来就是独立后处理,复用现有路径风险最小。 \ No newline at end of file diff --git a/docs/note-type-migration-gap-report.md b/docs/note-type-migration-gap-report.md new file mode 100644 index 0000000..1e5d03f --- /dev/null +++ b/docs/note-type-migration-gap-report.md @@ -0,0 +1,185 @@ +# Anki Note Type 自动迁移差距审计 + +本文基于当前仓库真实实现,审计“同步时自动原地迁移 Anki note type”的落地差距。 + +## 1. 实际同步主线 + +- 真实入口在 src/application/services/ManualSyncService.ts。 +- 普通卡走: + - DiffPlannerService + - ManualCardRenderer + - AnkiBatchExecutor +- QA Group 走: + - QaGroupSyncService +- 最终状态更新、marker 写回、通知汇总都在 ManualSyncService 收口。 + +这意味着本轮必须分别改普通卡和 QA Group 两条同步路径,不能只改一个共用入口。 + +## 2. 当前 gateway 合同缺少 updateNoteModel + +- src/application/ports/AnkiGateway.ts 当前只有: + - addNote / addNotes + - updateNote / updateNotes + - syncNoteTags + - changeDecks +- 没有 updateNoteModel。 +- src/infrastructure/anki/AnkiConnectGateway.ts 也没有对应实现。 + +结果:当前仓库根本没有“保留 noteId 原地切换 note model”的能力。 + +## 3. 当前 AnkiConnect 错误上下文过弱 + +- AnkiConnectGateway.invoke() 在 parsed.error 存在时直接抛出普通 Error(parsed.error)。 +- 当前错误里没有: + - action 名称 + - noteId + - 目标 modelName + - 调用阶段上下文 + +结果:即便后续接入 updateNoteModel,若不补上下文,用户只能看到原始 AnkiConnect 错误字符串,无法定位是哪张卡、哪个模型迁移失败。 + +## 4. QA Group 当前对 model mismatch 直接抛普通 Error + +- src/application/services/QaGroupSyncService.ts 的 tryLoadQaGroupNote() 里: + - note 不存在 -> 返回 undefined + - note 存在且 modelName 一致 -> 返回 note + - note 存在但 modelName 不一致 -> 直接抛普通 Error + +这与目标方案冲突:目标方案要求 model mismatch 进入“尝试迁移”分支,而不是直接失败。 + +## 5. QA Group 的字段生成和容量判断已基本符合新方案 + +- 当前 QA Group 字段值来源已经是: + - 当前 Markdown 解析结果 + - 当前 QaGroupFieldMapping + - buildQaGroupNoteFields() +- 当前已经: + - 先按 mapping.slots.length 做容量校验 + - 将未使用 slot 字段写空字符串 + - 不依赖旧 Anki 字段做字段转换 + - 不写 GroupId / Src / Sxx_Id + - 只在旧托管模型字段存在时清空 legacy internal fields + +这说明 QA Group 迁移不需要重做字段生成规则,只需把 mismatch 分支接到 updateNoteModel。 + +## 6. QA Group 当前 update/create 决策 + +- 现有逻辑是: + - noteId 不存在 -> addNote + - noteId 存在但 Anki 中缺失 -> addNote + - noteId 存在且 model 匹配 -> 对比 fields/deck/tags 后 updateNote / syncNoteTags +- 当前完全没有: + - noteId 存在且 model 不匹配 -> updateNoteModel + +因此 QA Group 迁移接入点非常明确:现有“已有 noteId”分支中,fields 已经先生成完成,只差把 mismatch 当作第三种状态处理。 + +## 7. 普通卡当前只按 noteId 是否存在决定 add 还是 update + +- src/application/services/AnkiBatchExecutor.ts 当前会先批量读取 getNoteSummaries(noteIds)。 +- 但 toUpdate 分支只检查: + - plannedCard.noteId 存在 + - noteSummariesById.has(noteId) +- 满足后就直接进入 updateQueue。 +- 后续 updateQueue 统一调用 updateNotes,不检查 summary.modelName 是否等于 renderedCard.noteModel。 + +这与目标方案冲突:普通卡当前会在 model mismatch 时错误地走 updateNoteFields,而不是做 model migration。 + +## 8. 普通卡目标字段生成已经可复用 + +- AnkiBatchExecutor.mapFields() 已经统一通过: + - getModelDetails(renderedCard.noteModel) + - NoteFieldMappingService.mapRenderedCard(...) +- NoteFieldMappingService 当前已经会: + - 用当前 noteModel 去查 settings 里的字段映射 + - 在字段映射不完整、字段不存在、cloze 不兼容时抛 PluginUserError + +这说明普通卡迁移时无需引入旧 Anki 字段转换;直接复用现有 mapFields 即可满足“当前 Markdown + 当前设置映射是唯一权威”。 + +## 9. 普通卡当前 deck/tag 迁移逻辑已独立存在 + +- 普通卡路径中: + - fields 更新走 updateNotes + - tags 更新走 syncNoteTags + - deck 更新走 changeDecks +- deck 迁移不和 updateNotes 绑定,而是通过 note summary 里的 cardIds 单独批量 changeDeck。 + +这意味着普通卡 model migration 成功后,不需要重做 deck/tag 逻辑;只要保留现有后续队列即可。 + +## 10. QA Group 当前 deck/tag/state/marker 流程也能复用 + +- QA Group 当前在同一个循环里: + - 先生成 fields + - 再比较 fields/deck/tags + - 然后 updateNote / syncNoteTags + - 再写 markerWrites + - 再生成 syncedGroupBlocks +- state 和 marker 都依赖 noteId,但目标方案要求 noteId 保持不变。 + +这意味着 QA Group 迁移成功后,当前 state/marker 写回结构本身不需要改 schema,只要确保失败时不要继续写 marker 和 syncedGroupBlocks。 + +## 11. 当前结果汇总里没有 note type migration 统计 + +- 当前只有: + - AnkiBatchExecutionResult.updated + - QaGroupSyncExecutionResult.updated + - ManualSyncResult.updated + - migratedDecks +- NoticeService 也只展示 migratedDecks,不展示 migratedNoteTypes。 + +因此若要把 note type migration 作为独立统计透出,需要沿着: + - AnkiBatchExecutionResult + - QaGroupSyncExecutionResult + - ManualSyncResult + - NoticeService + - notice i18n + 一路补字段。 + +## 12. 当前 i18n 里缺少 note type migration 错误键 + +- 现有字段映射错误和设置错误已经比较完整。 +- 但 src/presentation/i18n/messages/zh.ts 和 src/presentation/i18n/messages/en.ts 当前没有: + - errors.noteTypeMigration.failed + - errors.noteTypeMigration.unsupported + +结果:如果实现迁移失败或 AnkiConnect 不支持该 action,目前没有对应的用户可读错误出口。 + +## 13. 当前测试面锁定了旧行为 + +- AnkiConnectGateway.test.ts 还没有 updateNoteModel 覆盖。 +- AnkiBatchExecutor.test.ts 当前只覆盖: + - getModelDetails 缓存 + - deck 迁移 + - tag 同步 + - create/update 常规流 +- QaGroupSyncService.test.ts 当前只覆盖: + - create + - matching model update + - missing note recreate + - tag/deck/backlink/slot 逻辑 +- FakeManualSyncAnkiGateway 也还没有 updateNoteModel 的记录能力。 + +这意味着本轮必须先扩测试基座,再新增 mismatch 分支用例,否则无法验证行为。 + +## 14. 结论 + +当前仓库与目标方案的主要差距有五类: + +1. Gateway 合同没有 updateNoteModel。 +2. QA Group 在 model mismatch 时直接抛普通 Error。 +3. 普通卡在 model mismatch 时仍会走 updateNotes。 +4. i18n 和错误流缺少 note type migration 专用错误。 +5. 结果汇总和测试基座都还不知道 migrated note types。 + +当前仓库里已可直接复用的基础设施也很明确: + +1. 普通卡和 QA Group 都已用当前 Markdown + 当前字段映射生成目标 fields。 +2. QA Group 容量校验已经按目标 mapping.slots.length 执行。 +3. state、marker、deck、tag 的成功路径都已存在,不需要改 schema。 + +因此最安全的仓库兼容路径是: + +1. 新增 updateNoteModel gateway 能力。 +2. 在 QaGroupSyncService 把 note load 结果拆成 missing / matching / model-mismatch。 +3. 在 AnkiBatchExecutor 新增 modelMigrationQueue。 +4. 迁移成功后复用现有 tag/deck/state/marker 收尾逻辑。 +5. 迁移失败时抛 PluginUserError,并且不回退到 addNote。 \ No newline at end of file diff --git a/src/application/errors/noteTypeMigration.ts b/src/application/errors/noteTypeMigration.ts new file mode 100644 index 0000000..cfed6ad --- /dev/null +++ b/src/application/errors/noteTypeMigration.ts @@ -0,0 +1,49 @@ +import { PluginUserError } from "@/application/errors/PluginUserError"; + +interface NoteTypeMigrationErrorInput { + noteId: number; + fromModel: string; + toModel: string; + error: unknown; + location?: string; +} + +export function createNoteTypeMigrationError(input: NoteTypeMigrationErrorInput): PluginUserError { + const reason = normalizeNoteTypeMigrationReason(extractErrorMessage(input.error)); + + return new PluginUserError( + isUnsupportedNoteTypeMigrationError(input.error) + ? "errors.noteTypeMigration.unsupported" + : "errors.noteTypeMigration.failed", + { + noteId: input.noteId, + fromModel: input.fromModel, + toModel: input.toModel, + reason, + location: input.location ? ` (${input.location})` : "", + }, + ); +} + +export function isUnsupportedNoteTypeMigrationError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return message.includes("updatenotemodel") + && ( + message.includes("unsupported action") + || message.includes("unknown action") + || message.includes("not supported") + || message.includes("unsupported") + ); +} + +function normalizeNoteTypeMigrationReason(message: string): string { + return message.replace(/^AnkiConnect updateNoteModel failed(?: for note \d+(?: to model .+?)?)?:\s*/i, "").trim(); +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + + return String(error); +} \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 4180eeb..b64aca0 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -14,6 +14,12 @@ export interface UpdateAnkiNoteInput { fields: Record; } +export interface UpdateAnkiNoteModelInput { + noteId: number; + modelName: string; + fields: Record; +} + export interface SyncAnkiNoteTagsInput { noteId: number; addTags: string[]; @@ -68,6 +74,7 @@ export interface AnkiGateway { addNotes(inputs: AddAnkiNoteInput[]): Promise; deleteNotes(noteIds: number[]): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; + updateNoteModel(input: UpdateAnkiNoteModelInput): Promise; updateNotes(inputs: UpdateAnkiNoteInput[]): Promise; syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise; changeDecks(inputs: ChangeDeckInput[]): Promise; diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index ed88d1d..609b807 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -193,14 +193,198 @@ describe("AnkiBatchExecutor", () => { expect(ankiGateway.syncedNoteTags).toEqual([]); }); + + it("migrates a basic note in place when the current note model changed", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"], isCloze: false }; + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Old Basic", + cardIds: [700], + tags: ["old"], + }); + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-migrate-basic", 300, "Obsidian", "New Basic", "basic"); + + const result = await executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + { + ...createModule3Settings().noteFieldMappings, + "basic:New Basic": { + cardType: "basic", + modelName: "New Basic", + loadedFieldNames: ["Front", "Back"], + titleField: "Front", + bodyField: "Back", + loadedAt: 1, + }, + }, + ); + + expect(result.updated).toBe(1); + expect(result.migratedNoteTypes).toBe(1); + expect(ankiGateway.addedNotes).toEqual([]); + expect(ankiGateway.updatedNotes).toEqual([]); + expect(ankiGateway.updatedNoteModels).toEqual([ + { + noteId: 300, + modelName: "New Basic", + fields: { + Front: "Heading sync-migrate-basic", + Back: "Body sync-migrate-basic", + }, + }, + ]); + }); + + it("migrates a cloze note in place when the current note model changed", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.modelDetailsByName["New Cloze"] = { fieldNames: ["Text", "Extra"], isCloze: true }; + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Old Cloze", + cardIds: [700], + }); + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-migrate-cloze", 300, "Obsidian", "New Cloze", "cloze"); + + const result = await executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + { + ...createModule3Settings().noteFieldMappings, + "cloze:New Cloze": { + cardType: "cloze", + modelName: "New Cloze", + loadedFieldNames: ["Text", "Extra"], + mainField: "Text", + loadedAt: 1, + }, + }, + ); + + expect(result.updated).toBe(1); + expect(result.migratedNoteTypes).toBe(1); + expect(ankiGateway.updatedNoteModels).toEqual([ + { + noteId: 300, + modelName: "New Cloze", + fields: { + Text: "Heading sync-migrate-cloze

Body sync-migrate-cloze", + }, + }, + ]); + }); + + it("keeps the current updateNotes path when the existing and target note models already match", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Basic", + cardIds: [700], + }); + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-update-basic", 300); + + const result = await executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + createModule3Settings().noteFieldMappings, + ); + + expect(result.updated).toBe(1); + expect(result.migratedNoteTypes).toBe(0); + expect(ankiGateway.updatedNotes).toHaveLength(1); + expect(ankiGateway.updatedNoteModels).toEqual([]); + }); + + it("surfaces the existing mapping error instead of migrating when the target mapping is incomplete", async () => { + const ankiGateway = new CountingAnkiGateway(); + ankiGateway.modelDetailsByName["New Basic"] = { fieldNames: ["Front", "Back"], isCloze: false }; + ankiGateway.noteSummariesById.set(300, { + noteId: 300, + modelName: "Old Basic", + cardIds: [700], + }); + const executor = new AnkiBatchExecutor(ankiGateway); + const updateCard = createPlannedCard("sync-migrate-error", 300, "Obsidian", "New Basic", "basic"); + + await expect(executor.execute( + { + toCreate: [], + toUpdate: [updateCard], + toVerifyDeck: [updateCard], + toChangeDeck: [], + toRewriteMarker: [], + toOrphan: [], + unchangedCards: 0, + warnings: [], + }, + new Map([[updateCard.card.syncKey, createRenderedSyncCard(updateCard)]]), + async (plannedCard) => createRenderedSyncCard(plannedCard), + { + ...createModule3Settings().noteFieldMappings, + "basic:New Basic": { + cardType: "basic", + modelName: "New Basic", + loadedFieldNames: ["Front", "Back"], + titleField: "Front", + loadedAt: 1, + } as never, + }, + )).rejects.toMatchObject({ + userMessage: { + key: "errors.noteFieldMapping.incompleteSavedMapping.basic", + }, + }); + + expect(ankiGateway.updatedNoteModels).toEqual([]); + }); }); -function createPlannedCard(syncKey: string, noteId?: number, deck = "Obsidian"): PlannedCard { +function createPlannedCard( + syncKey: string, + noteId?: number, + deck = "Obsidian", + noteModel = "Basic", + cardType: IndexedCard["cardType"] = "basic", +): PlannedCard { return { - card: createIndexedCard(syncKey, noteId), + card: createIndexedCard(syncKey, noteId, cardType), noteId, deck, - noteModel: "Basic", + noteModel, renderConfigHash: "render-config", }; } @@ -220,14 +404,14 @@ function createRenderedSyncCard(plannedCard: PlannedCard): RenderedSyncCard { }; } -function createIndexedCard(syncKey: string, noteId?: number): IndexedCard { +function createIndexedCard(syncKey: string, noteId?: number, cardType: IndexedCard["cardType"] = "basic"): IndexedCard { return { noteId, syncKey, idMarkerState: noteId ? "present-valid" : "missing", noteIdSource: noteId ? "marker" : undefined, filePath: "notes/example.md", - cardType: "basic", + cardType, heading: `Heading ${syncKey}`, backlinkHeadingText: `Heading ${syncKey}`, headingLevel: 4, diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index 13e19da..42b8de2 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -1,4 +1,5 @@ import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +import { createNoteTypeMigrationError } from "@/application/errors/noteTypeMigration"; import type { AnkiGateway } from "@/application/ports/AnkiGateway"; import { BatchScheduler } from "@/application/services/BatchScheduler"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; @@ -10,6 +11,7 @@ import type { PlannedCard, ManualSyncPlan } from "@/domain/manual-sync/value-obj export interface AnkiBatchExecutionResult { created: number; updated: number; + migratedNoteTypes: number; migratedDecks: number; uploadedMedia: number; markerWrites: PlannedCard[]; @@ -36,6 +38,7 @@ export class AnkiBatchExecutor { const markerWriteMap = new Map(); let created = 0; let updated = 0; + let migratedNoteTypes = 0; let migratedDecks = 0; const summaryIds = Array.from(new Set([ @@ -49,6 +52,11 @@ export class AnkiBatchExecutor { const addQueue: RenderedSyncCard[] = []; const updateQueue: Array<{ plannedCard: PlannedCard; renderedCard: RenderedSyncCard }> = []; + const modelMigrationQueue: Array<{ + plannedCard: PlannedCard; + renderedCard: RenderedSyncCard; + summary: Awaited>[number]; + }> = []; const changeDeckQueue: PlannedCard[] = []; const explicitDeckChangeSyncKeys = new Set(plan.toChangeDeck.map((plannedCard) => plannedCard.card.syncKey)); @@ -58,7 +66,14 @@ export class AnkiBatchExecutor { for (const plannedCard of plan.toUpdate) { if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) { - updateQueue.push({ plannedCard, renderedCard: await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand) }); + const renderedCard = await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand); + const summary = noteSummariesById.get(plannedCard.noteId); + + if (summary && summary.modelName !== renderedCard.noteModel) { + modelMigrationQueue.push({ plannedCard, renderedCard, summary }); + } else { + updateQueue.push({ plannedCard, renderedCard }); + } continue; } @@ -109,7 +124,11 @@ export class AnkiBatchExecutor { (batch) => this.ankiGateway.ensureDecks(batch), ); - const uploadedMedia = await this.uploadMedia([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)]); + const uploadedMedia = await this.uploadMedia([ + ...addQueue, + ...updateQueue.map((entry) => entry.renderedCard), + ...modelMigrationQueue.map((entry) => entry.renderedCard), + ]); const addedNoteIds = await this.batchScheduler.runCollectBatches(addQueue, 50, 1, async (batch) => { return this.ankiGateway.addNotes( @@ -150,7 +169,29 @@ export class AnkiBatchExecutor { ); }); - const tagSyncQueue = updateQueue.flatMap(({ plannedCard }) => { + await this.batchScheduler.runVoidBatches(modelMigrationQueue, 25, 1, async (batch) => { + for (const { plannedCard, renderedCard, summary } of batch) { + const fields = await this.mapFields(renderedCard, noteFieldMappings, modelDetailsCache); + + try { + await this.ankiGateway.updateNoteModel({ + noteId: plannedCard.noteId ?? 0, + modelName: renderedCard.noteModel, + fields, + }); + } catch (error) { + throw createNoteTypeMigrationError({ + noteId: plannedCard.noteId ?? 0, + fromModel: summary.modelName, + toModel: renderedCard.noteModel, + error, + location: `${plannedCard.card.filePath}:${plannedCard.card.blockStartLine}`, + }); + } + } + }); + + const tagSyncQueue = [...updateQueue, ...modelMigrationQueue].flatMap(({ plannedCard }) => { if (!plannedCard.noteId) { return []; } @@ -174,8 +215,9 @@ export class AnkiBatchExecutor { await this.batchScheduler.runVoidBatches(tagSyncQueue, 50, 1, (batch) => this.ankiGateway.syncNoteTags(batch)); - updated += updateQueue.length; - for (const { plannedCard } of updateQueue) { + updated += updateQueue.length + modelMigrationQueue.length; + migratedNoteTypes = modelMigrationQueue.length; + for (const { plannedCard } of [...updateQueue, ...modelMigrationQueue]) { if (plannedCard.noteId) { touchedSyncKeys.add(plannedCard.card.syncKey); resolvedNoteIds.set(plannedCard.card.syncKey, plannedCard.noteId); @@ -220,6 +262,7 @@ export class AnkiBatchExecutor { return { created, updated, + migratedNoteTypes, migratedDecks, uploadedMedia, markerWrites: Array.from(markerWriteMap.values()).map((plannedCard) => ({ diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 379bba4..ab2def7 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -22,6 +22,7 @@ describe("ManualSyncService", () => { expect(result.created).toBe(1); expect(result.updated).toBe(0); + expect(result.migratedNoteTypes).toBe(0); expect(result.migratedDecks).toBe(0); expect(result.warnings).toEqual([]); expect(ankiGateway.addedNotes[0]?.deckName).toBe("notes"); diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index 821df8e..7ea5f8a 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -127,6 +127,7 @@ export class ManualSyncService { scannedCards: indexResult.cards.length + indexResult.groupBlocks.length, created: 0, updated: 0, + migratedNoteTypes: 0, migratedDecks: 0, orphaned: plan.toOrphan.length + orphanGroupBlocks.length, uploadedMedia: 0, @@ -200,6 +201,7 @@ export class ManualSyncService { scannedCards: indexResult.cards.length + indexResult.groupBlocks.length, created: executionResult.created + qaGroupExecution.created, updated: executionResult.updated + qaGroupExecution.updated, + migratedNoteTypes: executionResult.migratedNoteTypes + qaGroupExecution.migratedNoteTypes, migratedDecks: executionResult.migratedDecks + qaGroupExecution.migratedDecks, orphaned: plan.toOrphan.length + orphanGroupBlocks.length, uploadedMedia: executionResult.uploadedMedia, diff --git a/src/application/services/QaGroupSyncService.test.ts b/src/application/services/QaGroupSyncService.test.ts index b59efb1..84147ef 100644 --- a/src/application/services/QaGroupSyncService.test.ts +++ b/src/application/services/QaGroupSyncService.test.ts @@ -393,6 +393,189 @@ describe("QaGroupSyncService", () => { ]); }); + it("migrates an existing QA Group note in place when the bound note model changed", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + const vaultGateway = new FakeManualSyncVaultGateway(); + ankiGateway.noteDetailsById.set(42, { + noteId: 42, + modelName: "旧问答模板", + cardIds: [7001], + deckNames: ["notes"], + tags: ["old"], + fields: { + 题目: "旧题目", + 问题01: "旧问题", + 答案01: "旧答案", + }, + }); + const service = new QaGroupSyncService( + ankiGateway, + undefined, + undefined, + undefined, + () => 1234, + () => "group-1", + () => "item-1", + vaultGateway.createBacklink.bind(vaultGateway), + ); + + const result = await service.sync([ + createIndexedGroupBlock({ + noteId: 42, + groupId: "group-1", + tagsHint: ["fresh"], + }), + ], createEmptyPluginState(), createModule3Settings()); + + expect(result.updated).toBe(1); + expect(result.migratedNoteTypes).toBe(1); + expect(result.resolvedNoteIds.get("notes/example.md\u0000group\u00001\u0000hash-1")).toBe(42); + expect(ankiGateway.addedNotes).toEqual([]); + expect(ankiGateway.updatedNotes).toEqual([]); + expect(ankiGateway.updatedNoteModels).toEqual([ + { + noteId: 42, + modelName: QA_GROUP_USER_NOTE_TYPE, + fields: expect.objectContaining({ + 题目: "Concepts", + 问题01: "Alpha", + 答案01: expect.stringContaining("First answer"), + 问题02: "Beta", + 答案02: expect.stringContaining("Second answer"), + }), + }, + ]); + expect(ankiGateway.syncedNoteTags).toEqual([ + { + noteId: 42, + addTags: ["fresh"], + removeTags: ["old"], + }, + ]); + }); + + it("migrates into a smaller QA Group template when the current markdown still fits and clears unused slots", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + const vaultGateway = new FakeManualSyncVaultGateway(); + ankiGateway.modelDetailsByName["问答题(6组)"] = { + fieldNames: [ + "题目", + "问题01", "答案01", + "问题02", "答案02", + "问题03", "答案03", + "问题04", "答案04", + "问题05", "答案05", + "问题06", "答案06", + ], + isCloze: false, + }; + ankiGateway.noteDetailsById.set(42, { + noteId: 42, + modelName: "旧问答模板 12 组", + cardIds: [7001], + deckNames: ["notes"], + fields: { 题目: "旧题目" }, + }); + const service = new QaGroupSyncService( + ankiGateway, + undefined, + undefined, + undefined, + () => 1234, + () => "group-1", + () => "item-1", + vaultGateway.createBacklink.bind(vaultGateway), + ); + + const result = await service.sync([ + createIndexedGroupBlock({ + noteId: 42, + groupId: "group-1", + items: [ + { title: "A", answer: "1", ordinalInMarkdown: 1 }, + { title: "B", answer: "2", ordinalInMarkdown: 2 }, + { title: "C", answer: "3", ordinalInMarkdown: 3 }, + { title: "D", answer: "4", ordinalInMarkdown: 4 }, + { title: "E", answer: "5", ordinalInMarkdown: 5 }, + ], + }), + ], createEmptyPluginState(), createModule3Settings({ + cardTypeConfigs: { + ...createModule3Settings().cardTypeConfigs, + "qa-group": { + ...createModule3Settings().cardTypeConfigs["qa-group"], + noteType: "问答题(6组)", + }, + }, + noteFieldMappings: { + ...createModule3Settings().noteFieldMappings, + "qa-group:问答题(6组)": { + cardType: "qa-group", + modelName: "问答题(6组)", + loadedFieldNames: [ + "题目", + "问题01", "答案01", + "问题02", "答案02", + "问题03", "答案03", + "问题04", "答案04", + "问题05", "答案05", + "问题06", "答案06", + ], + titleField: "题目", + slots: [ + { index: 1, questionField: "问题01", answerField: "答案01" }, + { index: 2, questionField: "问题02", answerField: "答案02" }, + { index: 3, questionField: "问题03", answerField: "答案03" }, + { index: 4, questionField: "问题04", answerField: "答案04" }, + { index: 5, questionField: "问题05", answerField: "答案05" }, + { index: 6, questionField: "问题06", answerField: "答案06" }, + ], + warnings: [], + loadedAt: 1, + }, + }, + })); + + expect(result.migratedNoteTypes).toBe(1); + expect(ankiGateway.updatedNoteModels[0]?.modelName).toBe("问答题(6组)"); + expect(ankiGateway.updatedNoteModels[0]?.fields).toMatchObject({ + 题目: "Concepts", + 问题01: "A", + 答案01: expect.stringContaining("1"), + 问题05: "E", + 答案05: expect.stringContaining("5"), + 问题06: "", + 答案06: "", + }); + }); + + it("surfaces a user-facing error when QA Group note type migration fails", async () => { + const ankiGateway = new FakeManualSyncAnkiGateway(); + ankiGateway.updateNoteModelError = new Error("AnkiConnect updateNoteModel failed for note 42 to model 问答题(多级列表): unsupported action"); + ankiGateway.noteDetailsById.set(42, { + noteId: 42, + modelName: "旧问答模板", + cardIds: [7001], + deckNames: ["notes"], + fields: { 题目: "旧题目" }, + }); + const service = new QaGroupSyncService(ankiGateway); + + await expect(service.sync([ + createIndexedGroupBlock({ + noteId: 42, + groupId: "group-1", + }), + ], createEmptyPluginState(), createModule3Settings())).rejects.toMatchObject({ + userMessage: { + key: "errors.noteTypeMigration.unsupported", + }, + }); + + expect(ankiGateway.addedNotes).toEqual([]); + expect(ankiGateway.updatedNoteModels).toEqual([]); + }); + it("rejects a QA Group block when the selected template exposes fewer slots than the block needs", async () => { const ankiGateway = new FakeManualSyncAnkiGateway(); const service = new QaGroupSyncService(ankiGateway); diff --git a/src/application/services/QaGroupSyncService.ts b/src/application/services/QaGroupSyncService.ts index de3c085..c2e2f11 100644 --- a/src/application/services/QaGroupSyncService.ts +++ b/src/application/services/QaGroupSyncService.ts @@ -3,6 +3,7 @@ import MarkdownIt from "markdown-it"; import { createNoteFieldMappingKey, isQaGroupFieldMapping, type QaGroupFieldMapping } from "@/application/config/NoteModelFieldMapping"; import type { PluginSettings } from "@/application/config/PluginSettings"; import { PluginUserError } from "@/application/errors/PluginUserError"; +import { createNoteTypeMigrationError } from "@/application/errors/noteTypeMigration"; import type { AnkiGroupGateway, AnkiNoteDetails } from "@/application/ports/AnkiGateway"; import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; import { buildGroupSrc, type GroupItem, type IndexedGroupCardBlock } from "@/domain/manual-sync/entities/IndexedGroupCardBlock"; @@ -30,6 +31,7 @@ const HIGHLIGHT_PATTERN = /==(.+?)==/g; export interface QaGroupSyncExecutionResult { created: number; updated: number; + migratedNoteTypes: number; migratedDecks: number; markerWrites: GroupMarkerWriteRequest[]; resolvedNoteIds: Map; @@ -49,10 +51,15 @@ interface RecoveredGroupRecord { groupId?: string; items: GroupItem[]; freeSlots: number[]; - noteDetails?: AnkiNoteDetails; + loadedNote?: LoadedQaGroupNote; stateRecord?: GroupBlockState; } +type LoadedQaGroupNote = + | { kind: "missing" } + | { kind: "matching"; note: AnkiNoteDetails } + | { kind: "model-mismatch"; note: AnkiNoteDetails; actualModelName: string; expectedModelName: string }; + export class QaGroupSyncService { private sequence = 0; @@ -75,6 +82,7 @@ export class QaGroupSyncService { return { created: 0, updated: 0, + migratedNoteTypes: 0, migratedDecks: 0, markerWrites: [], resolvedNoteIds: new Map(), @@ -95,6 +103,7 @@ export class QaGroupSyncService { const syncedGroupBlocks: GroupBlockState[] = []; let created = 0; let updated = 0; + let migratedNoteTypes = 0; let migratedDecks = 0; const backlinkAnchor = this.buildGroupBacklinkAnchor(blocks[0] ?? null, settings); @@ -157,7 +166,7 @@ export class QaGroupSyncService { legacyInternalFields, ); let noteId = recovered.noteId ?? block.noteId; - let existingNote = recovered.noteDetails; + let loadedNote = recovered.loadedNote; if (noteId === undefined) { noteId = await this.ankiGateway.addNote({ @@ -169,8 +178,8 @@ export class QaGroupSyncService { created += 1; touchedSyncKeys.add(block.syncKey); } else { - existingNote ??= await this.tryLoadQaGroupNote(noteId, block, modelName); - if (!existingNote) { + loadedNote ??= await this.tryLoadQaGroupNote(noteId, block, modelName); + if (loadedNote.kind === "missing") { noteId = await this.ankiGateway.addNote({ deckName: deck, modelName, @@ -181,7 +190,8 @@ export class QaGroupSyncService { touchedSyncKeys.add(block.syncKey); } - if (existingNote) { + if (loadedNote.kind === "matching") { + const existingNote = loadedNote.note; const fieldsChanged = !haveEqualFields(existingNote.fields, fields); const deckChanged = !(existingNote.deckNames ?? []).includes(deck); const tagDiff = diffTagSets(block.tagsHint, existingNote.tags); @@ -247,6 +257,48 @@ export class QaGroupSyncService { } touchedSyncKeys.add(block.syncKey); } + + if (loadedNote.kind === "model-mismatch") { + const deckChanged = !(loadedNote.note.deckNames ?? []).includes(deck); + const tagDiff = diffTagSets(block.tagsHint, loadedNote.note.tags); + const tagsChanged = tagDiff.addTags.length > 0 || tagDiff.removeTags.length > 0; + + try { + await this.ankiGateway.updateNoteModel({ + noteId, + modelName, + fields, + }); + } catch (error) { + throw createNoteTypeMigrationError({ + noteId, + fromModel: loadedNote.actualModelName, + toModel: loadedNote.expectedModelName, + error, + location: `${block.filePath}:${block.blockStartLine}`, + }); + } + + if (deckChanged && loadedNote.note.cardIds.length > 0) { + await this.ankiGateway.changeDecks([{ + deckName: deck, + cardIds: loadedNote.note.cardIds, + }]); + migratedDecks += 1; + } + + if (tagsChanged) { + await this.ankiGateway.syncNoteTags([{ + noteId, + addTags: tagDiff.addTags, + removeTags: tagDiff.removeTags, + }]); + } + + updated += 1; + migratedNoteTypes += 1; + touchedSyncKeys.add(block.syncKey); + } } resolvedNoteIds.set(block.syncKey, noteId); @@ -310,6 +362,7 @@ export class QaGroupSyncService { return { created, updated, + migratedNoteTypes, migratedDecks, markerWrites, resolvedNoteIds, @@ -341,10 +394,31 @@ export class QaGroupSyncService { } if (block.noteId !== undefined) { - const note = await this.tryLoadQaGroupNote(block.noteId, block, modelName); - if (note) { - return noteDetailsToRecoveredGroup(note, mapping, availableSlots, block.groupId); + const loadedNote = await this.tryLoadQaGroupNote(block.noteId, block, modelName); + if (loadedNote.kind === "matching") { + return { + ...noteDetailsToRecoveredGroup(loadedNote.note, mapping, availableSlots, block.groupId), + loadedNote, + }; } + + if (loadedNote.kind === "model-mismatch") { + return { + noteId: loadedNote.note.noteId, + groupId: block.groupId, + items: [], + freeSlots: [...availableSlots], + loadedNote, + }; + } + + return { + noteId: block.noteId, + groupId: block.groupId, + items: [], + freeSlots: [...availableSlots], + loadedNote, + }; } return { @@ -353,17 +427,25 @@ export class QaGroupSyncService { }; } - private async tryLoadQaGroupNote(noteId: number, block: IndexedGroupCardBlock, modelName: string): Promise { + private async tryLoadQaGroupNote(noteId: number, _block: IndexedGroupCardBlock, modelName: string): Promise { const note = (await this.ankiGateway.getNoteDetails([noteId]))[0]; if (!note) { - return undefined; + return { kind: "missing" }; } if (note.modelName !== modelName) { - throw new Error(`Note ${noteId} for ${block.filePath}:${block.blockStartLine} is ${note.modelName}, expected ${modelName}.`); + return { + kind: "model-mismatch", + note, + actualModelName: note.modelName, + expectedModelName: modelName, + }; } - return note; + return { + kind: "matching", + note, + }; } private buildGroupBacklinkAnchor(block: IndexedGroupCardBlock | null, settings: PluginSettings): string { @@ -530,7 +612,6 @@ function noteDetailsToRecoveredGroup( groupId: fallbackGroupId, items, freeSlots: availableSlots.filter((slot) => !occupiedSlots.has(slot)), - noteDetails: note, }; } diff --git a/src/application/use-cases/manualSyncTypes.ts b/src/application/use-cases/manualSyncTypes.ts index 107b66e..eb080ce 100644 --- a/src/application/use-cases/manualSyncTypes.ts +++ b/src/application/use-cases/manualSyncTypes.ts @@ -5,6 +5,7 @@ export interface ManualSyncResult { scannedCards: number; created: number; updated: number; + migratedNoteTypes: number; migratedDecks: number; orphaned: number; uploadedMedia: number; diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index a2d2cb8..34c4ca4 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -340,6 +340,61 @@ describe("AnkiConnectGateway", () => { }); }); + it("updates a note model with the target fields payload", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.updateNoteModel({ + noteId: 42, + modelName: "问答题(多级列表)", + fields: { + 题目: "概念", + 问题01: "Alpha", + 答案01: "First answer", + }, + }); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "updateNoteModel", + version: 6, + params: { + note: { + id: 42, + modelName: "问答题(多级列表)", + fields: { + 题目: "概念", + 问题01: "Alpha", + 答案01: "First answer", + }, + }, + }, + }); + }); + + it("preserves note context when updateNoteModel fails", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: "unsupported action", + result: null, + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + + await expect(gateway.updateNoteModel({ + noteId: 42, + modelName: "问答题(多级列表)", + fields: { + 题目: "概念", + }, + })).rejects.toThrow("AnkiConnect updateNoteModel failed for note 42 to model 问答题(多级列表): unsupported action"); + }); + it("syncs note tags by removing obsolete tags before adding current tags", async () => { requestUrlMock.mockResolvedValue({ json: { diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index 105a8b4..024d3e7 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -1,7 +1,7 @@ import { requestUrl } from "obsidian"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway"; import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; interface AnkiResponse { @@ -327,6 +327,23 @@ export class AnkiConnectGateway implements AnkiGroupGateway { } } + async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise { + try { + await this.invoke("updateNoteModel", { + note: { + id: input.noteId, + modelName: input.modelName, + fields: input.fields, + }, + }); + } catch (error) { + throw new Error(buildActionFailureMessage("updateNoteModel", { + noteId: input.noteId, + modelName: input.modelName, + }, error)); + } + } + async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise { await this.invokeMulti(inputs.map((input) => ({ action: "updateNoteFields", @@ -478,3 +495,25 @@ function extractDeckNoteCount(rawStats: unknown, deckId: number | undefined): nu return undefined; } + +function buildActionFailureMessage( + action: string, + context: { noteId?: number; modelName?: string }, + error: unknown, +): string { + const scope = typeof context.noteId === "number" && typeof context.modelName === "string" + ? ` for note ${context.noteId} to model ${context.modelName}` + : typeof context.noteId === "number" + ? ` for note ${context.noteId}` + : ""; + + return `AnkiConnect ${action} failed${scope}: ${extractRequestErrorMessage(error)}`; +} + +function extractRequestErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message.trim(); + } + + return String(error); +} diff --git a/src/presentation/i18n/messages/en.ts b/src/presentation/i18n/messages/en.ts index 32b6aa8..84637d6 100644 --- a/src/presentation/i18n/messages/en.ts +++ b/src/presentation/i18n/messages/en.ts @@ -330,8 +330,8 @@ export const en = { cleanupEmptyDecksCancelled: "Empty-deck cleanup was cancelled.", cleanupEmptyDecksFailed: "Cleanup empty decks failed.", summary: { - currentFileSync: "Current file sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.", - vaultSync: "Vault sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.", + currentFileSync: "Current file sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated note types {{migratedNoteTypes}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.", + vaultSync: "Vault sync completed: files {{scannedFiles}}, cards {{scannedCards}}, created {{created}}, updated {{updated}}, migrated note types {{migratedNoteTypes}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, media {{uploadedMedia}}, skipped {{skippedUnchangedCards}}.", rebuild: "Card index rebuild completed: files {{scannedFiles}}, cards {{scannedCards}}, migrated decks {{migratedDecks}}, orphaned {{orphaned}}, rewritten markers {{rewrittenMarkers}}, skipped {{skippedUnchangedCards}}.", clearCurrentFile: "Current file synced cards cleared: tracked cards {{trackedCards}}, tracked groups {{trackedGroups}}, deleted notes {{deletedNotes}}, removed markers {{removedMarkers}}, removed ID markers {{removedCardMarkers}}, removed GI markers {{removedGroupMarkers}}, deleted local records {{deletedLocalRecords}}.", cleanupEmptyDecks: "Empty-deck cleanup completed: candidates {{candidateCount}}, selected {{selectedCount}}, deleted {{deletedCount}}, skipped {{skippedCount}}.", @@ -429,6 +429,10 @@ export const en = { qaGroupWarningsUnaccepted: "QA Group note type \"{{modelName}}\" still has unconfirmed field warnings. Confirm them in settings before syncing.", qaGroupSlotCapacityExceeded: "QA Group note type \"{{modelName}}\" only exposes {{capacity}} question/answer pair(s), but the current block has {{itemCount}} item(s): {{filePath}}:{{blockStartLine}}.", }, + noteTypeMigration: { + failed: "Failed to migrate Anki note {{noteId}} from \"{{fromModel}}\" to \"{{toModel}}\"{{location}}: {{reason}}", + unsupported: "The current AnkiConnect does not support migrating Anki note {{noteId}} from \"{{fromModel}}\" to \"{{toModel}}\"{{location}}. Upgrade AnkiConnect, or clear the Anki ID and sync again. Reason: {{reason}}", + }, writeBack: { summary: "Markdown marker write-back failed for {{fileCount}} file(s).", markdownFileNotFound: "Markdown file was not found.", diff --git a/src/presentation/i18n/messages/zh.ts b/src/presentation/i18n/messages/zh.ts index a75c814..06fbc73 100644 --- a/src/presentation/i18n/messages/zh.ts +++ b/src/presentation/i18n/messages/zh.ts @@ -328,8 +328,8 @@ export const zh = { cleanupEmptyDecksCancelled: "已取消空牌组清理。", cleanupEmptyDecksFailed: "清理空牌组失败。", summary: { - currentFileSync: "当前文件同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。", - vaultSync: "全库同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。", + currentFileSync: "当前文件同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移模板 {{migratedNoteTypes}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。", + vaultSync: "全库同步完成:文件 {{scannedFiles}},卡片 {{scannedCards}},新建 {{created}},更新 {{updated}},迁移模板 {{migratedNoteTypes}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},媒体 {{uploadedMedia}},跳过 {{skippedUnchangedCards}}。", rebuild: "卡片索引重建完成:文件 {{scannedFiles}},卡片 {{scannedCards}},迁移牌组 {{migratedDecks}},孤儿 {{orphaned}},重写标记 {{rewrittenMarkers}},跳过 {{skippedUnchangedCards}}。", clearCurrentFile: "当前文件已同步卡片清空完成:已跟踪卡片 {{trackedCards}},已跟踪分组 {{trackedGroups}},删除笔记 {{deletedNotes}},移除标记 {{removedMarkers}},移除 ID 标记 {{removedCardMarkers}},移除 GI 标记 {{removedGroupMarkers}},删除本地记录 {{deletedLocalRecords}}。", cleanupEmptyDecks: "空牌组清理完成:候选 {{candidateCount}},已选 {{selectedCount}},已删 {{deletedCount}},跳过 {{skippedCount}}。", @@ -427,6 +427,10 @@ export const zh = { qaGroupWarningsUnaccepted: "问答题(多级列表)笔记模板 \"{{modelName}}\" 仍有未确认的字段警告。请先在设置页确认后再同步。", qaGroupSlotCapacityExceeded: "问答题(多级列表)笔记模板 \"{{modelName}}\" 只识别到 {{capacity}} 组问题/答案字段,但当前块有 {{itemCount}} 项:{{filePath}}:{{blockStartLine}}。", }, + noteTypeMigration: { + failed: "无法将 Anki 笔记 {{noteId}} 从“{{fromModel}}”迁移到“{{toModel}}”{{location}}:{{reason}}", + unsupported: "当前 AnkiConnect 不支持将 Anki 笔记 {{noteId}} 从“{{fromModel}}”迁移到“{{toModel}}”{{location}}。请升级 AnkiConnect,或清理该卡片的 Anki ID 后重新同步。原因:{{reason}}", + }, writeBack: { summary: "Markdown 标记写回失败,共 {{fileCount}} 个文件。", markdownFileNotFound: "Markdown 文件不存在。", diff --git a/src/presentation/notices/NoticeService.test.ts b/src/presentation/notices/NoticeService.test.ts index 58e992f..8568168 100644 --- a/src/presentation/notices/NoticeService.test.ts +++ b/src/presentation/notices/NoticeService.test.ts @@ -37,6 +37,7 @@ function createManualSyncResult(overrides: Partial = {}): Manu scannedCards: 8, created: 2, updated: 1, + migratedNoteTypes: 1, migratedDecks: 1, orphaned: 0, uploadedMedia: 4, @@ -93,7 +94,7 @@ describe("NoticeService", () => { })); expect(noticeRecords.map((entry) => entry.message)).toEqual([ - "Current file sync completed: files 3, cards 8, created 2, updated 1, migrated decks 1, orphaned 0, media 4, skipped 5. Marker write conflicts: notes/a.md, notes/b.md. Warnings: 2.", + "Current file sync completed: files 3, cards 8, created 2, updated 1, migrated note types 1, migrated decks 1, orphaned 0, media 4, skipped 5. Marker write conflicts: notes/a.md, notes/b.md. Warnings: 2.", "Conflicting TARGET DECK declarations were found in YAML and body. This sync used the YAML value.", "No explicit deck was found and a folder-based deck could not be generated, so the default deck was used.", ]); diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 996d560..b64e48e 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -23,6 +23,7 @@ export class NoticeService { scannedCards: result.scannedCards, created: result.created, updated: result.updated, + migratedNoteTypes: result.migratedNoteTypes, migratedDecks: result.migratedDecks, orphaned: result.orphaned, uploadedMedia: result.uploadedMedia, diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 35103ae..6a7f37f 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -1,7 +1,7 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; -import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGroupGateway, AnkiModelTemplate, AnkiNoteDetails, AnkiNoteSummary, ChangeDeckInput, CreateAnkiModelInput, DeckStat, SyncAnkiNoteTagsInput, UpdateAnkiNoteInput, UpdateAnkiNoteModelInput } from "@/application/ports/AnkiGateway"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway"; @@ -142,6 +142,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { public addedNotes: AddAnkiNoteInput[] = []; public deletedNotes: number[][] = []; public updatedNotes: UpdateAnkiNoteInput[] = []; + public updatedNoteModels: UpdateAnkiNoteModelInput[] = []; public syncedNoteTags: SyncAnkiNoteTagsInput[] = []; public changedDecks: ChangeDeckInput[] = []; public deletedDecks: string[][] = []; @@ -166,6 +167,7 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { }; public modelTemplatesByName: Record> = {}; public modelStylingByName: Record = {}; + public updateNoteModelError: Error | null = null; private nextNoteId = 9000; @@ -296,6 +298,31 @@ export class FakeManualSyncAnkiGateway implements AnkiGroupGateway { await this.updateNotes([input]); } + async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise { + if (this.updateNoteModelError) { + throw this.updateNoteModelError; + } + + this.updatedNoteModels.push(input); + + const summary = this.noteSummariesById.get(input.noteId); + if (summary) { + this.noteSummariesById.set(input.noteId, { + ...summary, + modelName: input.modelName, + }); + } + + const detail = this.noteDetailsById.get(input.noteId); + if (detail) { + this.noteDetailsById.set(input.noteId, { + ...detail, + modelName: input.modelName, + fields: { ...input.fields }, + }); + } + } + async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise { this.updatedNotes.push(...inputs); }