fix(deck): migrate existing cards when deck rules change

This commit is contained in:
Dusk 2026-04-18 21:39:15 +08:00
parent 960b87103d
commit bd149b9d11
21 changed files with 636 additions and 85 deletions

View file

@ -196,7 +196,7 @@ TARGET DECK: 数学::第一章
### 同步语义
本轮按最新决策执行:旧卡按现有代码规则执行
本轮按最新决策执行:普通同步会让旧卡按最新 deck 规则重梳理并迁移
这意味着:
@ -205,10 +205,11 @@ TARGET DECK: 数学::第一章
- 若 deck 不存在,先 `ensureDecks`
- 再 `addNotes`
- `toUpdate`
- 保持当前实现行为
- deck 是否触发 update / 是否触发 `changeDeck`,按现有代码规则保留
- 本模块不额外改变旧卡 deck 迁移逻辑
- 本模块的重点是新增 deck 配置、解析与模板插入能力
- 只更新字段
- `toChangeDeck`
- 当已有 note 的 resolved deck 变化时,普通同步通过 `changeDecks` 迁移
- 普通同步会在 deck 规则变化时重新评估未改动文件
- `rebuildIndex` 仍只刷新索引与 marker不负责 Anki deck 迁移
### 当前主链路接入点
@ -219,11 +220,11 @@ TARGET DECK: 数学::第一章
2. `RenderConfigService` 或新增的 `DeckResolutionService`
- 解析最终 `resolvedDeck`
3. `DiffPlannerService`
- 保持当前旧卡 deck 行为,不在本模块内重写
- 产出字段更新与 deck 迁移两类计划
4. `ManualSyncService`
- 汇总 warnings
5. `AnkiBatchExecutor`
- 新卡使用 `resolvedDeck`
- 新卡使用 `resolvedDeck`,旧卡在需要时执行 `changeDecks`
### Warning 结构
@ -287,7 +288,7 @@ UI 层可以额外展示 notice但 warning 不能只存在于 notice。
- 新卡创建使用 `resolvedDeck`
- deck 不存在时先 `ensureDecks`
- warnings 能沿链路出现在最终 sync result
- 旧卡行为不被本模块额外改变,保持当前实现回归通过
- 旧卡在普通同步中会按最新 deck 规则迁移到新的 resolved deck
## Assumptions

View file

@ -168,39 +168,28 @@ Obsidian 文件路径示例:
本轮明确规则:
- deck 会参与新增卡片时的创建
- deck 不参与旧卡的自动移动
- 普通同步会在旧卡 resolved deck 变化时自动迁移到新 deck
也就是:
1. 新卡 add 时写入当前解析出的 deck
2. 已存在 note 的 update 不因为 deck 变化而自动 changeDeck
3. 文件移动、文件夹调整、默认 deck 改变、文件级 deck 改变,都不会主动把旧卡迁移到新 deck
2. 已存在 note 的字段 update 与 deck 迁移分离执行
3. 文件移动、文件夹调整、默认 deck 改变、文件级 deck 改变后,普通同步会按最新规则迁移旧卡到新 deck
实现级硬约束:
1. deck 变化不能单独触发已有 note 进入 `toUpdate`
2. 旧卡 update 路径中禁止因为 resolved deck 变化而执行 `changeDeck`
3. `changeDeck` 只允许用于未来单独的 deck 迁移模块,本轮主链路中不启用
1. deck 变化不能伪装成字段 update需要进入独立的 deck 迁移计划
2. 旧卡字段 update 路径与 `changeDeck` 路径分离执行
3. `changeDeck` 在普通同步主链路中正式启用
4. 新卡 `toCreate` 时,必须使用本轮解析出的最终 `resolvedDeck`
### 3.2 原因
本轮按你的确认执行:
本轮按新的产品要求执行:
- 文件级 deck 最高
- 旧卡不做自动移动
- 先把 deck 解析逻辑与创建行为做稳
- 避免 deck 规则调整后批量移动旧卡带来不可预期后果
### 3.3 后续可扩展方向
后续如果需要,可单独新增:
- `syncDeckOnUpdate`
- `moveExistingNotesToResolvedDeck`
- 显式的 deck 迁移命令
但本轮不做。
1. deck 规则变化后,旧卡也必须在普通同步中按最新规则重梳理
2. 规则变化不应再被“文件未改动”或“兼容 hash”静默吞掉
3. rebuildIndex 仍保持只修索引与 marker不承担迁移职责
## 四、领域与应用层设计
@ -388,14 +377,18 @@ TARGET DECK: 数学::第一章
`toUpdate` 中的卡片:
- 只更新字段
- 不自动移动 deck
deck 迁移由独立的 `toChangeDeck` 路径负责:
1. deck-only 变化进入 `toChangeDeck`
2. 字段变化且 deck 变化时,同时执行 `updateNotes + changeDecks`
补充实现要求:
1. 当前 manual-sync 链路下,旧卡 deck 变化不能仅因为 `deck` 改变而进入 `toUpdate`
2. `renderConfigHash` 不得因为 `deck` 变化而导致旧卡进入 update
3. `AnkiBatchExecutor` 的 update 路径中,不得执行基于 resolved deck 的 `changeDeck`
4. 如果当前代码中仍保留 update -> `changeDeck` 逻辑,本轮必须移除或显式短路掉
1. 当前 manual-sync 链路下,旧卡 deck 变化不能仅因为 `deck` 改变而伪装成 `toUpdate`
2. `renderConfigHash` 不得因为 `deck` 变化而导致旧卡进入字段 update
3. `AnkiBatchExecutor` 必须在独立队列中执行基于 resolved deck 的 `changeDeck`
4. 普通同步必须把规则变化后的旧卡迁移到新 deck
### 7.3 冲突告警
@ -519,9 +512,9 @@ TARGET DECK: 数学::第一章
1. 新卡使用解析出的 deck 创建
2. 缺失 deck 时使用默认 deck
3. deck 不存在时先创建 deck 再 add
4. 旧卡 update 不自动 changeDeck
5. 文件移动后,旧卡仍不自动迁移 deck
6. 文件级 deck 改变后,旧卡仍不自动迁移 deck
4. 旧卡 deck 变化时,普通同步会执行 `changeDeck`
5. 文件移动后,普通同步会把旧卡迁移到新的 resolved deck
6. 文件级 deck 改变后,普通同步会把旧卡迁移到新的 resolved deck
## 十、GitHub Agent 实施顺序
@ -550,7 +543,7 @@ TARGET DECK: 数学::第一章
2. YAML 冲突处理
3. 文件夹映射规则
4. 根目录 fallback
5. update 不自动移动 deck
5. 普通同步如何迁移旧卡 deck
### 阶段 3实现服务层
@ -592,6 +585,6 @@ npm run lint
2. 文件夹可自动映射为 Anki 多级父子牌组
3. 根目录文件正确回退到默认 deck
4. YAML 与正文冲突时YAML 生效且有告警
5. 旧卡 update 不自动移动 deck
5. 旧卡在普通同步中按最新规则迁移 deck
6. 所有关键路径都有测试覆盖
7. build、test、lint 通过

