From bd149b9d11e543e57cfccc5d73c54e2ef20b0000 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 21:39:15 +0800 Subject: [PATCH] fix(deck): migrate existing cards when deck rules change --- docs/2026-04-18PLAN5.md | 17 +- docs/2026-04-18,deck-resolution-plan.md | 55 ++--- docs/deck-resolution-decisions.md | 6 +- docs/deck-rule-migration-decisions.md | 69 ++++++ docs/deck-rule-migration-gap-report.md | 83 +++++++ docs/module-5-decisions.md | 4 +- docs/module-5-gap-report.md | 4 +- src/application/ports/AnkiGateway.ts | 2 +- .../services/AnkiBatchExecutor.test.ts | 15 +- src/application/services/AnkiBatchExecutor.ts | 60 ++++- .../services/FileIndexerService.test.ts | 54 ++++- .../services/FileIndexerService.ts | 15 ++ .../services/ManualSyncService.test.ts | 212 +++++++++++++++++- src/application/services/ManualSyncService.ts | 5 + .../services/RenderConfigService.ts | 11 +- src/application/use-cases/manualSyncTypes.ts | 1 + .../manual-sync/entities/PluginState.ts | 1 + .../services/DiffPlannerService.test.ts | 79 ++++++- .../services/DiffPlannerService.ts | 23 +- .../value-objects/ManualSyncPlan.ts | 1 + src/presentation/notices/NoticeService.ts | 4 +- 21 files changed, 636 insertions(+), 85 deletions(-) create mode 100644 docs/deck-rule-migration-decisions.md create mode 100644 docs/deck-rule-migration-gap-report.md diff --git a/docs/2026-04-18PLAN5.md b/docs/2026-04-18PLAN5.md index 8449474..deb390e 100644 --- a/docs/2026-04-18PLAN5.md +++ b/docs/2026-04-18PLAN5.md @@ -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 diff --git a/docs/2026-04-18,deck-resolution-plan.md b/docs/2026-04-18,deck-resolution-plan.md index 2911b0b..4d60512 100644 --- a/docs/2026-04-18,deck-resolution-plan.md +++ b/docs/2026-04-18,deck-resolution-plan.md @@ -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 通过 diff --git a/docs/deck-resolution-decisions.md b/docs/deck-resolution-decisions.md index 0428fad..6ad0635 100644 --- a/docs/deck-resolution-decisions.md +++ b/docs/deck-resolution-decisions.md @@ -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` 不包含 deck,deck 迁移由独立计划集合承载 +4. rebuildIndex 仍不触发 `changeDeck` ## 5. 校验与错误 diff --git a/docs/deck-rule-migration-decisions.md b/docs/deck-rule-migration-decisions.md new file mode 100644 index 0000000..28357a5 --- /dev/null +++ b/docs/deck-rule-migration-decisions.md @@ -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. 为避免迁移到不存在的 deck,add 和 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 迁移 \ No newline at end of file diff --git a/docs/deck-rule-migration-gap-report.md b/docs/deck-rule-migration-gap-report.md new file mode 100644 index 0000000..6e55020 --- /dev/null +++ b/docs/deck-rule-migration-gap-report.md @@ -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 \ No newline at end of file diff --git a/docs/module-5-decisions.md b/docs/module-5-decisions.md index ef19e80..3fb79cd 100644 --- a/docs/module-5-decisions.md +++ b/docs/module-5-decisions.md @@ -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 diff --git a/docs/module-5-gap-report.md b/docs/module-5-gap-report.md index b74fc22..2fdf6b4 100644 --- a/docs/module-5-gap-report.md +++ b/docs/module-5-gap-report.md @@ -90,9 +90,9 @@ ## 兼容与边界判断 -模块 5 明确要求:旧卡行为保持当前实现,不在本模块内改写旧卡 deck update 语义。 +该报告反映的是模块 5 实现前的历史差距。 -因此本轮不能回退到更早的 deck 迁移语义,也不能新增新的 old-card deck migration 逻辑。 +后续 deck-rule-migration 修复已经把产品语义更新为:普通同步会按最新 deck 规则迁移旧卡;只有 `rebuildIndex` 继续保持“不迁移 Anki deck”的边界。 ## 本轮修复边界 diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index 961106f..5a4a93f 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -10,7 +10,7 @@ export interface AddAnkiNoteInput { export interface UpdateAnkiNoteInput { noteId: number; - deckName: string; + deckName?: string; fields: Record; } diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index 0861530..6d43f26 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -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([ [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(); }); }); diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index a3e29c3..b9a6952 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -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; @@ -34,13 +35,19 @@ export class AnkiBatchExecutor { const markerWriteMap = new Map(); 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>(); + + 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(); + 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, diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index 20c9e49..6294ba4 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -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"]); + }); }); \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index afd46e6..0484104 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -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, + })); } \ No newline at end of file diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 6a81839..b02f3e5 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -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(""].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", ""].join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: { + "课程/数学/第一章/导数.md": { + filePath: "课程/数学/第一章/导数.md", + fileHash: "hash-a", + fileStamp: `1:${["#### Prompt", "Answer", ""].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", ""].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", ""].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", ""].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 () => { diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index e262d49..b32ae1a 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -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), }; diff --git a/src/application/services/RenderConfigService.ts b/src/application/services/RenderConfigService.ts index 00fce2b..0a8008c 100644 --- a/src/application/services/RenderConfigService.ts +++ b/src/application/services/RenderConfigService.ts @@ -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, }; } diff --git a/src/application/use-cases/manualSyncTypes.ts b/src/application/use-cases/manualSyncTypes.ts index bafc9d0..107b66e 100644 --- a/src/application/use-cases/manualSyncTypes.ts +++ b/src/application/use-cases/manualSyncTypes.ts @@ -5,6 +5,7 @@ export interface ManualSyncResult { scannedCards: number; created: number; updated: number; + migratedDecks: number; orphaned: number; uploadedMedia: number; skippedUnchangedCards: number; diff --git a/src/domain/manual-sync/entities/PluginState.ts b/src/domain/manual-sync/entities/PluginState.ts index e9f0a32..4735992 100644 --- a/src/domain/manual-sync/entities/PluginState.ts +++ b/src/domain/manual-sync/entities/PluginState.ts @@ -5,6 +5,7 @@ export interface FileState { filePath: string; fileHash: string; fileStamp: string; + deckRulesFingerprint?: string; lastIndexedAt: number; cardIds: string[]; } diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts index c37defe..60c5d90 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.test.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -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); }); }); \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index 6124be3..5fc0dba 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -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(); 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, diff --git a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts index 841bd71..83ce3bd 100644 --- a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts +++ b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts @@ -13,6 +13,7 @@ export interface PlannedCard { export interface ManualSyncPlan { toCreate: PlannedCard[]; toUpdate: PlannedCard[]; + toChangeDeck: PlannedCard[]; toRewriteMarker: PlannedCard[]; toOrphan: CardState[]; unchangedCards: number; diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 4cc0d51..1f9bdb5 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -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(", ")}.` : "";