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:
Dusk 2026-04-25 09:53:30 +08:00
parent 3d82d7fb55
commit 3136f50624
18 changed files with 1068 additions and 30 deletions

View 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. 与计划的受控偏离
- 偏离 1source modelName 不放进 gateway 合同。
- 原因:这是业务上下文,不是 transport 层必需输入。
- 偏离 2第一版不会增加单独的“迁移开始提示” notice。
- 原因:当前 NoticeService 是结果汇总型通知,不适合在每张卡迁移前弹增量日志。
- 保留在错误和最终 summary 中呈现迁移信息即可。
- 偏离 3普通卡迁移继续沿用现有 changeDecks 批处理,而不是把 deckName 塞进 updateNoteModel。
- 原因:当前仓库 deck 迁移本来就是独立后处理,复用现有路径风险最小。

View 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。

View 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);
}

View file

@ -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>;

View file

@ -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,

View file

@ -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) => ({

View file

@ -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");

View file

@ -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,

View file

@ -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);

View file

@ -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,
};
}

View file

@ -5,6 +5,7 @@ export interface ManualSyncResult {
scannedCards: number;
created: number;
updated: number;
migratedNoteTypes: number;
migratedDecks: number;
orphaned: number;
uploadedMedia: number;

View file

@ -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: {

View file

@ -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);
}

View file

@ -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.",

View file

@ -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 文件不存在。",

View file

@ -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.",
]);

View file

@ -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,

View file

@ -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);
}