View file

@ -33,9 +33,9 @@
## 4. 同步语义
1. 新卡 add 必须使用 resolved deck
2. 旧卡 update 只更新字段,不 changeDeck
3. `renderConfigHash` 改为不包含 deck
4. 为避免旧状态产生无意义 update保留一个仅用于兼容比较的 legacy hash 分支
2. 普通同步中,旧卡若 resolved deck 变化则执行 `changeDeck`
3. `renderConfigHash` 不包含 deckdeck 迁移由独立计划集合承载
4. rebuildIndex 仍不触发 `changeDeck`
## 5. 校验与错误

View file

@ -0,0 +1,69 @@
# Deck Rule Migration Decisions
## 1. 规则失效策略
新增 `deckRulesFingerprint`,并持久化到 `FileState`
指纹内容固定包含:
1. `defaultDeck`
2. `fileDeckEnabled`
3. `fileDeckMarker`
4. `folderDeckMode`
5. 规则版本常量
当文件内容未变但指纹变化时,普通全库同步必须强制重读文件并重新提取 deck 线索。
## 2. 计划语义
`renderConfigHash` 继续只表达字段渲染语义,不再承担 deck 兼容豁免。
新增 `ManualSyncPlan.toChangeDeck`
1. 仅 deck 变化时进入 `toChangeDeck`
2. 字段变化且 deck 变化时,同时进入 `toUpdate``toChangeDeck`
3. 仅字段变化时,不触发 deck 迁移
## 3. 执行语义
`AnkiBatchExecutor` 在主链路正式启用 `ankiGateway.changeDecks(...)`
1. `updateNotes` 只更新字段
2. `changeDecks` 只迁移 deck
3. deck 迁移基于 note summary 的 `cardIds`
4. 为避免迁移到不存在的 deckadd 和 changeDeck 目标 deck 都先走 `ensureDecks`
## 4. 结果统计
新增迁移统计字段:
1. `ManualSyncPlan.toChangeDeck`
2. `AnkiBatchExecutionResult.migratedDecks`
3. `ManualSyncResult.migratedDecks`
统计口径按“计划迁移的 note 数量”计算,而不是按 Anki card 数量计算。
## 5. 状态回写
`ManualSyncService.buildNextState` 继续把最新 resolved deck 写回 `CardState.deck`,并把当前 `deckRulesFingerprint` 写回 `FileState.deckRulesFingerprint`,作为下一次普通同步的比较基线。
## 6. rebuildIndex 语义
`rebuildIndex` 仍只负责:
1. 索引刷新
2. marker 修复
即使规则变了,也不执行 `changeDecks`
## 7. 类型兼容策略
`UpdateAnkiNoteInput.deckName` 保留为可选字段,仅用于兼容旧测试桩和调用面;主链路不再依赖它承载 deck 迁移语义。
## 8. 文档语义
仓库文档统一改为以下产品语义:
1. 普通同步会按最新 deck 规则重算旧卡
2. 若 resolved deck 变化,普通同步会迁移旧 note 到新 deck
3. rebuildIndex 不负责 Anki deck 迁移

View file

