mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: automatic Anki note type migration during sync
- Support for QA Group and normal cards migration. - Preservation of noteId and learning history. - 自动同步过程中的 Anki 笔记类型迁移 - 支持问答组和普通卡片的迁移 - 保留笔记 ID 和学习历史记录
This commit is contained in:
parent
3d82d7fb55
commit
3136f50624
18 changed files with 1068 additions and 30 deletions
171
docs/note-type-migration-decisions.md
Normal file
171
docs/note-type-migration-decisions.md
Normal file
|
|
@ -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 迁移本来就是独立后处理,复用现有路径风险最小。
|
||||
185
docs/note-type-migration-gap-report.md
Normal file
185
docs/note-type-migration-gap-report.md
Normal file
|
|
@ -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。
|
||||
49
src/application/errors/noteTypeMigration.ts
Normal file
49
src/application/errors/noteTypeMigration.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ export interface UpdateAnkiNoteInput {
|
|||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UpdateAnkiNoteModelInput {
|
||||
noteId: number;
|
||||
modelName: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SyncAnkiNoteTagsInput {
|
||||
noteId: number;
|
||||
addTags: string[];
|
||||
|
|
@ -68,6 +74,7 @@ export interface AnkiGateway {
|
|||
addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
|
||||
updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void>;
|
||||
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void>;
|
||||
syncNoteTags(inputs: SyncAnkiNoteTagsInput[]): Promise<void>;
|
||||
changeDecks(inputs: ChangeDeckInput[]): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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<br><br>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,
|
||||
|
|
|
|||
|
|
@ -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<string, PlannedCard>();
|
||||
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<ReturnType<AnkiGateway["getNoteSummaries"]>>[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) => ({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
|
|
@ -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<AnkiNoteDetails | undefined> {
|
||||
private async tryLoadQaGroupNote(noteId: number, _block: IndexedGroupCardBlock, modelName: string): Promise<LoadedQaGroupNote> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface ManualSyncResult {
|
|||
scannedCards: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
migratedNoteTypes: number;
|
||||
migratedDecks: number;
|
||||
orphaned: number;
|
||||
uploadedMedia: number;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
|
|
@ -327,6 +327,23 @@ export class AnkiConnectGateway implements AnkiGroupGateway {
|
|||
}
|
||||
}
|
||||
|
||||
async updateNoteModel(input: UpdateAnkiNoteModelInput): Promise<void> {
|
||||
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<void> {
|
||||
await this.invokeMulti<void>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 文件不存在。",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function createManualSyncResult(overrides: Partial<ManualSyncResult> = {}): 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.",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, AnkiModelTemplate>> = {};
|
||||
public modelStylingByName: Record<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.updatedNotes.push(...inputs);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue