mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
fix(deck): migrate existing cards when deck rules change
This commit is contained in:
parent
960b87103d
commit
bd149b9d11
21 changed files with 636 additions and 85 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 通过
|
||||
|
|
|
|||
|
|
@ -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. 校验与错误
|
||||
|
||||
|
|
|
|||
69
docs/deck-rule-migration-decisions.md
Normal file
69
docs/deck-rule-migration-decisions.md
Normal 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. 为避免迁移到不存在的 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 迁移
|
||||
83
docs/deck-rule-migration-gap-report.md
Normal file
83
docs/deck-rule-migration-gap-report.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -90,9 +90,9 @@
|
|||
|
||||
## 兼容与边界判断
|
||||
|
||||
模块 5 明确要求:旧卡行为保持当前实现,不在本模块内改写旧卡 deck update 语义。
|
||||
该报告反映的是模块 5 实现前的历史差距。
|
||||
|
||||
因此本轮不能回退到更早的 deck 迁移语义,也不能新增新的 old-card deck migration 逻辑。
|
||||
后续 deck-rule-migration 修复已经把产品语义更新为:普通同步会按最新 deck 规则迁移旧卡;只有 `rebuildIndex` 继续保持“不迁移 Anki deck”的边界。
|
||||
|
||||
## 本轮修复边界
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface AddAnkiNoteInput {
|
|||
|
||||
export interface UpdateAnkiNoteInput {
|
||||
noteId: number;
|
||||
deckName: string;
|
||||
deckName?: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface ManualSyncResult {
|
|||
scannedCards: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
migratedDecks: number;
|
||||
orphaned: number;
|
||||
uploadedMedia: number;
|
||||
skippedUnchangedCards: number;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface FileState {
|
|||
filePath: string;
|
||||
fileHash: string;
|
||||
fileStamp: string;
|
||||
deckRulesFingerprint?: string;
|
||||
lastIndexedAt: number;
|
||||
cardIds: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface PlannedCard {
|
|||
export interface ManualSyncPlan {
|
||||
toCreate: PlannedCard[];
|
||||
toUpdate: PlannedCard[];
|
||||
toChangeDeck: PlannedCard[];
|
||||
toRewriteMarker: PlannedCard[];
|
||||
toOrphan: CardState[];
|
||||
unchangedCards: number;
|
||||
|
|
|
|||
|
|
@ -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(", ")}.`
|
||||
: "";
|
||||
|
|
|
|||
Loading…
Reference in a new issue