@ -0,0 +1,83 @@
# Deck Rule Migration Gap Report
## 审查范围
本报告只审查 deck 规则变更后“旧卡未按最新规则重梳理”的主链路实现:
1. `FileIndexerService`
2. `RenderConfigService`
3. `DiffPlannerService`
4. `ManualSyncPlan` / `ManualSyncResult`
5. `AnkiBatchExecutor`
6. `ManualSyncService`
7. `PluginState` / `FileState` 持久化
## 审查结论
修复前,旧卡不会在普通同步中按最新 deck 规则重梳理,原因不是单点 bug而是三层实现同时把这种变化当作“可忽略”
### 1. 索引复用条件缺少规则失效
`FileIndexerService` 只按文件时间戳、pending write-back、缺卡状态判断是否重读文件。
缺口:
1. deck 规则变化时,未改动文件仍会直接复用旧 `deckHint`
2. 旧 `CardState.deck` 会继续作为比较基线
3. 普通同步无法感知“规则变了但内容没变”
### 2. 计划阶段把 deck-only 变化视为 unchanged
`RenderConfigService``renderConfigHash` 本来已经不包含 deck`DiffPlannerService` 仍通过兼容 hash 逻辑把历史 deck 差异豁免掉。
缺口:
1. deck-only 变化不会进入执行计划
2. 仅 deck 变化的卡会继续计入 `unchangedCards`
3. 缺少单独的 `toChangeDeck` 计划集合
### 3. 执行阶段没有主链路 deck 迁移
`AnkiBatchExecutor` 只做:
1. `ensureDecks + addNotes`
2. `updateNotes`
3. marker write-back
缺口:
1. 主链路没有使用 `ankiGateway.changeDecks(...)`
2. `UpdateAnkiNoteInput.deckName` 仍让接口看起来像 update 会顺带迁移 deck
3. 同步结果没有统计 migrated decks
### 4. 状态层缺少规则版本比较基准
`FileState` 中没有任何 deck 规则指纹,因此即使 settings 变了,也没有稳定方式让旧索引失效。
### 5. 文档与测试仍锁定旧语义
仓库中的模块 5 文档和旧的 deck 决策文档仍写着:
1. 旧卡不自动移动
2. `changeDeck` 不启用
3. deck 变化不应触发普通同步迁移
这些表述已经和新任务目标冲突,必须同步修正。
## 本轮修复边界
本轮只解决 deck 规则变更后的旧卡重梳理:
1. deck 规则指纹失效
2. 旧卡 deck 迁移计划生成
3. `changeDecks` 主链路执行
4. 迁移统计与状态回写
5. rebuildIndex 保持不迁移
6. 文档和测试改成新语义
不扩展到:
1. note type 迁移
2. 字段映射迁移
3. card identity 迁移
4. 单独的 deck migration 命令 UI

View file

@ -57,8 +57,8 @@
1. `CardIndexingService` 只提取“文件级显式 deck 线索”
2. `RenderConfigService` 调用 `DeckResolutionService` 解析最终 deck
3. `DiffPlannerService` 保持当前旧卡行为,不额外改写 update / changeDeck 语义
4. `AnkiBatchExecutor` 继续`toCreate` 使用 `resolvedDeck`
3. `DiffPlannerService` 分离字段更新与 deck 迁移计划
4. `AnkiBatchExecutor``toCreate` 使用 `resolvedDeck`,并在普通同步中执行 `changeDecks`
## 6. warnings

View file

@ -90,9 +90,9 @@
## 兼容与边界判断
模块 5 明确要求:旧卡行为保持当前实现,不在本模块内改写旧卡 deck update 语义
该报告反映的是模块 5 实现前的历史差距
因此本轮不能回退到更早的 deck 迁移语义,也不能新增新的 old-card deck migration 逻辑
后续 deck-rule-migration 修复已经把产品语义更新为:普通同步会按最新 deck 规则迁移旧卡;只有 `rebuildIndex` 继续保持“不迁移 Anki deck”的边界
## 本轮修复边界

View file

@ -10,7 +10,7 @@ export interface AddAnkiNoteInput {
export interface UpdateAnkiNoteInput {
noteId: number;
deckName: string;
deckName?: string;
fields: Record<string, string>;
}

View file

@ -38,6 +38,7 @@ describe("AnkiBatchExecutor", () => {
const plan: ManualSyncPlan = {
toCreate: [createOne, createTwo],
toUpdate: [updateOne],
toChangeDeck: [],
toRewriteMarker: [],
toOrphan: [],
unchangedCards: 0,
@ -56,7 +57,7 @@ describe("AnkiBatchExecutor", () => {
expect(ankiGateway.getModelDetailsCalls).toEqual(["Basic"]);
});
it("ensures decks for adds and does not move existing notes to another deck on update", async () => {
it("ensures target decks and executes changeDecks separately from field updates", async () => {
const ankiGateway = new CountingAnkiGateway();
ankiGateway.noteSummariesById.set(300, {
noteId: 300,
@ -67,15 +68,18 @@ describe("AnkiBatchExecutor", () => {
const executor = new AnkiBatchExecutor(ankiGateway);
const createCard = createPlannedCard("ahs_create", undefined, "Folder::Deck");
const updateCard = createPlannedCard("ahs_update", 300, "Changed::Deck");
const changeDeckCard = createPlannedCard("ahs_migrate", 300, "Changed::Deck");
const renderedCards = new Map<string, RenderedSyncCard>([
[createCard.card.cardId, createRenderedSyncCard(createCard)],
[updateCard.card.cardId, createRenderedSyncCard(updateCard)],
[changeDeckCard.card.cardId, createRenderedSyncCard(changeDeckCard)],
]);
await executor.execute(
const result = await executor.execute(
{
toCreate: [createCard],
toUpdate: [updateCard],
toChangeDeck: [changeDeckCard],
toRewriteMarker: [],
toOrphan: [],
unchangedCards: 0,
@ -86,10 +90,11 @@ describe("AnkiBatchExecutor", () => {
createModule3Settings().noteFieldMappings,
);
expect(ankiGateway.ensuredDecks).toEqual([["Folder::Deck"]]);
expect(ankiGateway.changedDecks).toEqual([]);
expect(result.migratedDecks).toBe(1);
expect(ankiGateway.ensuredDecks).toEqual([["Folder::Deck", "Changed::Deck"]]);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "Changed::Deck", cardIds: [700] }]);
expect(ankiGateway.addedNotes[0]?.deckName).toBe("Folder::Deck");
expect(ankiGateway.updatedNotes[0]?.deckName).toBe("Changed::Deck");
expect(ankiGateway.updatedNotes[0]?.deckName).toBeUndefined();
});
});

View file

@ -9,6 +9,7 @@ import type { PlannedCard, ManualSyncPlan } from "@/domain/manual-sync/value-obj
export interface AnkiBatchExecutionResult {
created: number;
updated: number;
migratedDecks: number;
uploadedMedia: number;
markerWrites: PlannedCard[];
resolvedNoteIds: Map<string, number | undefined>;
@ -34,13 +35,19 @@ export class AnkiBatchExecutor {
const markerWriteMap = new Map<string, PlannedCard>();
let created = 0;
let updated = 0;
let migratedDecks = 0;
const summaryIds = Array.from(new Set([...plan.toUpdate, ...plan.toRewriteMarker].flatMap((plannedCard) => plannedCard.noteId ? [plannedCard.noteId] : [])));
const summaryIds = Array.from(new Set([
...plan.toUpdate,
...plan.toRewriteMarker,
...plan.toChangeDeck,
].flatMap((plannedCard) => plannedCard.noteId ? [plannedCard.noteId] : [])));
const noteSummaries = await this.batchScheduler.runCollectBatches(summaryIds, 100, 1, (batch) => this.ankiGateway.getNoteSummaries(batch));
const noteSummariesById = new Map(noteSummaries.map((summary) => [summary.noteId, summary]));
const addQueue: RenderedSyncCard[] = [];
const updateQueue: Array<{ plannedCard: PlannedCard; renderedCard: RenderedSyncCard }> = [];
const changeDeckQueue: PlannedCard[] = [];
for (const plannedCard of plan.toCreate) {
addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand));
@ -71,8 +78,20 @@ export class AnkiBatchExecutor {
markerWriteMap.set(plannedCard.card.cardId, plannedCard);
}
for (const plannedCard of plan.toChangeDeck) {
if (!plannedCard.noteId || !noteSummariesById.has(plannedCard.noteId)) {
continue;
}
changeDeckQueue.push(plannedCard);
resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId);
}
await this.batchScheduler.runVoidBatches(
Array.from(new Set(addQueue.map((card) => card.deck))),
Array.from(new Set([
...addQueue.map((card) => card.deck),
...changeDeckQueue.map((plannedCard) => plannedCard.deck),
])),
50,
1,
(batch) => this.ankiGateway.ensureDecks(batch),
@ -114,7 +133,6 @@ export class AnkiBatchExecutor {
await this.ankiGateway.updateNotes(
await Promise.all(batch.map(async ({ plannedCard, renderedCard }) => ({
noteId: plannedCard.noteId ?? 0,
deckName: renderedCard.deck,
fields: await this.mapFields(renderedCard, noteFieldMappings, modelDetailsCache),
}))),
);
@ -128,9 +146,45 @@ export class AnkiBatchExecutor {
}
}
const deckChangeInputs = new Map<string, Set<number>>();
for (const plannedCard of changeDeckQueue) {
const noteId = plannedCard.noteId;
if (!noteId) {
continue;
}
const summary = noteSummariesById.get(noteId);
if (!summary || summary.cardIds.length === 0) {
continue;
}
const cardIds = deckChangeInputs.get(plannedCard.deck) ?? new Set<number>();
for (const cardId of summary.cardIds) {
cardIds.add(cardId);
}
deckChangeInputs.set(plannedCard.deck, cardIds);
}
await this.batchScheduler.runVoidBatches(
Array.from(deckChangeInputs.entries()).map(([deckName, cardIds]) => ({
deckName,
cardIds: Array.from(cardIds),
})),
25,
1,
(batch) => this.ankiGateway.changeDecks(batch),
);
migratedDecks = changeDeckQueue.length;
for (const plannedCard of changeDeckQueue) {
touchedCardIds.add(plannedCard.card.cardId);
}
return {
created,
updated,
migratedDecks,
uploadedMedia,
markerWrites: Array.from(markerWriteMap.values()).map((plannedCard) => ({
...plannedCard,

View file

@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState";
import { createModule3Settings, FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes";
import { FileIndexerService } from "./FileIndexerService";
import { createDeckRulesFingerprint, FileIndexerService } from "./FileIndexerService";
describe("FileIndexerService", () => {
it("filters by path before reading file content", async () => {
@ -35,6 +35,7 @@ describe("FileIndexerService", () => {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${["#### One", "Body"].join("\n").length}`,
deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings()),
lastIndexedAt: 1,
cardIds: ["ahs_1"],
},
@ -74,4 +75,55 @@ describe("FileIndexerService", () => {
expect(vaultGateway.readCalls).toEqual([]);
expect(result.cards[0]).toMatchObject({ cardId: "ahs_1", noteId: 10 });
});
it("forces re-read when deck rules fingerprint changed even if file stamp is unchanged", async () => {
const content = ["#### One", "Body"].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/one.md": content,
});
const service = new FileIndexerService(vaultGateway);
const state = {
files: {
"notes/one.md": {
filePath: "notes/one.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(createModule3Settings({ defaultDeck: "Old::Deck" })),
lastIndexedAt: 1,
cardIds: ["ahs_1"],
},
},
cards: {
ahs_1: {
cardId: "ahs_1",
noteId: 10,
filePath: "notes/one.md",
heading: "One",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 16,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: content,
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "Old::Deck",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
const result = await service.indexVault(createModule3Settings({ defaultDeck: "New::Deck" }), state);
expect(result.skippedUnchangedFiles).toBe(0);
expect(vaultGateway.readCalls).toEqual(["notes/one.md"]);
});
});

View file

@ -5,6 +5,7 @@ import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
import type { CardState, PendingWriteBackState, PluginState } from "@/domain/manual-sync/entities/PluginState";
import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService";
import { hashString } from "@/domain/shared/hash";
export interface FileIndexerResult {
scopedFilePaths: string[];
@ -79,6 +80,7 @@ export class FileIndexerService {
const cards: IndexedCard[] = [];
let skippedUnchangedFiles = 0;
let skippedUnchangedCards = 0;
const deckRulesFingerprint = createDeckRulesFingerprint(settings);
for (const ref of refs) {
const fileStamp = createFileStamp(ref.mtime, ref.size);
@ -92,6 +94,7 @@ export class FileIndexerService {
hasPendingWriteBack ||
!existingFileState ||
existingFileState.fileStamp !== fileStamp ||
existingFileState.deckRulesFingerprint !== deckRulesFingerprint ||
hasMissingKnownCard;
if (!shouldRead) {
@ -201,4 +204,16 @@ function restoreIndexedCard(card: CardState): IndexedCard {
export function createFileStamp(mtime: number, size: number): string {
return `${mtime}:${size}`;
}
const DECK_RULES_FINGERPRINT_VERSION = "deck-rules-v1";
export function createDeckRulesFingerprint(settings: PluginSettings): string {
return hashString(JSON.stringify({
version: DECK_RULES_FINGERPRINT_VERSION,
defaultDeck: settings.defaultDeck,
fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker,
folderDeckMode: settings.folderDeckMode,
}));
}

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PluginSettings } from "@/application/config/PluginSettings";
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
import { RenderConfigService } from "@/application/services/RenderConfigService";
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
import { hashString } from "@/domain/shared/hash";
@ -21,6 +22,7 @@ describe("ManualSyncService", () => {
expect(result.created).toBe(1);
expect(result.updated).toBe(0);
expect(result.migratedDecks).toBe(0);
expect(result.warnings).toEqual([]);
expect(ankiGateway.addedNotes[0]?.deckName).toBe("notes");
expect(ankiGateway.ensuredDecks).toEqual([["notes"]]);
@ -58,11 +60,48 @@ describe("ManualSyncService", () => {
expect(result.created).toBe(0);
expect(result.rewrittenMarkers).toBe(1);
expect(result.migratedDecks).toBe(0);
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(ankiGateway.changedDecks).toEqual([]);
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("note=");
});
it("rebuildIndex does not migrate decks even when deck rules changed", async () => {
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck", folderDeckMode: "off" });
const newSettings = createModule3Settings({ defaultDeck: "New::Deck", folderDeckMode: "folder" });
const content = ["#### Prompt", "Answer"].join("\n");
const vaultGateway = new FakeManualSyncVaultGateway({
"课程/数学/第一章/导数.md": content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"课程/数学/第一章/导数.md": {
filePath: "课程/数学/第一章/导数.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
cardIds: ["ahs_known"],
},
},
cards: {
ahs_known: createStoredSyncedCard(oldSettings, {
filePath: "课程/数学/第一章/导数.md",
deck: "Old::Deck",
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.rebuildIndex(newSettings);
expect(result.migratedDecks).toBe(0);
expect(ankiGateway.changedDecks).toEqual([]);
});
it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => {
const settings = createModule3Settings();
const vaultGateway = new FakeManualSyncVaultGateway({
@ -159,7 +198,7 @@ describe("ManualSyncService", () => {
expect(ankiGateway.ensuredDecks).toEqual([["课程::数学::第一章"]]);
});
it("does not update or move an existing note when only the resolved deck changes", async () => {
it("migrates an existing note when only the resolved deck changes", async () => {
const settings = createModule3Settings({ defaultDeck: "New::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"example.md": ["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
@ -186,15 +225,15 @@ describe("ManualSyncService", () => {
tagsHint: [],
markerState: "card-and-note" as const,
};
const renderPlan = new RenderConfigService().resolve(indexedCard, settings, ["Old::Deck"]);
const legacyRenderConfigHash = renderPlan.compatibleRenderConfigHashes.find((hash) => hash !== renderPlan.renderConfigHash) ?? renderPlan.renderConfigHash;
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck" });
const renderPlan = new RenderConfigService().resolve(indexedCard, oldSettings);
const stateRepository = new InMemoryPluginStateRepository({
files: {},
cards: {
ahs_known: createStoredSyncedCard(settings, {
filePath: "example.md",
deck: "Old::Deck",
renderConfigHash: legacyRenderConfigHash,
renderConfigHash: renderPlan.renderConfigHash,
}),
},
pendingWriteBack: [],
@ -210,8 +249,171 @@ describe("ManualSyncService", () => {
const result = await service.syncFile("example.md", settings);
expect(result.updated).toBe(0);
expect(result.migratedDecks).toBe(1);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(ankiGateway.changedDecks).toEqual([]);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "New::Deck", cardIds: [7001] }]);
});
it("ordinary sync re-evaluates unchanged files when folder deck rules changed and migrates old notes", async () => {
const oldSettings = createModule3Settings({ folderDeckMode: "off", defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ folderDeckMode: "folder", defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"课程/数学/第一章/导数.md": ["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"课程/数学/第一章/导数.md": {
filePath: "课程/数学/第一章/导数.md",
fileHash: "hash-a",
fileStamp: `1:${["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n").length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
cardIds: ["ahs_known"],
},
},
cards: {
ahs_known: createStoredSyncedCard(oldSettings, {
filePath: "课程/数学/第一章/导数.md",
deck: "Default::Deck",
}),
},
pendingWriteBack: [],
});
stateRepository.savedState = await stateRepository.load();
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.noteSummariesById.set(42, {
noteId: 42,
modelName: "Basic",
cardIds: [7001],
});
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncVault(newSettings);
expect(result.updated).toBe(0);
expect(result.migratedDecks).toBe(1);
expect(vaultGateway.readCalls).toEqual(["课程/数学/第一章/导数.md"]);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "课程::数学::第一章", cardIds: [7001] }]);
expect(stateRepository.savedState?.cards.ahs_known?.deck).toBe("课程::数学::第一章");
expect(stateRepository.savedState?.files["课程/数学/第一章/导数.md"]?.deckRulesFingerprint).toBe(createDeckRulesFingerprint(newSettings));
});
it("ordinary sync re-evaluates unchanged files when default deck changed and migrates root-level notes", async () => {
const content = ["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const oldSettings = createModule3Settings({ defaultDeck: "Old::Deck", folderDeckMode: "off" });
const newSettings = createModule3Settings({ defaultDeck: "New::Deck", folderDeckMode: "off" });
const vaultGateway = new FakeManualSyncVaultGateway({
"example.md": content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"example.md": {
filePath: "example.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
cardIds: ["ahs_known"],
},
},
cards: {
ahs_known: createStoredSyncedCard(oldSettings, {
filePath: "example.md",
deck: "Old::Deck",
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.noteSummariesById.set(42, {
noteId: 42,
modelName: "Basic",
cardIds: [7001],
});
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncVault(newSettings);
expect(result.migratedDecks).toBe(1);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "New::Deck", cardIds: [7001] }]);
});
it("ordinary sync re-evaluates unchanged files when file deck marker changes and migrates old notes", async () => {
const content = ["MY DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const oldSettings = createModule3Settings({ fileDeckMarker: "TARGET DECK", defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ fileDeckMarker: "MY DECK", defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"notes/example.md": {
filePath: "notes/example.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
cardIds: ["ahs_known"],
},
},
cards: {
ahs_known: createStoredSyncedCard(oldSettings, {
deck: "notes",
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.noteSummariesById.set(42, {
noteId: 42,
modelName: "Basic",
cardIds: [7001],
});
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncVault(newSettings);
expect(result.migratedDecks).toBe(1);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "Scoped::Deck", cardIds: [7001] }]);
});
it("ordinary sync re-evaluates unchanged files when file-level deck is enabled and migrates old notes", async () => {
const content = ["TARGET DECK: Scoped::Deck", "", "#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n");
const oldSettings = createModule3Settings({ fileDeckEnabled: false, defaultDeck: "Default::Deck" });
const newSettings = createModule3Settings({ fileDeckEnabled: true, defaultDeck: "Default::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": content,
});
const stateRepository = new InMemoryPluginStateRepository({
files: {
"notes/example.md": {
filePath: "notes/example.md",
fileHash: "hash-a",
fileStamp: `1:${content.length}`,
deckRulesFingerprint: createDeckRulesFingerprint(oldSettings),
lastIndexedAt: 1,
cardIds: ["ahs_known"],
},
},
cards: {
ahs_known: createStoredSyncedCard(oldSettings, {
deck: "notes",
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.noteSummariesById.set(42, {
noteId: 42,
modelName: "Basic",
cardIds: [7001],
});
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncVault(newSettings);
expect(result.migratedDecks).toBe(1);
expect(ankiGateway.changedDecks).toEqual([{ deckName: "Scoped::Deck", cardIds: [7001] }]);
});
it("skips current file sync when the file is outside the configured scope", async () => {

View file

@ -5,6 +5,7 @@ import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVault
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import { AnkiBatchExecutor } from "@/application/services/AnkiBatchExecutor";
import { FileIndexerService } from "@/application/services/FileIndexerService";
import { createDeckRulesFingerprint } from "@/application/services/FileIndexerService";
import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBackService";
import { RenderConfigService } from "@/application/services/RenderConfigService";
import { ScanScopeService } from "@/application/services/ScanScopeService";
@ -93,6 +94,7 @@ export class ManualSyncService {
scannedCards: indexResult.cards.length,
created: 0,
updated: 0,
migratedDecks: 0,
orphaned: plan.toOrphan.length,
uploadedMedia: 0,
skippedUnchangedCards: indexResult.skippedUnchangedCards,
@ -157,6 +159,7 @@ export class ManualSyncService {
scannedCards: indexResult.cards.length,
created: executionResult.created,
updated: executionResult.updated,
migratedDecks: executionResult.migratedDecks,
orphaned: plan.toOrphan.length,
uploadedMedia: executionResult.uploadedMedia,
skippedUnchangedCards: indexResult.skippedUnchangedCards,
@ -176,6 +179,7 @@ export class ManualSyncService {
orphanCards: Array<{ cardId: string }>,
): PluginState {
const now = this.now();
const deckRulesFingerprint = createDeckRulesFingerprint(settings);
const nextState: PluginState = {
files: { ...previousState.files },
cards: { ...previousState.cards },
@ -191,6 +195,7 @@ export class ManualSyncService {
filePath: indexedFile.filePath,
fileHash: indexedFile.fileHash,
fileStamp: indexedFile.fileStamp,
deckRulesFingerprint,
lastIndexedAt: now,
cardIds: indexedFile.cards.map((card) => card.cardId),
};

View file

@ -9,14 +9,13 @@ export interface RenderPlan {
deck: string;
noteModel: string;
renderConfigHash: string;
compatibleRenderConfigHashes: string[];
warnings: DeckResolutionWarning[];
}
export class RenderConfigService {
constructor(private readonly deckResolutionService = new DeckResolutionService()) {}
resolve(card: IndexedCard, settings: PluginSettings, compatibilityDecks: string[] = []): RenderPlan {
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
const noteModel = card.cardType === "basic" ? settings.qaNoteType : settings.clozeNoteType;
const deckResolution = this.deckResolutionService.resolve(card, settings.defaultDeck, settings.folderDeckMode);
const deck = deckResolution.resolvedDeck.value;
@ -29,19 +28,11 @@ export class RenderConfigService {
convertHighlightsToCloze: settings.convertHighlightsToCloze,
};
const renderConfigHash = hashString(JSON.stringify(renderConfigPayload));
const compatibleRenderConfigHashes = Array.from(new Set([
renderConfigHash,
...compatibilityDecks.map((compatibilityDeck) => hashString(JSON.stringify({
...renderConfigPayload,
deck: compatibilityDeck,
}))),
]));
return {
deck,
noteModel,
renderConfigHash,
compatibleRenderConfigHashes,
warnings: deckResolution.warnings,
};
}

View file

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

View file

@ -5,6 +5,7 @@ export interface FileState {
filePath: string;
fileHash: string;
fileStamp: string;
deckRulesFingerprint?: string;
lastIndexedAt: number;
cardIds: string[];
}

View file

@ -37,6 +37,7 @@ describe("DiffPlannerService", () => {
);
expect(plan.toCreate).toHaveLength(1);
expect(plan.toChangeDeck).toHaveLength(0);
});
it("rewrites a missing marker without create or update when the restored card content is unchanged", () => {
@ -97,6 +98,7 @@ describe("DiffPlannerService", () => {
expect(plan.toCreate).toHaveLength(0);
expect(plan.toUpdate).toHaveLength(0);
expect(plan.toChangeDeck).toHaveLength(0);
expect(plan.toRewriteMarker.map((plannedCard) => plannedCard.card.cardId)).toEqual(["ahs_known"]);
expect(plan.unchangedCards).toBe(1);
});
@ -172,6 +174,7 @@ describe("DiffPlannerService", () => {
);
expect(plan.toUpdate).toHaveLength(1);
expect(plan.toChangeDeck).toHaveLength(1);
expect(plan.toRewriteMarker).toHaveLength(1);
});
@ -212,7 +215,7 @@ describe("DiffPlannerService", () => {
expect(plan.toOrphan.map((card) => card.cardId)).toEqual(["ahs_missing"]);
});
it("does not schedule update when only the resolved deck changed", () => {
it("schedules deck migration when only the resolved deck changed", () => {
const service = new DiffPlannerService();
const settings = createModule3Settings({ defaultDeck: "New::Deck" });
const card = {
@ -236,8 +239,7 @@ describe("DiffPlannerService", () => {
tagsHint: [],
markerState: "card-and-note" as const,
};
const legacyRenderPlan = new RenderConfigService().resolve(card, settings, ["Old::Deck"]);
const legacyRenderConfigHash = legacyRenderPlan.compatibleRenderConfigHashes.find((hash) => hash !== legacyRenderPlan.renderConfigHash) ?? legacyRenderPlan.renderConfigHash;
const legacyRenderPlan = new RenderConfigService().resolve(card, createModule3Settings({ defaultDeck: "Old::Deck" }));
const state = {
files: {},
cards: {
@ -257,7 +259,7 @@ describe("DiffPlannerService", () => {
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "hash-card",
renderConfigHash: legacyRenderConfigHash,
renderConfigHash: legacyRenderPlan.renderConfigHash,
deck: "Old::Deck",
deckWarnings: [],
tagsHint: [],
@ -271,6 +273,73 @@ describe("DiffPlannerService", () => {
const plan = service.plan([card], state, ["example.md"], settings);
expect(plan.toUpdate).toHaveLength(0);
expect(plan.unchangedCards).toBe(1);
expect(plan.toChangeDeck).toHaveLength(1);
expect(plan.unchangedCards).toBe(0);
});
it("does not trigger deck migration when only fields changed", () => {
const service = new DiffPlannerService();
const settings = createModule3Settings();
const card = {
cardId: "ahs_1",
noteId: 42,
markerNoteId: 42,
filePath: "example.md",
cardType: "basic" as const,
heading: "Prompt",
headingLevel: 4,
bodyMarkdown: "Updated Body",
blockStartOffset: 0,
blockEndOffset: 23,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Updated Body"].join("\n"),
rawBlockHash: "new-hash",
deckWarnings: [],
tagsHint: [],
markerState: "card-and-note" as const,
};
const existingRenderPlan = new RenderConfigService().resolve({
...card,
bodyMarkdown: "Body",
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "old-hash",
}, settings);
const state = {
files: {},
cards: {
ahs_1: {
cardId: "ahs_1",
noteId: 42,
filePath: "example.md",
heading: "Prompt",
headingLevel: 4,
bodyMarkdown: "Body",
cardType: "basic" as const,
blockStartOffset: 0,
blockEndOffset: 16,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 2,
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "old-hash",
renderConfigHash: existingRenderPlan.renderConfigHash,
deck: existingRenderPlan.deck,
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
const plan = service.plan([card], state, ["example.md"], settings);
expect(plan.toUpdate).toHaveLength(1);
expect(plan.toChangeDeck).toHaveLength(0);
});
});

View file

@ -13,6 +13,7 @@ export class DiffPlannerService {
const pendingByCardId = new Map(state.pendingWriteBack.map((pending) => [pending.cardId, pending]));
const toCreate: PlannedCard[] = [];
const toUpdate: PlannedCard[] = [];
const toChangeDeck: PlannedCard[] = [];
const toRewriteMarker: PlannedCard[] = [];
const warningMap = new Map<string, DeckResolutionWarning>();
let unchangedCards = 0;
@ -24,7 +25,7 @@ export class DiffPlannerService {
cardsById.set(card.cardId, card);
const existingState = state.cards[card.cardId];
const renderPlan = this.renderConfigService.resolve(card, settings, existingState?.deck ? [existingState.deck] : []);
const renderPlan = this.renderConfigService.resolve(card, settings);
for (const warning of renderPlan.warnings) {
warningMap.set(getDeckResolutionWarningKey(warning), warning);
}
@ -59,17 +60,24 @@ export class DiffPlannerService {
continue;
}
if (
const fieldsChanged =
existingState.rawBlockHash !== card.rawBlockHash ||
!renderPlan.compatibleRenderConfigHashes.includes(existingState.renderConfigHash) ||
existingState.renderConfigHash !== renderPlan.renderConfigHash ||
existingState.orphan ||
pendingByCardId.has(card.cardId)
) {
pendingByCardId.has(card.cardId);
const deckChanged = existingState.deck !== renderPlan.deck;
if (fieldsChanged) {
toUpdate.push(plannedCard);
continue;
}
unchangedCards += 1;
if (deckChanged) {
toChangeDeck.push(plannedCard);
}
if (!fieldsChanged && !deckChanged) {
unchangedCards += 1;
}
}
const scopedPaths = new Set(scopedFilePaths);
@ -78,6 +86,7 @@ export class DiffPlannerService {
return {
toCreate,
toUpdate,
toChangeDeck,
toRewriteMarker,
toOrphan,
unchangedCards,

View file

@ -13,6 +13,7 @@ export interface PlannedCard {
export interface ManualSyncPlan {
toCreate: PlannedCard[];
toUpdate: PlannedCard[];
toChangeDeck: PlannedCard[];
toRewriteMarker: PlannedCard[];
toOrphan: CardState[];
unchangedCards: number;

View file

@ -12,7 +12,7 @@ export class NoticeService {
}
showSyncSummary(prefix: string, result: ManualSyncResult): void {
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, created ${result.created}, updated ${result.updated}, orphaned ${result.orphaned}, media ${result.uploadedMedia}, skipped ${result.skippedUnchangedCards}.`;
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, created ${result.created}, updated ${result.updated}, migrated decks ${result.migratedDecks}, orphaned ${result.orphaned}, media ${result.uploadedMedia}, skipped ${result.skippedUnchangedCards}.`;
const conflicts = result.markerWriteConflictFiles.length > 0
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
: "";
@ -25,7 +25,7 @@ export class NoticeService {
}
showRebuildSummary(prefix: string, result: ManualSyncResult): void {
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, orphaned ${result.orphaned}, rewritten markers ${result.rewrittenMarkers}, skipped ${result.skippedUnchangedCards}.`;
const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, migrated decks ${result.migratedDecks}, orphaned ${result.orphaned}, rewritten markers ${result.rewrittenMarkers}, skipped ${result.skippedUnchangedCards}.`;
const conflicts = result.markerWriteConflictFiles.length > 0
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
: "";