From d3d356fb1094932362223ec4ddd6dcf2c9bfe845 Mon Sep 17 00:00:00 2001
From: Dusk
Date: Sat, 18 Apr 2026 10:07:49 +0800
Subject: [PATCH] feat: implement module 3 manual sync architecture
---
docs/2026-04-18PLAN4.md | 657 ++++++++++++++++++
docs/implementation-decisions.md | 79 +++
docs/implementation-gap-report.md | 144 ++++
src/application/ports/AnkiGateway.ts | 11 +
.../ports/ManualSyncVaultGateway.ts | 15 +
.../ports/PluginStateRepository.ts | 6 +
src/application/services/AnkiBatchExecutor.ts | 210 ++++++
src/application/services/BatchScheduler.ts | 50 ++
.../services/FileIndexerService.test.ts | 75 ++
.../services/FileIndexerService.ts | 155 +++++
.../services/ManualSyncService.test.ts | 57 ++
src/application/services/ManualSyncService.ts | 232 +++++++
.../services/MarkdownWriteBackService.ts | 102 +++
.../services/NoteFieldMappingService.ts | 21 +-
.../services/RenderConfigService.ts | 33 +
src/application/services/ScanScopeService.ts | 4 +-
.../use-cases/ExecuteSyncPlanUseCase.test.ts | 30 +-
.../use-cases/ManualSyncCurrentFileUseCase.ts | 11 +
.../use-cases/ManualSyncVaultUseCase.ts | 11 +
.../use-cases/RebuildCardIndexUseCase.ts | 11 +
.../use-cases/SyncCurrentFileUseCase.test.ts | 24 +-
.../use-cases/SyncVaultUseCase.test.ts | 20 +-
src/application/use-cases/manualSyncTypes.ts | 11 +
src/domain/manual-sync/entities/AhsMarker.ts | 8 +
.../manual-sync/entities/IndexedCard.ts | 26 +
.../manual-sync/entities/IndexedFile.ts | 9 +
.../manual-sync/entities/PluginState.ts | 57 ++
.../manual-sync/entities/RenderedSyncCard.ts | 12 +
.../services/CardIndexingService.test.ts | 83 +++
.../services/CardIndexingService.ts | 354 ++++++++++
.../services/CardMarkerService.test.ts | 52 ++
.../manual-sync/services/CardMarkerService.ts | 117 ++++
.../services/DiffPlannerService.test.ts | 147 ++++
.../services/DiffPlannerService.ts | 81 +++
.../services/ManualCardRenderer.ts | 187 +++++
.../value-objects/ManualSyncPlan.ts | 18 +
.../anki/AnkiConnectGateway.test.ts | 148 +++-
src/infrastructure/anki/AnkiConnectGateway.ts | 69 +-
.../obsidian/ObsidianVaultGateway.ts | 16 +-
.../DataJsonPluginConfigRepository.ts | 2 +
.../DataJsonPluginStateRepository.test.ts | 54 ++
.../DataJsonPluginStateRepository.ts | 23 +
src/presentation/AnkiHeadingSyncPlugin.ts | 44 +-
src/presentation/commands/registerCommands.ts | 12 +-
src/presentation/notices/NoticeService.ts | 22 +-
src/test-support/manualSyncFakes.ts | 199 ++++++
46 files changed, 3672 insertions(+), 37 deletions(-)
create mode 100644 docs/2026-04-18PLAN4.md
create mode 100644 docs/implementation-decisions.md
create mode 100644 docs/implementation-gap-report.md
create mode 100644 src/application/ports/ManualSyncVaultGateway.ts
create mode 100644 src/application/ports/PluginStateRepository.ts
create mode 100644 src/application/services/AnkiBatchExecutor.ts
create mode 100644 src/application/services/BatchScheduler.ts
create mode 100644 src/application/services/FileIndexerService.test.ts
create mode 100644 src/application/services/FileIndexerService.ts
create mode 100644 src/application/services/ManualSyncService.test.ts
create mode 100644 src/application/services/ManualSyncService.ts
create mode 100644 src/application/services/MarkdownWriteBackService.ts
create mode 100644 src/application/services/RenderConfigService.ts
create mode 100644 src/application/use-cases/ManualSyncCurrentFileUseCase.ts
create mode 100644 src/application/use-cases/ManualSyncVaultUseCase.ts
create mode 100644 src/application/use-cases/RebuildCardIndexUseCase.ts
create mode 100644 src/application/use-cases/manualSyncTypes.ts
create mode 100644 src/domain/manual-sync/entities/AhsMarker.ts
create mode 100644 src/domain/manual-sync/entities/IndexedCard.ts
create mode 100644 src/domain/manual-sync/entities/IndexedFile.ts
create mode 100644 src/domain/manual-sync/entities/PluginState.ts
create mode 100644 src/domain/manual-sync/entities/RenderedSyncCard.ts
create mode 100644 src/domain/manual-sync/services/CardIndexingService.test.ts
create mode 100644 src/domain/manual-sync/services/CardIndexingService.ts
create mode 100644 src/domain/manual-sync/services/CardMarkerService.test.ts
create mode 100644 src/domain/manual-sync/services/CardMarkerService.ts
create mode 100644 src/domain/manual-sync/services/DiffPlannerService.test.ts
create mode 100644 src/domain/manual-sync/services/DiffPlannerService.ts
create mode 100644 src/domain/manual-sync/services/ManualCardRenderer.ts
create mode 100644 src/domain/manual-sync/value-objects/ManualSyncPlan.ts
create mode 100644 src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
create mode 100644 src/infrastructure/persistence/DataJsonPluginStateRepository.ts
create mode 100644 src/test-support/manualSyncFakes.ts
diff --git a/docs/2026-04-18PLAN4.md b/docs/2026-04-18PLAN4.md
new file mode 100644
index 0000000..1abadbd
--- /dev/null
+++ b/docs/2026-04-18PLAN4.md
@@ -0,0 +1,657 @@
+# 模块 3:全新手动同步型插件的高性能架构开发计划
+
+## Summary
+
+- 本模块不再基于当前插件做兼容式演进,而是按“全新插件”设计。
+- 核心交互改为手动触发,不做默认自动同步。
+- 核心目标不是最少开发量,而是“手动同步体验稳定 + 全库同步性能尽可能高 + 后续可扩展”。
+- 不考虑老卡兼容、不考虑迁移、不考虑复用旧的 `legacy cardKey` 身份体系。
+- 允许插件在“用户主动点击同步”时回写 Markdown 标记,但禁止在日常编辑过程中自动改正文。
+
+## Product Goals
+
+- 支持两个主命令:
+ - `同步当前文件到 Anki`
+ - `同步全库到 Anki`
+- 支持一个维护命令:
+ - `重建卡片索引`
+- 支持标题块卡片模型:
+ - 某一级标题 = Basic 卡
+ - 某一级标题 = Cloze 卡
+- 同步身份稳定:
+ - Markdown 侧身份使用 `cardId`
+ - Anki 侧身份使用 `noteId`
+- 全库同步性能最优先:
+ - 先过滤文件路径,再读文件内容
+ - 文件没变则跳过
+ - 文件变了也只更新变动卡片块
+ - Anki 请求按批次执行,不按单卡逐条发
+
+## Non Goals
+
+- 不做自动实时同步
+- 不做旧插件数据迁移
+- 不做旧卡兼容逻辑
+- 不做双向同步
+- 不做 Anki 卡片模板 HTML/CSS 编辑器
+- 不支持无 marker 的长期身份推断策略
+
+## User Interaction Model
+
+### 1. 默认模式:手动同步
+
+- 插件默认不监听文件改动后自动同步。
+- 只有用户主动点击命令时,才允许:
+ - 扫描文件
+ - 规划同步
+ - 调用 Anki
+ - 回写 Markdown 标记
+
+### 2. 命令设计
+
+- `同步当前文件到 Anki`
+ - 读取当前激活 Markdown 文件
+ - 仅处理该文件内的卡片块
+- `同步全库到 Anki`
+ - 处理配置范围内全部 Markdown 文件
+ - 使用文件级和卡片级缓存跳过未变内容
+- `重建卡片索引`
+ - 重建本地状态
+ - 不默认执行全量 Anki 字段更新
+
+### 3. Notice / Result 输出
+
+- 每次同步结束后展示:
+ - 扫描文件数
+ - 扫描卡片数
+ - 新增卡数
+ - 更新卡数
+ - orphan / 删除数
+ - 上传 media 数
+ - 跳过未变化卡数
+- 若发生 Markdown 回写冲突:
+ - 明确提示哪些文件未完成 marker 写回
+ - 不使用泛化报错文案
+
+## Identity Model
+
+### 1. 双身份设计
+
+- `cardId`
+ - Markdown 侧身份
+ - 代表某个标题块本身
+ - 一旦生成,不再修改
+- `noteId`
+ - Anki 侧身份
+ - 代表 Anki 中的 note
+
+### 2. 标记格式
+
+- 块尾机器标记固定为:
+ - ``
+- 在首次发现卡片但尚未创建 Anki note 时,允许写成:
+ - ``
+
+### 3. 标记位置
+
+- 统一放在标题块末尾:
+ - 当前标题块最后一个非空内容行之后
+ - 下一个同级或更高层级标题之前
+- 目标示例:
+ ```md
+ #### 标题
+ 正文内容
+
+ ```
+
+### 4. 身份规则
+
+- 插件长期只认 `cardId`
+- `noteId` 是 `cardId` 指向的远端目标
+- 不再使用 `标题 + 行号 + 路径` 作为正式身份
+- 不做无 marker 时的长期启发式身份匹配
+
+## Card Model
+
+### 1. 卡片边界
+
+- 从当前目标标题开始
+- 到下一个同级或更高层级标题前结束
+
+### 2. 卡片类型
+
+- `basic`
+- `cloze`
+
+### 3. 每张卡的最小索引字段
+
+- `cardId`
+- `noteId?`
+- `filePath`
+- `heading`
+- `headingLevel`
+- `cardType`
+- `blockStartOffset`
+- `blockEndOffset`
+- `rawBlockText`
+- `rawBlockHash`
+- `markerLine?`
+- `deckHint?`
+- `tagsHint[]`
+
+### 4. 每张卡的渲染字段
+
+- `renderedTitle`
+- `renderedBody`
+- `renderedFields`
+- `mediaManifest`
+- `renderConfigHash`
+
+## Architecture
+
+### 1. Card Marker Service
+
+负责:
+
+- 解析 `AHS` 标记
+- 生成新的 `cardId`
+- 生成标准 marker 文本
+- 在块尾插入或替换 marker
+
+必须保证:
+
+- 一个标题块最多一个合法 marker
+- marker 不进入正文内容
+- 块尾写回顺序稳定
+
+### 2. File Indexer
+
+负责:
+
+- 列出候选 Markdown 文件
+- 先按 include/exclude 过滤路径
+- 读取变更文件内容
+- 提取卡片块
+- 生成文件级 hash 与卡片级 hash
+
+必须保证:
+
+- 不在扫描阶段做完整 HTML render
+- 不在这里调用 Anki
+
+### 3. Diff Planner
+
+负责:
+
+- 比较“当前索引”和“本地状态”
+- 输出:
+ - `toCreate`
+ - `toUpdate`
+ - `toOrphan`
+ - `toDelete`(如果启用)
+ - `toRewriteMarker`
+
+### 4. Renderer
+
+负责:
+
+- 只渲染待同步卡片
+- 解析 markdown / cloze / embeds / wikilinks
+- 生成最终 Anki 字段
+
+必须保证:
+
+- 未变化卡片不进入 renderer
+
+### 5. Anki Batch Executor
+
+负责:
+
+- 批量 createDeck
+- 批量 notesInfo
+- 批量 addNote
+- 批量 updateNoteFields
+- 按 deck 分组批量 changeDeck
+- 批量 tag 操作
+- 批量 storeMedia
+
+### 6. Markdown Write Back Service
+
+负责:
+
+- 按文件聚合 marker 写回
+- 同一文件只写一次
+- optimistic concurrency 校验
+- 失败时记录待补写状态
+
+### 7. Local State Store
+
+负责:
+
+- 持久化文件索引
+- 持久化卡片索引
+- 持久化 `cardId -> noteId`
+- 持久化待补写 marker 状态
+
+## File Discovery And Scan Strategy
+
+### 1. 文件发现顺序
+
+- 先列出全部 Markdown 文件路径
+- 再做 include/exclude 过滤
+- 只读取命中的文件
+
+### 2. 文件级缓存
+
+本地状态必须保存:
+
+- `filePath`
+- `fileHash`
+- `lastIndexedAt`
+- `cardIds[]`
+
+### 3. 文件级跳过策略
+
+- 文件 `fileHash` 未变化:
+ - 全文件跳过
+ - 不提取卡片
+ - 不 render
+ - 不发 Anki 请求
+
+### 4. 卡片级提取
+
+对变更文件:
+
+- 提取标题块
+- 提取 marker
+- 计算 `rawBlockHash`
+- 与本地卡片状态对比
+
+### 5. 卡片级跳过策略
+
+- `rawBlockHash` 未变化:
+ - 卡片跳过
+ - 不 render
+ - 不 update Anki
+
+## Rendering Strategy
+
+### 1. 渲染延后
+
+- 扫描阶段只做轻量提取
+- 只有 `toCreate` / `toUpdate` 才进入渲染阶段
+
+### 2. 渲染配置哈希
+
+除了 `rawBlockHash`,还必须保存:
+
+- `renderConfigHash`
+
+它至少包含:
+
+- note type
+- 字段映射
+- deck 规则
+- backlink 开关
+- cloze 转换开关
+
+这样即使 Markdown 没变,但配置变了,也会触发 update。
+
+### 3. 渲染产物
+
+- `title`
+- `body`
+- `fields`
+- `mediaManifest`
+
+## Sync Planning Rules
+
+### 1. `toCreate`
+
+条件:
+
+- 卡片有 `cardId`
+- 本地状态中没有 `noteId`
+
+### 2. `toUpdate`
+
+条件满足任一:
+
+- 本地有 `noteId` 且 `rawBlockHash` 变化
+- 本地有 `noteId` 且 `renderConfigHash` 变化
+- 本地记录为 pending write-back
+
+### 3. `toOrphan`
+
+条件:
+
+- 本地状态里存在 `cardId`
+- 当前索引中该 `cardId` 已消失
+
+第一版默认:
+
+- 仅标记 orphan
+- 不自动删 Anki note
+
+### 4. `toRewriteMarker`
+
+条件:
+
+- 缺失 `cardId`
+- 已有 `cardId` 但缺失 `noteId`
+- marker 格式可恢复但内容过时
+
+## Anki Execution Strategy
+
+### 1. 请求批处理原则
+
+- 所有 Anki 操作按“操作类型”分批
+- 禁止按单卡逐条独立请求
+- 支持受控 batch size
+- 支持受控并发
+
+### 2. 推荐执行顺序
+
+1. `createDeck`
+2. `notesInfo(existing noteIds)`
+3. `addNote`
+4. `updateNoteFields`
+5. `changeDeck` 按目标 deck 分组
+6. `removeTags` / `addTags`
+7. `storeMedia`
+8. Markdown marker 写回
+
+### 3. `notesInfo`
+
+- 对全部待更新 noteId 一次拉取
+- 不能在 `updateNote` 内部每张卡再补一轮 `notesInfo`
+
+### 4. `changeDeck`
+
+- 先拿 note 对应 cardIds
+- 再按 deck 分组聚合
+- 同目标 deck 的卡一次 `changeDeck`
+
+### 5. Media 上传
+
+- 先全局去重
+- key 至少包含:
+ - `absolutePath`
+ - `targetFileName`
+ - `mtime` 或 content hash
+- 用 3 到 5 并发上传
+- 避免无脑全并发
+
+### 6. 批处理调度器
+
+建议新增 `BatchScheduler`
+
+负责:
+
+- 切分 batch
+- 控制并发数
+- 失败收集
+- 对可重试错误做有限重试
+
+## Markdown Write Back Strategy
+
+### 1. 写回时机
+
+- 仅在用户主动点击同步命令时允许写回
+- 平时编辑过程中绝不自动改正文
+
+### 2. 写回内容
+
+- 补 `cardId`
+- 补 `noteId`
+- 修复过时 marker
+
+### 3. 写回粒度
+
+- 同一文件一次写回
+- 同步结束后按文件聚合执行
+
+### 4. 写回顺序
+
+- 同一文件多个块从下往上处理
+- 避免上方插入影响下方定位
+
+### 5. 冲突控制
+
+- 写回前比较当前文件 hash / expected content
+- 若文件已变化:
+ - 不覆盖
+ - 记录 pending write-back
+ - 在结果 notice 中明确提示
+
+## Local State Design
+
+### 1. 文件层状态
+
+- `filePath`
+- `fileHash`
+- `lastIndexedAt`
+- `cardIds[]`
+
+### 2. 卡片层状态
+
+- `cardId`
+- `noteId?`
+- `filePath`
+- `rawBlockHash`
+- `renderConfigHash`
+- `lastSyncedAt`
+- `cardType`
+- `deck`
+- `orphan`
+
+### 3. 待补写状态
+
+- `filePath`
+- `cardId`
+- `noteId`
+- `expectedFileHash`
+- `targetMarker`
+
+### 4. 存储要求
+
+- 单次同步结束统一保存
+- 必须支持局部覆盖和完整重建
+
+## Commands And UX
+
+### 1. 同步当前文件
+
+流程:
+
+1. 获取当前 Markdown 文件
+2. 提取卡片块
+3. 补齐缺失 `cardId`
+4. 生成计划
+5. 执行 Anki 批同步
+6. 按文件回写 marker
+7. 更新本地状态
+8. 展示结果 notice
+
+### 2. 同步全库
+
+流程:
+
+1. 列出全部 Markdown 文件路径
+2. include/exclude 过滤
+3. 文件 hash 对比,跳过未变化文件
+4. 对变更文件做块级 diff
+5. 渲染待同步卡
+6. 批量执行 Anki 请求
+7. 批量写回 marker
+8. 保存本地状态
+9. 展示结果 notice
+
+### 3. 重建卡片索引
+
+流程:
+
+1. 全库重建文件索引与卡片索引
+2. 不默认发送所有更新到 Anki
+3. 修复本地状态与文件 marker 的一致性
+
+## Recommended Implementation Order
+
+### Phase 1:Identity And Marker
+
+- 实现 `cardId` 生成规则
+- 实现 `AHS` marker 解析与块尾写回
+- 为卡片块补写 `cardId`
+
+### Phase 2:File And Card Index
+
+- 文件路径过滤
+- 文件级 hash
+- 卡片块提取
+- 卡片级 `rawBlockHash`
+
+### Phase 3:Local State
+
+- 文件层状态持久化
+- 卡片层状态持久化
+- pending write-back 状态持久化
+
+### Phase 4:Planner
+
+- `toCreate`
+- `toUpdate`
+- `toOrphan`
+- `toRewriteMarker`
+
+### Phase 5:Renderer
+
+- 只为待同步卡渲染字段
+- 解析 media / wikilink / cloze
+
+### Phase 6:Anki Batch Executor
+
+- 批量 `notesInfo`
+- 批量 `addNote`
+- 批量 `updateNoteFields`
+- deck 分组 `changeDeck`
+- media 批量上传
+
+### Phase 7:Commands And Notice
+
+- 当前文件同步命令
+- 全库同步命令
+- 重建索引命令
+- 结果 notice
+
+## Public Interfaces / Types
+
+### Marker
+
+- `AhsMarker`
+ - `cardId: string`
+ - `noteId?: number`
+ - `raw: string`
+ - `lineIndex: number`
+
+### File Index
+
+- `IndexedFile`
+ - `filePath: string`
+ - `fileHash: string`
+ - `cards: IndexedCard[]`
+
+### Card Index
+
+- `IndexedCard`
+ - `cardId: string`
+ - `noteId?: number`
+ - `filePath: string`
+ - `cardType: "basic" | "cloze"`
+ - `heading: string`
+ - `rawBlockHash: string`
+ - `markerLine?: number`
+
+### Planner
+
+- `SyncPlan`
+ - `toCreate: IndexedCard[]`
+ - `toUpdate: IndexedCard[]`
+ - `toOrphan: string[]`
+ - `toRewriteMarker: IndexedCard[]`
+
+### Local State
+
+- `PluginState`
+ - `files: Record`
+ - `cards: Record`
+ - `pendingWriteBack: PendingWriteBackState[]`
+
+## Test Plan
+
+### 1. Marker / Identity
+
+- 缺失 marker 的卡片块会生成 `cardId`
+- 合法 `AHS` marker 能正确解析 `cardId` 与 `noteId`
+- 非法 marker 报错
+- 同一块多个 marker 报错
+- marker 不进入正文
+
+### 2. File Index
+
+- include/exclude 过滤先于文件读取
+- 文件未变化时整文件跳过
+- 文件变化但卡片块未变化时,卡片跳过
+- 标题块边界提取正确
+
+### 3. Planner
+
+- 无 `noteId` 的 `cardId` 卡进入 `toCreate`
+- `rawBlockHash` 变化进入 `toUpdate`
+- `renderConfigHash` 变化进入 `toUpdate`
+- 已消失卡进入 `toOrphan`
+- pending write-back 卡进入 `toRewriteMarker` 或 `toUpdate`
+
+### 4. Renderer
+
+- 只渲染 `toCreate/toUpdate`
+- cloze 渲染正确
+- media manifest 正确
+- wikilink / backlink 正确
+
+### 5. Anki Batch Executor
+
+- `notesInfo` 对全部待更新 noteId 批量请求
+- `updateNoteFields` 按 batch 执行
+- `changeDeck` 按目标 deck 分组
+- media 上传去重
+- 失败时能返回批次级错误信息
+
+### 6. Markdown Write Back
+
+- 同一文件多个 marker 一次写回
+- 写回顺序自底向上
+- 文件变化时触发冲突,不覆盖
+- 冲突后生成 pending write-back
+
+### 7. Commands
+
+- `同步当前文件` 只影响当前文件
+- `同步全库` 会跳过未变化文件
+- `重建卡片索引` 不默认触发全量远端更新
+
+## Risks
+
+- `cardId` 设计一旦上线不宜再频繁变更
+- 文件级和卡片级缓存同时存在,状态结构会比当前插件复杂
+- Anki 批处理若 batch 过大,可能触发单次请求过重的问题
+- Markdown 块尾写回必须非常稳定,否则会直接影响用户信任
+
+## Decisions Locked For Module 3
+
+- 新插件默认只支持手动同步
+- 不做旧插件迁移
+- 不继续使用 legacy `cardKey`
+- 正式身份采用 `cardId + noteId`
+- 块尾 `AHS` marker 是唯一支持的机器标记形式
+- 全库同步必须实现文件级和卡片级跳过
+- Anki 执行层必须按类型批处理
diff --git a/docs/implementation-decisions.md b/docs/implementation-decisions.md
new file mode 100644
index 0000000..583f5d6
--- /dev/null
+++ b/docs/implementation-decisions.md
@@ -0,0 +1,79 @@
+# 模块 3 实现决策
+
+本文锁定模块 3 的具体实现决策,范围以 `docs/2026-04-18PLAN4.md` 为准。
+
+## 1. 主链路替换策略
+
+- 模块 3 采用新的索引/状态/规划/执行主链路。
+- 现有模块 1/2 代码保留在仓库中,但插件命令入口改接模块 3 use case。
+- 不为旧 `legacy cardKey` / `embeddedNoteId` 做兼容适配。
+
+## 2. marker 与身份
+
+- 统一 marker 格式:``
+- 允许仅写 `cardId`:``
+- `cardId` 采用 `ahs_` 前缀加随机唯一片段生成
+- 缺失 marker 的块在用户执行命令时生成 `cardId`,平时不自动回写
+
+## 3. 文件级跳过实现
+
+- 为满足“先过滤路径,再读文件内容”,新增文件元数据扫描接口
+- 文件状态除计划要求的 `fileHash` 外,额外保存内部 `fileStamp`
+- `fileStamp` 采用 `mtime:size`,只用于决定是否需要重新读取文件
+- `fileHash` 在真正读取文件内容后计算并持久化
+
+## 4. pending write-back 恢复策略
+
+- 计划要求 pending write-back 可恢复,但 marker 缺失时无法仅靠 marker 重新定位
+- 因此增加一个严格受限的内部恢复规则:仅允许在“同一文件 + 相同 `rawBlockHash`”时复用既有 `cardId/noteId`
+- 这不是通用无 marker 身份推断,只用于本地已知 pending 或已存在状态的恢复
+
+## 5. 渲染策略
+
+- 扫描阶段只建立 `IndexedFile/IndexedCard`
+- 仅 `toCreate / toUpdate` 进入 renderer
+- `renderConfigHash` 独立计算,不与 `rawBlockHash` 混用
+- 现有 markdown 渲染逻辑保留并抽到新的模块 3 渲染服务里复用
+
+## 6. Anki 批处理策略
+
+- 新增 `BatchScheduler`
+- `notesInfo / addNotes / updateNoteFields / changeDeck / storeMedia` 按批执行
+- 默认使用保守常量:
+ - `notesInfo` batch size 100
+ - `addNotes / updateNoteFields` batch size 50
+ - `storeMedia` batch size 10,并发 3
+- 不为当前模块额外开放用户设置项,先保持范围收敛
+
+## 7. 本地状态结构
+
+- 新状态统一存入 `pluginState`
+- 结构包含:
+ - `files`
+ - `cards`
+ - `pendingWriteBack`
+- 当前实现不做旧 `syncRegistry` 自动迁移;旧数据保留但模块 3 不读取
+
+## 8. rebuild index
+
+- rebuild index 不调用 Anki 字段更新
+- 它会:
+ - 全量重建文件状态与卡片状态
+ - 为缺失 `cardId` 的块补齐 marker
+ - 尝试用本地状态恢复已知 `noteId`
+
+## 9. 命令与提示
+
+- 命令名称按计划使用中文:
+ - `同步当前文件到 Anki`
+ - `同步全库到 Anki`
+ - `重建卡片索引`
+- Notice 展示:扫描文件数、扫描卡片数、新增、更新、orphan、media、跳过未变化卡数
+- 若有 markdown 回写冲突,明确列出失败文件
+
+## 10. 范围控制
+
+- 不实现旧插件迁移
+- 不实现双向同步
+- 不实现实时监听自动同步
+- 不为 tags 引入额外语法;当前阶段 `tagsHint` 为空数组,但批处理执行层保留接口位置
\ No newline at end of file
diff --git a/docs/implementation-gap-report.md b/docs/implementation-gap-report.md
new file mode 100644
index 0000000..6b8d2d2
--- /dev/null
+++ b/docs/implementation-gap-report.md
@@ -0,0 +1,144 @@
+# 模块 3 实现差距报告
+
+本文以 `docs/2026-04-18PLAN4.md` 为唯一实现基准,对当前仓库实现做差距审计。
+
+## 结论
+
+当前仓库主体仍停留在模块 1/2 的架构:
+
+- 身份模型仍以 `cardKey` 和 `embeddedNoteId` 为核心,而不是 `cardId + noteId`
+- 扫描阶段会对全部卡片立即 render,而不是先索引、后规划、再延迟渲染
+- 本地状态仍是 `syncRegistry` 单层结构,缺失文件层状态和独立 pending write-back 层
+- Anki 执行除了 `notesInfo` 外基本仍是逐卡顺序调用
+- 命令层缺少 `重建卡片索引`
+
+模块 3 不能在当前 `legacy-card-key` 体系上继续增量补丁,必须引入新的索引、状态、规划和执行主链路。
+
+## 主要差距
+
+### 1. 身份与 marker
+
+当前实现:
+
+- `CardIdentityPolicy` 生成 `cardKey`
+- `HeadingSyncMarkerService` 只写 ``
+- `CardExtractionService` 只解析旧 marker 里的 `noteId`
+
+与计划冲突:
+
+- 缺失永久 `cardId`
+- marker 格式错误,未写入 `cardId`
+- 仍保留 `embeddedNoteId` / `identityMode` 这类模块 2 兼容逻辑
+
+### 2. 文件发现与索引
+
+当前实现:
+
+- `ScanAndPlanSyncUseCase` 先取所有 markdown 内容,再过滤,再提取,再 render
+
+与计划冲突:
+
+- 没有路径先过滤再读文件的流程
+- 没有文件级跳过
+- 没有文件层 `fileHash/lastIndexedAt/cardIds[]`
+- 没有卡片级 `rawBlockHash`
+
+### 3. 本地状态
+
+当前实现:
+
+- 仅有 `SyncRegistry`
+- 仓库存储只有 `syncRegistry.records`
+
+与计划冲突:
+
+- 缺失 `files`
+- 缺失新的 `cards`
+- 缺失独立 `pendingWriteBack`
+- 缺失 `renderConfigHash`
+
+### 4. Planner
+
+当前实现:
+
+- `SyncPlanningService` 只产出 `toAdd / toUpdate / toMarkOrphan`
+
+与计划冲突:
+
+- 缺失 `toRewriteMarker`
+- `toUpdate` 依据的是旧 `contentHash`,不是 `rawBlockHash + renderConfigHash`
+- 仍包含 `embeddedNoteId` 的特殊逻辑
+
+### 5. Renderer
+
+当前实现:
+
+- 所有卡片都在扫描阶段立即 render
+
+与计划冲突:
+
+- 未实现“只 render `toCreate / toUpdate`”
+- 缺失 `renderConfigHash`
+- 渲染输出与原始块变化耦合在一个 `contentHash` 中
+
+### 6. Anki 执行层
+
+当前实现:
+
+- `notesInfo` 已批量化
+- `addNote / updateNote / storeMedia` 仍主要是逐卡/逐资源调用
+
+与计划冲突:
+
+- 缺少批处理调度器
+- 缺少按操作类型分批执行
+- 缺少 deck 分组 `changeDeck`
+- 缺少明确的媒体并发控制
+
+### 7. Markdown 回写
+
+当前实现:
+
+- 已有“同文件一次写回、自底向上写回”的局部能力
+
+与计划冲突:
+
+- marker 仍是旧格式
+- pending write-back 仍挂在旧 `identityMode`
+- 结果汇总里缺少回写冲突文件明细
+
+### 8. 命令与 UX
+
+当前实现:
+
+- 只有“同步当前文件”和“同步全库”命令
+- Notice 仅展示少量计数
+
+与计划冲突:
+
+- 缺少“重建卡片索引”
+- 结果 notice 缺少扫描文件数、扫描卡片数、跳过数、回写冲突文件明细
+
+### 9. 测试
+
+当前实现:
+
+- 模块 1/2 的 extraction/planning/write-back 测试较多
+
+与计划冲突:
+
+- 缺少 `cardId` 生成与 marker 新格式测试
+- 缺少文件级跳过测试
+- 缺少 `renderConfigHash` 驱动 update 测试
+- 缺少 rebuild index 测试
+- 缺少批处理调度与结果汇总测试
+
+## 修复方向
+
+模块 3 将按以下方向落地:
+
+1. 新建 `cardId + noteId` 为核心的 marker / index / state / planner / executor 主链路
+2. 保留现有 markdown 渲染逻辑中可复用的部分,但改为延迟渲染
+3. 新增文件层状态与 pending write-back 层
+4. 新增 rebuild index 命令与 use case
+5. 用新的同步 use case 接管插件命令入口
\ No newline at end of file
diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts
index e22ad1c..961106f 100644
--- a/src/application/ports/AnkiGateway.ts
+++ b/src/application/ports/AnkiGateway.ts
@@ -17,14 +17,25 @@ export interface UpdateAnkiNoteInput {
export interface AnkiNoteSummary {
noteId: number;
modelName: string;
+ cardIds: number[];
+}
+
+export interface ChangeDeckInput {
+ deckName: string;
+ cardIds: number[];
}
export interface AnkiGateway {
ensureDeckExists(deckName: string): Promise;
+ ensureDecks(deckNames: string[]): Promise;
listNoteModels(): Promise;
getModelDetails(modelName: string): Promise;
getNoteSummaries(noteIds: number[]): Promise;
addNote(input: AddAnkiNoteInput): Promise;
+ addNotes(inputs: AddAnkiNoteInput[]): Promise;
updateNote(input: UpdateAnkiNoteInput): Promise;
+ updateNotes(inputs: UpdateAnkiNoteInput[]): Promise;
+ changeDecks(inputs: ChangeDeckInput[]): Promise;
storeMedia(asset: MediaAsset): Promise;
+ storeMediaFiles(assets: MediaAsset[]): Promise;
}
\ No newline at end of file
diff --git a/src/application/ports/ManualSyncVaultGateway.ts b/src/application/ports/ManualSyncVaultGateway.ts
new file mode 100644
index 0000000..b09b9a5
--- /dev/null
+++ b/src/application/ports/ManualSyncVaultGateway.ts
@@ -0,0 +1,15 @@
+import type { SourceFile } from "@/domain/card/entities/SourceFile";
+import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver";
+
+export interface MarkdownFileReference {
+ path: string;
+ basename: string;
+ mtime: number;
+ size: number;
+}
+
+export interface ManualSyncVaultGateway extends RenderResourceResolver {
+ listMarkdownFileRefs(): Promise;
+ readMarkdownFile(path: string): Promise;
+ replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise;
+}
\ No newline at end of file
diff --git a/src/application/ports/PluginStateRepository.ts b/src/application/ports/PluginStateRepository.ts
new file mode 100644
index 0000000..1c28ebc
--- /dev/null
+++ b/src/application/ports/PluginStateRepository.ts
@@ -0,0 +1,6 @@
+import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
+
+export interface PluginStateRepository {
+ load(): Promise;
+ save(state: PluginState): Promise;
+}
\ No newline at end of file
diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts
new file mode 100644
index 0000000..a34fee8
--- /dev/null
+++ b/src/application/services/AnkiBatchExecutor.ts
@@ -0,0 +1,210 @@
+import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
+import type { AnkiGateway } from "@/application/ports/AnkiGateway";
+import { BatchScheduler } from "@/application/services/BatchScheduler";
+import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService";
+import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
+import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
+import type { PlannedCard, ManualSyncPlan } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
+
+export interface AnkiBatchExecutionResult {
+ created: number;
+ updated: number;
+ uploadedMedia: number;
+ markerWrites: PlannedCard[];
+ resolvedNoteIds: Map;
+ touchedCardIds: Set;
+}
+
+export class AnkiBatchExecutor {
+ constructor(
+ private readonly ankiGateway: AnkiGateway,
+ private readonly noteFieldMappingService = new NoteFieldMappingService(),
+ private readonly batchScheduler = new BatchScheduler(),
+ ) {}
+
+ async execute(
+ plan: ManualSyncPlan,
+ renderedCards: Map,
+ renderOnDemand: (plannedCard: PlannedCard) => Promise,
+ noteFieldMappings: Record,
+ ): Promise {
+ const resolvedNoteIds = new Map();
+ const touchedCardIds = new Set();
+ const markerWriteMap = new Map();
+ let created = 0;
+ let updated = 0;
+
+ const summaryIds = Array.from(new Set([...plan.toUpdate, ...plan.toRewriteMarker].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 }> = [];
+
+ for (const plannedCard of plan.toCreate) {
+ addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand));
+ }
+
+ for (const plannedCard of plan.toUpdate) {
+ if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) {
+ updateQueue.push({ plannedCard, renderedCard: await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand) });
+ continue;
+ }
+
+ addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand));
+ markerWriteMap.set(plannedCard.card.cardId, plannedCard);
+ }
+
+ for (const plannedCard of plan.toRewriteMarker) {
+ if (markerWriteMap.has(plannedCard.card.cardId)) {
+ continue;
+ }
+
+ if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) {
+ markerWriteMap.set(plannedCard.card.cardId, plannedCard);
+ resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId);
+ continue;
+ }
+
+ addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand));
+ markerWriteMap.set(plannedCard.card.cardId, plannedCard);
+ }
+
+ await this.batchScheduler.runVoidBatches(
+ Array.from(new Set([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)].map((card) => card.deck))),
+ 50,
+ 1,
+ (batch) => this.ankiGateway.ensureDecks(batch),
+ );
+
+ const uploadedMedia = await this.uploadMedia([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)]);
+
+ const addedNoteIds = await this.batchScheduler.runCollectBatches(addQueue, 50, 1, async (batch) => {
+ return this.ankiGateway.addNotes(
+ await Promise.all(batch.map(async (renderedCard) => ({
+ deckName: renderedCard.deck,
+ modelName: renderedCard.noteModel,
+ fields: await this.mapFields(renderedCard, noteFieldMappings),
+ tags: renderedCard.card.tagsHint,
+ }))),
+ );
+ });
+
+ for (let index = 0; index < addQueue.length; index += 1) {
+ const renderedCard = addQueue[index];
+ const noteId = addedNoteIds[index];
+ created += 1;
+ touchedCardIds.add(renderedCard.card.cardId);
+ resolvedNoteIds.set(renderedCard.card.cardId, noteId);
+
+ const existingMarkerWrite = markerWriteMap.get(renderedCard.card.cardId);
+ markerWriteMap.set(renderedCard.card.cardId, {
+ ...(existingMarkerWrite ?? {
+ card: renderedCard.card,
+ deck: renderedCard.deck,
+ noteModel: renderedCard.noteModel,
+ renderConfigHash: renderedCard.renderConfigHash,
+ }),
+ noteId,
+ });
+ }
+
+ await this.batchScheduler.runVoidBatches(updateQueue, 50, 1, async (batch) => {
+ 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),
+ }))),
+ );
+ });
+
+ updated += updateQueue.length;
+ for (const { plannedCard } of updateQueue) {
+ if (plannedCard.noteId) {
+ touchedCardIds.add(plannedCard.card.cardId);
+ resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId);
+ }
+ }
+
+ const deckChanges = new Map();
+ for (const { plannedCard } of updateQueue) {
+ const noteId = plannedCard.noteId;
+ if (!noteId) {
+ continue;
+ }
+
+ const summary = noteSummariesById.get(noteId);
+ if (!summary || summary.cardIds.length === 0) {
+ continue;
+ }
+
+ const cardIds = deckChanges.get(plannedCard.deck) ?? [];
+ cardIds.push(...summary.cardIds);
+ deckChanges.set(plannedCard.deck, cardIds);
+ }
+
+ await this.batchScheduler.runVoidBatches(
+ Array.from(deckChanges.entries()).map(([deckName, cardIds]) => ({ deckName, cardIds })),
+ 25,
+ 1,
+ (batch) => this.ankiGateway.changeDecks(batch),
+ );
+
+ return {
+ created,
+ updated,
+ uploadedMedia,
+ markerWrites: Array.from(markerWriteMap.values()).map((plannedCard) => ({
+ ...plannedCard,
+ noteId: resolvedNoteIds.get(plannedCard.card.cardId) ?? plannedCard.noteId,
+ })),
+ resolvedNoteIds,
+ touchedCardIds,
+ };
+ }
+
+ private async requireRenderedCard(
+ plannedCard: PlannedCard,
+ renderedCards: Map,
+ renderOnDemand: (plannedCard: PlannedCard) => Promise,
+ ): Promise {
+ const existing = renderedCards.get(plannedCard.card.cardId);
+ if (existing) {
+ return existing;
+ }
+
+ const rendered = await renderOnDemand(plannedCard);
+ renderedCards.set(plannedCard.card.cardId, rendered);
+ return rendered;
+ }
+
+ private async mapFields(
+ renderedCard: RenderedSyncCard,
+ noteFieldMappings: Record,
+ ): Promise> {
+ const modelDetails = await this.ankiGateway.getModelDetails(renderedCard.noteModel);
+ return this.noteFieldMappingService.mapRenderedCard(
+ {
+ type: renderedCard.card.cardType,
+ noteModel: renderedCard.noteModel,
+ renderedFields: renderedCard.renderedFields,
+ },
+ modelDetails,
+ noteFieldMappings,
+ );
+ }
+
+ private async uploadMedia(renderedCards: RenderedSyncCard[]): Promise {
+ const uniqueMedia = new Map();
+
+ for (const renderedCard of renderedCards) {
+ for (const asset of renderedCard.media) {
+ uniqueMedia.set(`${asset.kind}:${asset.absolutePath}:${asset.fileName}`, asset);
+ }
+ }
+
+ await this.batchScheduler.runVoidBatches(Array.from(uniqueMedia.values()), 10, 3, (batch) => this.ankiGateway.storeMediaFiles(batch));
+ return uniqueMedia.size;
+ }
+}
\ No newline at end of file
diff --git a/src/application/services/BatchScheduler.ts b/src/application/services/BatchScheduler.ts
new file mode 100644
index 0000000..a54d979
--- /dev/null
+++ b/src/application/services/BatchScheduler.ts
@@ -0,0 +1,50 @@
+export class BatchScheduler {
+ async runCollectBatches(
+ items: TInput[],
+ batchSize: number,
+ concurrency: number,
+ worker: (batch: TInput[]) => Promise,
+ ): Promise {
+ if (items.length === 0) {
+ return [];
+ }
+
+ const batches = chunk(items, batchSize);
+ const results: TResult[][] = new Array(batches.length);
+ let nextIndex = 0;
+
+ await Promise.all(
+ Array.from({ length: Math.min(concurrency, batches.length) }, async () => {
+ while (nextIndex < batches.length) {
+ const currentIndex = nextIndex;
+ nextIndex += 1;
+ results[currentIndex] = await worker(batches[currentIndex]);
+ }
+ }),
+ );
+
+ return results.flat();
+ }
+
+ async runVoidBatches(
+ items: TInput[],
+ batchSize: number,
+ concurrency: number,
+ worker: (batch: TInput[]) => Promise,
+ ): Promise {
+ await this.runCollectBatches(items, batchSize, concurrency, async (batch) => {
+ await worker(batch);
+ return [];
+ });
+ }
+}
+
+function chunk(items: T[], batchSize: number): T[][] {
+ const batches: T[][] = [];
+
+ for (let index = 0; index < items.length; index += batchSize) {
+ batches.push(items.slice(index, index + batchSize));
+ }
+
+ return batches;
+}
\ No newline at end of file
diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts
new file mode 100644
index 0000000..296f353
--- /dev/null
+++ b/src/application/services/FileIndexerService.test.ts
@@ -0,0 +1,75 @@
+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";
+
+describe("FileIndexerService", () => {
+ it("filters by path before reading file content", async () => {
+ const vaultGateway = new FakeManualSyncVaultGateway({
+ "keep/one.md": ["#### One", "Body"].join("\n"),
+ "skip/two.md": ["#### Two", "Body"].join("\n"),
+ });
+ const service = new FileIndexerService(vaultGateway);
+
+ await service.indexVault(
+ createModule3Settings({
+ includeFolders: ["keep"],
+ }),
+ createEmptyPluginState(),
+ );
+
+ expect(vaultGateway.readCalls).toEqual(["keep/one.md"]);
+ });
+
+ it("skips unchanged files by file stamp and reuses stored card state", async () => {
+ const vaultGateway = new FakeManualSyncVaultGateway({
+ "notes/one.md": ["#### One", "Body"].join("\n"),
+ });
+ const service = new FileIndexerService(vaultGateway);
+ const state = {
+ files: {
+ "notes/one.md": {
+ filePath: "notes/one.md",
+ fileHash: "hash-a",
+ fileStamp: `1:${["#### One", "Body"].join("\n").length}`,
+ 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: ["#### One", "Body"].join("\n"),
+ rawBlockHash: "hash-card",
+ renderConfigHash: "render-hash",
+ deck: "Obsidian",
+ tagsHint: [],
+ lastSyncedAt: 1,
+ orphan: false,
+ },
+ },
+ pendingWriteBack: [],
+ };
+
+ const result = await service.indexVault(createModule3Settings(), state);
+
+ expect(result.skippedUnchangedFiles).toBe(1);
+ expect(result.skippedUnchangedCards).toBe(1);
+ expect(vaultGateway.readCalls).toEqual([]);
+ expect(result.cards[0]).toMatchObject({ cardId: "ahs_1", noteId: 10 });
+ });
+});
\ No newline at end of file
diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts
new file mode 100644
index 0000000..d338d06
--- /dev/null
+++ b/src/application/services/FileIndexerService.ts
@@ -0,0 +1,155 @@
+import type { PluginSettings } from "@/application/config/PluginSettings";
+import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
+import { ScanScopeService } from "@/application/services/ScanScopeService";
+import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
+import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
+import type { CardState, PluginState } from "@/domain/manual-sync/entities/PluginState";
+import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService";
+
+export interface FileIndexerResult {
+ scopedFilePaths: string[];
+ scannedFiles: number;
+ indexedFiles: IndexedFile[];
+ cards: IndexedCard[];
+ skippedUnchangedFiles: number;
+ skippedUnchangedCards: number;
+}
+
+export class FileIndexerService {
+ constructor(
+ private readonly vaultGateway: ManualSyncVaultGateway,
+ private readonly scanScopeService = new ScanScopeService(),
+ private readonly cardIndexingService = new CardIndexingService(),
+ ) {}
+
+ async indexVault(settings: PluginSettings, state: PluginState, forceReadAll = false): Promise {
+ const refs = this.scanScopeService.filter(await this.vaultGateway.listMarkdownFileRefs(), settings.includeFolders, settings.excludeFolders);
+ return this.indexRefs(refs, settings, state, forceReadAll);
+ }
+
+ async indexFile(filePath: string, settings: PluginSettings, state: PluginState): Promise {
+ const refs = await this.vaultGateway.listMarkdownFileRefs();
+ const reference = refs.find((entry) => entry.path === filePath);
+ const sourceFile = await this.vaultGateway.readMarkdownFile(filePath);
+
+ if (!sourceFile) {
+ throw new Error(`Markdown file not found: ${filePath}`);
+ }
+
+ const fileStamp = reference ? createFileStamp(reference.mtime, reference.size) : `${Date.now()}:${sourceFile.content.length}`;
+ const indexedFile = this.cardIndexingService.index(sourceFile, {
+ qaHeadingLevel: settings.qaHeadingLevel,
+ clozeHeadingLevel: settings.clozeHeadingLevel,
+ fileStamp,
+ knownCards: Object.values(state.cards).filter((card) => card.filePath === filePath),
+ pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath === filePath),
+ });
+
+ return {
+ scopedFilePaths: [filePath],
+ scannedFiles: 1,
+ indexedFiles: [indexedFile],
+ cards: indexedFile.cards,
+ skippedUnchangedFiles: 0,
+ skippedUnchangedCards: 0,
+ };
+ }
+
+ private async indexRefs(
+ refs: Awaited>,
+ settings: PluginSettings,
+ state: PluginState,
+ forceReadAll: boolean,
+ ): Promise {
+ const indexedFiles: IndexedFile[] = [];
+ const cards: IndexedCard[] = [];
+ let skippedUnchangedFiles = 0;
+ let skippedUnchangedCards = 0;
+
+ for (const ref of refs) {
+ const fileStamp = createFileStamp(ref.mtime, ref.size);
+ const existingFileState = state.files[ref.path];
+ const hasPendingWriteBack = state.pendingWriteBack.some((pending) => pending.filePath === ref.path);
+ const knownCards = Object.values(state.cards).filter((card) => card.filePath === ref.path);
+ const hasMissingKnownCard = existingFileState?.cardIds.some((cardId) => !state.cards[cardId]) ?? false;
+ const shouldRead =
+ forceReadAll ||
+ hasPendingWriteBack ||
+ !existingFileState ||
+ existingFileState.fileStamp !== fileStamp ||
+ hasMissingKnownCard;
+
+ if (!shouldRead) {
+ skippedUnchangedFiles += 1;
+ skippedUnchangedCards += existingFileState?.cardIds.length ?? 0;
+ const restoredCards = (existingFileState?.cardIds ?? [])
+ .map((cardId) => state.cards[cardId])
+ .filter((card): card is CardState => Boolean(card))
+ .map((card) => restoreIndexedCard(card));
+
+ indexedFiles.push({
+ filePath: ref.path,
+ fileHash: existingFileState?.fileHash ?? "",
+ fileStamp,
+ cards: restoredCards,
+ });
+ cards.push(...restoredCards);
+ continue;
+ }
+
+ const sourceFile = await this.vaultGateway.readMarkdownFile(ref.path);
+ if (!sourceFile) {
+ continue;
+ }
+
+ const indexedFile = this.cardIndexingService.index(sourceFile, {
+ qaHeadingLevel: settings.qaHeadingLevel,
+ clozeHeadingLevel: settings.clozeHeadingLevel,
+ fileStamp,
+ knownCards,
+ pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath === ref.path),
+ });
+
+ indexedFiles.push(indexedFile);
+ cards.push(...indexedFile.cards);
+ }
+
+ return {
+ scopedFilePaths: refs.map((ref) => ref.path),
+ scannedFiles: refs.length,
+ indexedFiles,
+ cards,
+ skippedUnchangedFiles,
+ skippedUnchangedCards,
+ };
+ }
+}
+
+function restoreIndexedCard(card: CardState): IndexedCard {
+ return {
+ cardId: card.cardId,
+ noteId: card.noteId,
+ markerNoteId: card.noteId,
+ filePath: card.filePath,
+ cardType: card.cardType,
+ heading: card.heading,
+ headingLevel: card.headingLevel,
+ bodyMarkdown: card.bodyMarkdown,
+ blockStartOffset: card.blockStartOffset,
+ blockEndOffset: card.blockEndOffset,
+ blockStartLine: card.blockStartLine,
+ bodyStartLine: card.bodyStartLine,
+ blockEndLine: card.blockEndLine,
+ contentEndLine: card.contentEndLine,
+ markerLine: card.markerLine,
+ rawBlockText: card.rawBlockText,
+ rawBlockHash: card.rawBlockHash,
+ deckHint: card.deckHint,
+ tagsHint: card.tagsHint,
+ markerState: card.noteId ? "card-and-note" : "card-only",
+ };
+}
+
+export function createFileStamp(mtime: number, size: number): string {
+ return `${mtime}:${size}`;
+}
\ No newline at end of file
diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts
new file mode 100644
index 0000000..e1d50fd
--- /dev/null
+++ b/src/application/services/ManualSyncService.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import { ManualSyncService } from "./ManualSyncService";
+import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes";
+
+describe("ManualSyncService", () => {
+ it("syncs one file, creates Anki notes, writes the new marker format, and saves plugin state", async () => {
+ const vaultGateway = new FakeManualSyncVaultGateway({
+ "notes/example.md": ["#### Prompt", "Answer"].join("\n"),
+ });
+ const stateRepository = new InMemoryPluginStateRepository();
+ const ankiGateway = new FakeManualSyncAnkiGateway();
+ const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
+
+ const result = await service.syncFile("notes/example.md", createModule3Settings());
+
+ expect(result.created).toBe(1);
+ expect(result.updated).toBe(0);
+ expect(vaultGateway.getFileContent("notes/example.md")).toContain("",
+ rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")),
+ },
+ ],
+ },
+ );
+
+ expect(indexedFile.cards[0]).toMatchObject({
+ cardId: "ahs_known",
+ noteId: 42,
+ });
+ });
+
+ it("rejects misplaced or multiple markers inside a single heading block", () => {
+ const service = new CardIndexingService();
+
+ expect(() =>
+ service.index(
+ {
+ path: "notes/example.md",
+ basename: "example",
+ content: ["#### Prompt", "", "Answer", ""].join("\n"),
+ },
+ {
+ qaHeadingLevel: 4,
+ clozeHeadingLevel: 5,
+ fileStamp: "1:1",
+ knownCards: [],
+ pendingWriteBack: [],
+ },
+ ),
+ ).toThrow("Multiple AHS markers");
+ });
+});
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts
new file mode 100644
index 0000000..4bdd174
--- /dev/null
+++ b/src/domain/manual-sync/services/CardIndexingService.ts
@@ -0,0 +1,354 @@
+import type { SourceFile } from "@/domain/card/entities/SourceFile";
+import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
+import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
+import type { CardState, PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
+import { hashString } from "@/domain/shared/hash";
+
+import { CardMarkerError, CardMarkerService } from "./CardMarkerService";
+
+interface HeadingMatch {
+ level: number;
+ text: string;
+ lineIndex: number;
+}
+
+interface MarkerExtractionResult {
+ bodyLines: string[];
+ markerCardId?: string;
+ markerNoteId?: number;
+ contentEndLine: number;
+ markerLine?: number;
+ markerState: IndexedCard["markerState"];
+}
+
+export interface CardIndexingContext {
+ qaHeadingLevel: number;
+ clozeHeadingLevel: number;
+ fileStamp: string;
+ knownCards: CardState[];
+ pendingWriteBack: PendingWriteBackState[];
+}
+
+const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/;
+const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i;
+
+export class CardIndexingService {
+ constructor(private readonly markerService = new CardMarkerService()) {}
+
+ index(sourceFile: SourceFile, context: CardIndexingContext): IndexedFile {
+ validateHeadingPolicy(context.qaHeadingLevel, context.clozeHeadingLevel);
+
+ const lines = sourceFile.content.split(/\r?\n/);
+ const lineStartOffsets = computeLineStartOffsets(sourceFile.content);
+ const headings = collectHeadings(lines);
+ const targetDeck = extractTargetDeck(lines);
+ const cards: IndexedCard[] = [];
+ const knownCardsById = new Map(context.knownCards.map((card) => [card.cardId, card]));
+ const pendingByCardId = new Map(context.pendingWriteBack.map((pending) => [pending.cardId, pending]));
+ const reusableKnownCards = context.knownCards.filter((card) => !card.orphan);
+ const usedCardIds = new Set();
+
+ for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) {
+ const heading = headings[headingIndex];
+ const cardType = resolveCardType(heading.level, context.qaHeadingLevel, context.clozeHeadingLevel);
+ if (!cardType) {
+ continue;
+ }
+
+ const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length);
+ const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex);
+ const marker = extractMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1, this.markerService);
+ const trimmedBodyLines = trimBlankEdges(marker.bodyLines);
+ const bodyMarkdown = trimmedBodyLines.join("\n");
+ const rawBlockText = [lines[heading.lineIndex], ...trimmedBodyLines].join("\n").trimEnd();
+ const rawBlockHash = hashString(rawBlockText);
+ const resolvedIdentity = this.resolveIdentity(
+ sourceFile.path,
+ rawBlockHash,
+ marker.markerCardId,
+ marker.markerNoteId,
+ knownCardsById,
+ pendingByCardId,
+ reusableKnownCards,
+ usedCardIds,
+ );
+
+ usedCardIds.add(resolvedIdentity.cardId);
+
+ cards.push({
+ cardId: resolvedIdentity.cardId,
+ noteId: resolvedIdentity.noteId,
+ markerNoteId: marker.markerNoteId,
+ filePath: sourceFile.path,
+ cardType,
+ heading: heading.text,
+ headingLevel: heading.level,
+ bodyMarkdown,
+ blockStartOffset: lineStartOffsets[heading.lineIndex] ?? 0,
+ blockEndOffset: blockEndLineIndex < lines.length ? (lineStartOffsets[blockEndLineIndex] ?? sourceFile.content.length) : sourceFile.content.length,
+ blockStartLine: heading.lineIndex + 1,
+ bodyStartLine: heading.lineIndex + 2,
+ blockEndLine: blockEndLineIndex,
+ contentEndLine: marker.contentEndLine,
+ markerLine: marker.markerLine,
+ rawBlockText,
+ rawBlockHash,
+ deckHint: targetDeck,
+ tagsHint: [],
+ markerState: marker.markerState,
+ sourceContent: sourceFile.content,
+ });
+ }
+
+ return {
+ filePath: sourceFile.path,
+ fileHash: hashString(sourceFile.content),
+ fileStamp: context.fileStamp,
+ content: sourceFile.content,
+ cards,
+ };
+ }
+
+ private resolveIdentity(
+ filePath: string,
+ rawBlockHash: string,
+ markerCardId: string | undefined,
+ markerNoteId: number | undefined,
+ knownCardsById: Map,
+ pendingByCardId: Map,
+ reusableKnownCards: CardState[],
+ usedCardIds: Set,
+ ): { cardId: string; noteId?: number } {
+ if (markerCardId) {
+ const pending = pendingByCardId.get(markerCardId);
+ const known = knownCardsById.get(markerCardId);
+
+ return {
+ cardId: markerCardId,
+ noteId: pending?.noteId ?? known?.noteId ?? markerNoteId,
+ };
+ }
+
+ const pendingMatch = Array.from(pendingByCardId.values()).find(
+ (pending) => pending.filePath === filePath && pending.rawBlockHash === rawBlockHash && !usedCardIds.has(pending.cardId),
+ );
+ if (pendingMatch) {
+ return {
+ cardId: pendingMatch.cardId,
+ noteId: pendingMatch.noteId,
+ };
+ }
+
+ const knownMatch = reusableKnownCards.find(
+ (card) => card.filePath === filePath && card.rawBlockHash === rawBlockHash && !usedCardIds.has(card.cardId),
+ );
+ if (knownMatch) {
+ return {
+ cardId: knownMatch.cardId,
+ noteId: knownMatch.noteId,
+ };
+ }
+
+ return {
+ cardId: this.markerService.generateCardId(),
+ };
+ }
+}
+
+function validateHeadingPolicy(qaHeadingLevel: number, clozeHeadingLevel: number): void {
+ if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) {
+ throw new Error("QA heading level must be an integer between 1 and 6.");
+ }
+
+ if (!Number.isInteger(clozeHeadingLevel) || clozeHeadingLevel < 1 || clozeHeadingLevel > 6) {
+ throw new Error("Cloze heading level must be an integer between 1 and 6.");
+ }
+
+ if (qaHeadingLevel === clozeHeadingLevel) {
+ throw new Error("QA and Cloze heading levels must be different.");
+ }
+}
+
+function collectHeadings(lines: string[]): HeadingMatch[] {
+ const headings: HeadingMatch[] = [];
+ let fenceMarker: string | null = null;
+
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
+ const line = lines[lineIndex];
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
+ fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
+ continue;
+ }
+
+ if (fenceMarker) {
+ continue;
+ }
+
+ const match = line.match(HEADING_REGEXP);
+ if (!match) {
+ continue;
+ }
+
+ headings.push({
+ level: match[1].length,
+ text: match[2].replace(/\s+#+\s*$/, "").trim(),
+ lineIndex,
+ });
+ }
+
+ return headings;
+}
+
+function extractTargetDeck(lines: string[]): string | undefined {
+ let fenceMarker: string | null = null;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
+ fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
+ continue;
+ }
+
+ if (fenceMarker) {
+ continue;
+ }
+
+ const match = line.match(TARGET_DECK_REGEXP);
+ if (match) {
+ return match[1].trim();
+ }
+ }
+
+ return undefined;
+}
+
+function resolveCardType(level: number, qaHeadingLevel: number, clozeHeadingLevel: number): IndexedCard["cardType"] | null {
+ if (level === qaHeadingLevel) {
+ return "basic";
+ }
+
+ if (level === clozeHeadingLevel) {
+ return "cloze";
+ }
+
+ return null;
+}
+
+function findBlockEndLineIndex(headings: HeadingMatch[], currentHeadingIndex: number, totalLineCount: number): number {
+ const currentHeading = headings[currentHeadingIndex];
+
+ for (let headingIndex = currentHeadingIndex + 1; headingIndex < headings.length; headingIndex += 1) {
+ const nextHeading = headings[headingIndex];
+ if (nextHeading.level <= currentHeading.level) {
+ return nextHeading.lineIndex;
+ }
+ }
+
+ return totalLineCount;
+}
+
+function trimBlankEdges(lines: string[]): string[] {
+ let startIndex = 0;
+ let endIndex = lines.length;
+
+ while (startIndex < endIndex && !lines[startIndex].trim()) {
+ startIndex += 1;
+ }
+
+ while (endIndex > startIndex && !lines[endIndex - 1].trim()) {
+ endIndex -= 1;
+ }
+
+ return lines.slice(startIndex, endIndex);
+}
+
+function extractMarker(
+ bodyLines: string[],
+ bodyStartLine: number,
+ headingLine: number,
+ markerService: CardMarkerService,
+): MarkerExtractionResult {
+ const candidates = bodyLines
+ .map((line, index) => ({ line, index }))
+ .filter(({ line }) => markerService.isCandidate(line));
+ const validMarkers = candidates
+ .map(({ line, index }) => markerService.parse(line, bodyStartLine + index))
+ .filter((marker): marker is NonNullable => Boolean(marker));
+
+ if (validMarkers.length > 1) {
+ throw new CardMarkerError(`Multiple AHS markers found in heading block at line ${headingLine}.`);
+ }
+
+ let lastNonEmptyIndex = bodyLines.length - 1;
+ while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) {
+ lastNonEmptyIndex -= 1;
+ }
+
+ if (lastNonEmptyIndex < 0) {
+ if (candidates.length > 0) {
+ throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`);
+ }
+
+ return {
+ bodyLines,
+ contentEndLine: headingLine,
+ markerState: "missing",
+ };
+ }
+
+ const lastLine = bodyLines[lastNonEmptyIndex];
+ const lastMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex);
+
+ if (lastMarker) {
+ for (const candidate of candidates) {
+ if (candidate.index !== lastNonEmptyIndex) {
+ throw new CardMarkerError(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`);
+ }
+ }
+
+ const nextBodyLines = bodyLines.filter((_line, index) => index !== lastNonEmptyIndex);
+ return {
+ bodyLines: nextBodyLines,
+ markerCardId: lastMarker.cardId,
+ markerNoteId: lastMarker.noteId,
+ contentEndLine: findContentEndLine(nextBodyLines, bodyStartLine, headingLine),
+ markerLine: bodyStartLine + lastNonEmptyIndex,
+ markerState: lastMarker.noteId ? "card-and-note" : "card-only",
+ };
+ }
+
+ if (candidates.length > 0) {
+ throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`);
+ }
+
+ return {
+ bodyLines,
+ contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine),
+ markerState: "missing",
+ };
+}
+
+function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number {
+ for (let index = bodyLines.length - 1; index >= 0; index -= 1) {
+ if (bodyLines[index].trim()) {
+ return bodyStartLine + index;
+ }
+ }
+
+ return headingLine;
+}
+
+function computeLineStartOffsets(content: string): number[] {
+ const offsets = [0];
+
+ for (let index = 0; index < content.length; index += 1) {
+ if (content[index] === "\n") {
+ offsets.push(index + 1);
+ }
+ }
+
+ return offsets;
+}
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/CardMarkerService.test.ts b/src/domain/manual-sync/services/CardMarkerService.test.ts
new file mode 100644
index 0000000..0d9c06e
--- /dev/null
+++ b/src/domain/manual-sync/services/CardMarkerService.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import { CardMarkerService } from "./CardMarkerService";
+
+describe("CardMarkerService", () => {
+ it("parses cardId and noteId from the module 3 marker format", () => {
+ const service = new CardMarkerService(() => 1);
+
+ expect(service.parse("", 3)).toEqual({
+ cardId: "ahs_123",
+ noteId: 42,
+ raw: "",
+ lineIndex: 3,
+ });
+ });
+
+ it("writes multiple markers bottom-up in a single file", () => {
+ const service = new CardMarkerService(() => 1);
+ const sourceContent = ["#### Top", "Body 1", "", "#### Bottom", "Body 2"].join("\n");
+
+ const nextContent = service.applyBatch(sourceContent, [
+ {
+ filePath: "notes/example.md",
+ cardId: "ahs_top",
+ noteId: 11,
+ blockStartLine: 1,
+ contentEndLine: 2,
+ blockEndLine: 3,
+ sourceContent,
+ },
+ {
+ filePath: "notes/example.md",
+ cardId: "ahs_bottom",
+ noteId: 22,
+ blockStartLine: 4,
+ contentEndLine: 5,
+ blockEndLine: 5,
+ sourceContent,
+ },
+ ]);
+
+ expect(nextContent).toBe([
+ "#### Top",
+ "Body 1",
+ "",
+ "",
+ "#### Bottom",
+ "Body 2",
+ "",
+ ].join("\n"));
+ });
+});
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts
new file mode 100644
index 0000000..15274c9
--- /dev/null
+++ b/src/domain/manual-sync/services/CardMarkerService.ts
@@ -0,0 +1,117 @@
+import { hashString } from "@/domain/shared/hash";
+import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
+import type { AhsMarker } from "@/domain/manual-sync/entities/AhsMarker";
+
+export interface MarkerWriteRequest {
+ filePath: string;
+ cardId: string;
+ noteId?: number;
+ blockStartLine: number;
+ contentEndLine: number;
+ blockEndLine: number;
+ markerLine?: number;
+ sourceContent: string;
+}
+
+export class CardMarkerError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "CardMarkerError";
+ }
+}
+
+const AHS_MARKER_CANDIDATE_REGEXP = /\s*$/;
+
+export class CardMarkerService {
+ private counter = 0;
+
+ constructor(private readonly now: () => number = () => Date.now()) {}
+
+ isCandidate(line: string): boolean {
+ return AHS_MARKER_CANDIDATE_REGEXP.test(line);
+ }
+
+ parse(line: string, lineIndex: number): AhsMarker | null {
+ const match = line.match(AHS_MARKER_REGEXP);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ cardId: match[1],
+ noteId: match[2] ? Number(match[2]) : undefined,
+ raw: match[0].trim(),
+ lineIndex,
+ };
+ }
+
+ create(cardId: string, noteId?: number): AhsMarker {
+ return {
+ cardId,
+ noteId,
+ raw: noteId ? `` : ``,
+ lineIndex: -1,
+ };
+ }
+
+ createForCard(card: IndexedCard): AhsMarker {
+ return this.create(card.cardId, card.noteId);
+ }
+
+ generateCardId(): string {
+ this.counter += 1;
+ return `ahs_${hashString(`${this.now()}|${this.counter}|${Math.random()}`).slice(0, 12)}`;
+ }
+
+ applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string {
+ if (writes.length === 0) {
+ return sourceContent;
+ }
+
+ const filePath = writes[0]?.filePath;
+ const seenBlocks = new Set();
+ const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n";
+ const lines = sourceContent.split(/\r?\n/);
+
+ for (const write of writes) {
+ if (write.filePath !== filePath) {
+ throw new CardMarkerError("Batch marker writes must belong to the same Markdown file.");
+ }
+
+ if (write.sourceContent !== sourceContent) {
+ throw new CardMarkerError(`Marker writes for ${write.filePath} must share the scanned source content.`);
+ }
+
+ if (write.markerLine === undefined && write.contentEndLine > write.blockEndLine) {
+ throw new CardMarkerError(`Cannot insert marker outside the heading block in ${write.filePath}.`);
+ }
+
+ if (write.markerLine !== undefined && write.markerLine < write.blockStartLine) {
+ throw new CardMarkerError(`Cannot replace marker outside the heading block in ${write.filePath}.`);
+ }
+
+ if (seenBlocks.has(write.blockStartLine)) {
+ throw new CardMarkerError(`Duplicate marker write detected for block ${write.blockStartLine} in ${write.filePath}.`);
+ }
+
+ seenBlocks.add(write.blockStartLine);
+ }
+
+ const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine);
+ for (const write of sortedWrites) {
+ const adjustedBlockEndLine = write.markerLine ? write.blockEndLine - 1 : write.blockEndLine;
+ if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) {
+ throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`);
+ }
+
+ if (write.markerLine) {
+ lines.splice(write.markerLine - 1, 1);
+ }
+
+ lines.splice(write.contentEndLine, 0, this.create(write.cardId, write.noteId).raw);
+ }
+
+ return lines.join(lineEnding);
+ }
+}
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts
new file mode 100644
index 0000000..2a336aa
--- /dev/null
+++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from "vitest";
+
+import { createModule3Settings } from "@/test-support/manualSyncFakes";
+import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState";
+
+import { DiffPlannerService } from "./DiffPlannerService";
+
+describe("DiffPlannerService", () => {
+ it("places cardId-only cards without noteId into toCreate", () => {
+ const service = new DiffPlannerService();
+ const plan = service.plan(
+ [
+ {
+ cardId: "ahs_1",
+ filePath: "notes/example.md",
+ cardType: "basic",
+ heading: "Prompt",
+ headingLevel: 4,
+ bodyMarkdown: "Body",
+ blockStartOffset: 0,
+ blockEndOffset: 16,
+ blockStartLine: 1,
+ bodyStartLine: 2,
+ blockEndLine: 2,
+ contentEndLine: 2,
+ rawBlockText: ["#### Prompt", "Body"].join("\n"),
+ rawBlockHash: "hash-card",
+ tagsHint: [],
+ markerState: "card-only",
+ },
+ ],
+ createEmptyPluginState(),
+ ["notes/example.md"],
+ createModule3Settings(),
+ );
+
+ expect(plan.toCreate).toHaveLength(1);
+ });
+
+ it("uses rawBlockHash, renderConfigHash, and pending entries to schedule updates and rewrites", () => {
+ const service = new DiffPlannerService();
+ const state = {
+ files: {},
+ cards: {
+ ahs_1: {
+ cardId: "ahs_1",
+ noteId: 42,
+ filePath: "notes/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: "old-render",
+ deck: "Old",
+ tagsHint: [],
+ lastSyncedAt: 1,
+ orphan: false,
+ },
+ },
+ pendingWriteBack: [
+ {
+ filePath: "notes/example.md",
+ cardId: "ahs_1",
+ noteId: 42,
+ expectedFileHash: "hash",
+ targetMarker: "",
+ rawBlockHash: "hash-card",
+ },
+ ],
+ };
+
+ const plan = service.plan(
+ [
+ {
+ cardId: "ahs_1",
+ noteId: 42,
+ markerNoteId: 41,
+ filePath: "notes/example.md",
+ cardType: "basic",
+ heading: "Prompt",
+ headingLevel: 4,
+ bodyMarkdown: "Body",
+ blockStartOffset: 0,
+ blockEndOffset: 16,
+ blockStartLine: 1,
+ bodyStartLine: 2,
+ blockEndLine: 2,
+ contentEndLine: 2,
+ rawBlockText: ["#### Prompt", "Body"].join("\n"),
+ rawBlockHash: "hash-card",
+ tagsHint: [],
+ markerState: "card-and-note",
+ },
+ ],
+ state,
+ ["notes/example.md"],
+ createModule3Settings(),
+ );
+
+ expect(plan.toUpdate).toHaveLength(1);
+ expect(plan.toRewriteMarker).toHaveLength(1);
+ });
+
+ it("marks missing scoped cards as orphan", () => {
+ const service = new DiffPlannerService();
+ const state = {
+ files: {},
+ cards: {
+ ahs_missing: {
+ cardId: "ahs_missing",
+ noteId: 1,
+ filePath: "notes/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: "hash-card",
+ renderConfigHash: "render-hash",
+ deck: "Obsidian",
+ tagsHint: [],
+ lastSyncedAt: 1,
+ orphan: false,
+ },
+ },
+ pendingWriteBack: [],
+ };
+
+ const plan = service.plan([], state, ["notes/example.md"], createModule3Settings());
+
+ expect(plan.toOrphan.map((card) => card.cardId)).toEqual(["ahs_missing"]);
+ });
+});
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts
new file mode 100644
index 0000000..80dc6b9
--- /dev/null
+++ b/src/domain/manual-sync/services/DiffPlannerService.ts
@@ -0,0 +1,81 @@
+import type { PluginSettings } from "@/application/config/PluginSettings";
+import { RenderConfigService } from "@/application/services/RenderConfigService";
+import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
+import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
+import type { ManualSyncPlan, PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
+
+export class DiffPlannerService {
+ constructor(private readonly renderConfigService = new RenderConfigService()) {}
+
+ plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan {
+ const cardsById = new Map();
+ const pendingByCardId = new Map(state.pendingWriteBack.map((pending) => [pending.cardId, pending]));
+ const toCreate: PlannedCard[] = [];
+ const toUpdate: PlannedCard[] = [];
+ const toRewriteMarker: PlannedCard[] = [];
+ let unchangedCards = 0;
+
+ for (const card of cards) {
+ if (cardsById.has(card.cardId)) {
+ throw new Error(`Duplicate cardId detected in current scan: ${card.cardId}`);
+ }
+
+ cardsById.set(card.cardId, card);
+ const existingState = state.cards[card.cardId];
+ const renderPlan = this.renderConfigService.resolve(card, settings);
+ const resolvedNoteId = existingState?.noteId ?? card.noteId;
+ const plannedCard: PlannedCard = {
+ card: {
+ ...card,
+ noteId: resolvedNoteId,
+ },
+ noteId: resolvedNoteId,
+ deck: renderPlan.deck,
+ noteModel: renderPlan.noteModel,
+ renderConfigHash: renderPlan.renderConfigHash,
+ };
+
+ if (!resolvedNoteId) {
+ toCreate.push(plannedCard);
+ continue;
+ }
+
+ if (
+ card.markerState === "missing" ||
+ card.markerState === "card-only" ||
+ card.markerNoteId !== resolvedNoteId ||
+ pendingByCardId.has(card.cardId)
+ ) {
+ toRewriteMarker.push(plannedCard);
+ }
+
+ if (!existingState) {
+ toUpdate.push(plannedCard);
+ continue;
+ }
+
+ if (
+ existingState.rawBlockHash !== card.rawBlockHash ||
+ existingState.renderConfigHash !== renderPlan.renderConfigHash ||
+ existingState.orphan ||
+ pendingByCardId.has(card.cardId)
+ ) {
+ toUpdate.push(plannedCard);
+ continue;
+ }
+
+ unchangedCards += 1;
+ }
+
+ const scopedPaths = new Set(scopedFilePaths);
+ const toOrphan = Object.values(state.cards).filter((card) => scopedPaths.has(card.filePath) && !cardsById.has(card.cardId));
+
+ return {
+ toCreate,
+ toUpdate,
+ toRewriteMarker,
+ toOrphan,
+ unchangedCards,
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/domain/manual-sync/services/ManualCardRenderer.ts b/src/domain/manual-sync/services/ManualCardRenderer.ts
new file mode 100644
index 0000000..3973217
--- /dev/null
+++ b/src/domain/manual-sync/services/ManualCardRenderer.ts
@@ -0,0 +1,187 @@
+import MarkdownIt from "markdown-it";
+
+import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
+import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
+import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
+import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
+
+const markdown = new MarkdownIt({
+ breaks: true,
+ html: true,
+ linkify: true,
+});
+
+const INLINE_CODE_PATTERN = /`[^`\n]+`/g;
+const FENCED_CODE_PATTERN = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
+const DISPLAY_MATH_PATTERN = /(?Open in Obsidian
`
+ : "";
+
+ return {
+ card: plannedCard.card,
+ noteId: plannedCard.noteId,
+ deck: plannedCard.deck,
+ noteModel: plannedCard.noteModel,
+ renderConfigHash: plannedCard.renderConfigHash,
+ renderedFields: {
+ title: headingResult.html,
+ body: bodyResult.html + backlinkHtml,
+ },
+ media: dedupeMedia([...headingResult.media, ...bodyResult.media]),
+ };
+ }
+
+ private renderMarkdown(
+ markdownText: string,
+ plannedCard: PlannedCard,
+ context: ManualCardRenderContext,
+ cloze: boolean,
+ inline: boolean,
+ ): RenderResult {
+ const protectedBlocks = protectSegments(markdownText, FENCED_CODE_PATTERN, "FENCED_CODE");
+ const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE");
+ let transformed = protectedInline.text;
+ const media: MediaAsset[] = [];
+ let nextClozeIndex = 1;
+
+ transformed = transformed.replace(DISPLAY_MATH_PATTERN, (_match, content: string) => `\\[${content.trim()}\\]`);
+ transformed = transformed.replace(INLINE_MATH_PATTERN, (_match, content: string) => `\\(${content.trim()}\\)`);
+
+ if (cloze && context.convertHighlightsToCloze) {
+ transformed = transformed.replace(HIGHLIGHT_PATTERN, "{$1}");
+ }
+
+ if (cloze) {
+ transformed = transformed.replace(CLOZE_PATTERN, (_match, explicitIndex: string | undefined, content: string) => {
+ const clozeIndex = explicitIndex ? Number(explicitIndex) : nextClozeIndex++;
+ return `{{c${clozeIndex}::${content}}}`;
+ });
+ }
+
+ transformed = transformed.replace(EMBED_PATTERN, (_match, rawTarget: string) => {
+ const resolvedEmbed = context.resourceResolver.resolveEmbed(rawTarget, plannedCard.card.filePath);
+
+ if (!resolvedEmbed) {
+ return _match;
+ }
+
+ media.push({
+ kind: resolvedEmbed.kind,
+ fileName: resolvedEmbed.fileName,
+ absolutePath: resolvedEmbed.absolutePath,
+ altText: resolvedEmbed.altText,
+ });
+
+ if (resolvedEmbed.kind === "audio") {
+ return `[sound:${resolvedEmbed.fileName}]`;
+ }
+
+ return `
`;
+ });
+
+ transformed = transformed.replace(WIKILINK_PATTERN, (_match, rawTarget: string) => {
+ const resolvedLink = context.resourceResolver.resolveWikiLink(rawTarget, plannedCard.card.filePath);
+
+ if (!resolvedLink) {
+ return _match;
+ }
+
+ return `${escapeHtml(resolvedLink.displayText)}`;
+ });
+
+ transformed = transformed.replace(HIGHLIGHT_PATTERN, "$1");
+ const protectedMath = protectSegments(transformed, ANKI_MATH_PATTERN, "MATH");
+ transformed = protectedMath.text;
+ transformed = protectedInline.restore(transformed);
+ transformed = protectedBlocks.restore(transformed);
+
+ const renderedHtml = (inline ? markdown.renderInline(transformed) : markdown.render(transformed)).trim();
+
+ return {
+ html: protectedMath.restore(renderedHtml, escapeHtml),
+ media,
+ };
+ }
+}
+
+function protectSegments(
+ text: string,
+ pattern: RegExp,
+ prefix: string,
+): { text: string; restore: (value: string, formatter?: (segment: string) => string) => string } {
+ const matches: string[] = [];
+ const nextText = text.replace(pattern, (segment) => {
+ const token = `@@${prefix}_${matches.length}@@`;
+ matches.push(segment);
+ return token;
+ });
+
+ return {
+ text: nextText,
+ restore: (value: string, formatter = (segment: string) => segment) =>
+ matches.reduce((current, segment, index) => current.split(`@@${prefix}_${index}@@`).join(formatter(segment)), value),
+ };
+}
+
+function dedupeMedia(media: MediaAsset[]): MediaAsset[] {
+ const seen = new Set();
+
+ return media.filter((asset) => {
+ const key = `${asset.kind}:${asset.absolutePath}:${asset.fileName}`;
+ if (seen.has(key)) {
+ return false;
+ }
+
+ seen.add(key);
+ return true;
+ });
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
\ No newline at end of file
diff --git a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts
new file mode 100644
index 0000000..818da87
--- /dev/null
+++ b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts
@@ -0,0 +1,18 @@
+import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
+import type { CardState } from "@/domain/manual-sync/entities/PluginState";
+
+export interface PlannedCard {
+ card: IndexedCard;
+ noteId?: number;
+ deck: string;
+ noteModel: string;
+ renderConfigHash: string;
+}
+
+export interface ManualSyncPlan {
+ toCreate: PlannedCard[];
+ toUpdate: PlannedCard[];
+ toRewriteMarker: PlannedCard[];
+ toOrphan: CardState[];
+ unchangedCards: number;
+}
\ No newline at end of file
diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts
index 50b5e2b..6a246f8 100644
--- a/src/infrastructure/anki/AnkiConnectGateway.test.ts
+++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts
@@ -76,8 +76,8 @@ describe("AnkiConnectGateway", () => {
const summaries = await gateway.getNoteSummaries([100, 101, 102]);
expect(summaries).toEqual([
- { noteId: 100, modelName: "Basic" },
- { noteId: 102, modelName: "Cloze" },
+ { noteId: 100, modelName: "Basic", cardIds: [1] },
+ { noteId: 102, modelName: "Cloze", cardIds: [2] },
]);
expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
action: "notesInfo",
@@ -87,4 +87,148 @@ describe("AnkiConnectGateway", () => {
},
});
});
+
+ it("batches add note calls through AnkiConnect multi", async () => {
+ requestUrlMock.mockResolvedValue({
+ json: {
+ error: null,
+ result: [9001, 9002],
+ },
+ });
+
+ const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
+ const noteIds = await gateway.addNotes([
+ {
+ deckName: "Deck",
+ modelName: "Basic",
+ fields: { Front: "A", Back: "B" },
+ tags: [],
+ },
+ {
+ deckName: "Deck",
+ modelName: "Basic",
+ fields: { Front: "C", Back: "D" },
+ tags: ["tag"],
+ },
+ ]);
+
+ expect(noteIds).toEqual([9001, 9002]);
+ expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
+ action: "multi",
+ version: 6,
+ params: {
+ actions: [
+ {
+ action: "addNote",
+ params: {
+ note: {
+ deckName: "Deck",
+ modelName: "Basic",
+ fields: { Front: "A", Back: "B" },
+ options: {
+ allowDuplicate: false,
+ duplicateScope: "deck",
+ },
+ tags: [],
+ },
+ },
+ },
+ {
+ action: "addNote",
+ params: {
+ note: {
+ deckName: "Deck",
+ modelName: "Basic",
+ fields: { Front: "C", Back: "D" },
+ options: {
+ allowDuplicate: false,
+ duplicateScope: "deck",
+ },
+ tags: ["tag"],
+ },
+ },
+ },
+ ],
+ },
+ });
+ });
+
+ it("batches update, changeDeck, and media operations through multi", async () => {
+ requestUrlMock.mockResolvedValue({
+ json: {
+ error: null,
+ result: [null, null],
+ },
+ });
+
+ const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765");
+ await gateway.updateNotes([
+ {
+ noteId: 10,
+ deckName: "Deck A",
+ fields: { Front: "Prompt", Back: "Answer" },
+ },
+ ]);
+ await gateway.changeDecks([
+ {
+ deckName: "Deck B",
+ cardIds: [1, 2],
+ },
+ ]);
+ await gateway.storeMediaFiles([
+ {
+ kind: "image",
+ fileName: "asset.png",
+ absolutePath: "/tmp/asset.png",
+ },
+ ]);
+
+ expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({
+ action: "multi",
+ version: 6,
+ params: {
+ actions: [
+ {
+ action: "updateNoteFields",
+ params: {
+ note: {
+ id: 10,
+ fields: { Front: "Prompt", Back: "Answer" },
+ },
+ },
+ },
+ ],
+ },
+ });
+ expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({
+ action: "multi",
+ version: 6,
+ params: {
+ actions: [
+ {
+ action: "changeDeck",
+ params: {
+ cards: [1, 2],
+ deck: "Deck B",
+ },
+ },
+ ],
+ },
+ });
+ expect(JSON.parse(requestUrlMock.mock.calls[2][0].body)).toEqual({
+ action: "multi",
+ version: 6,
+ params: {
+ actions: [
+ {
+ action: "storeMediaFile",
+ params: {
+ filename: "asset.png",
+ path: "/tmp/asset.png",
+ },
+ },
+ ],
+ },
+ });
+ });
});
\ No newline at end of file
diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts
index d585859..41a03f3 100644
--- a/src/infrastructure/anki/AnkiConnectGateway.ts
+++ b/src/infrastructure/anki/AnkiConnectGateway.ts
@@ -1,7 +1,7 @@
import { requestUrl } from "obsidian";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
-import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
+import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
interface AnkiResponse {
@@ -24,6 +24,14 @@ export class AnkiConnectGateway implements AnkiGateway {
await this.invoke("createDeck", { deck: deckName });
}
+ async ensureDecks(deckNames: string[]): Promise {
+ const uniqueDeckNames = Array.from(new Set(deckNames));
+ await this.invokeMulti(uniqueDeckNames.map((deckName) => ({
+ action: "createDeck",
+ params: { deck: deckName },
+ })));
+ }
+
async listNoteModels(): Promise {
return this.invoke("modelNames", {});
}
@@ -62,6 +70,7 @@ export class AnkiConnectGateway implements AnkiGateway {
return [{
noteId: entry.noteId,
modelName: entry.modelName,
+ cardIds: Array.isArray(entry.cards) ? entry.cards : [],
}];
});
}
@@ -81,6 +90,24 @@ export class AnkiConnectGateway implements AnkiGateway {
});
}
+ async addNotes(inputs: AddAnkiNoteInput[]): Promise {
+ return this.invokeMulti(inputs.map((input) => ({
+ action: "addNote",
+ params: {
+ note: {
+ deckName: input.deckName,
+ modelName: input.modelName,
+ fields: input.fields,
+ options: {
+ allowDuplicate: false,
+ duplicateScope: "deck",
+ },
+ tags: input.tags,
+ },
+ },
+ })));
+ }
+
async updateNote(input: UpdateAnkiNoteInput): Promise {
await this.invoke("updateNoteFields", {
note: {
@@ -102,6 +129,28 @@ export class AnkiConnectGateway implements AnkiGateway {
}
}
+ async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise {
+ await this.invokeMulti(inputs.map((input) => ({
+ action: "updateNoteFields",
+ params: {
+ note: {
+ id: input.noteId,
+ fields: input.fields,
+ },
+ },
+ })));
+ }
+
+ async changeDecks(inputs: ChangeDeckInput[]): Promise {
+ await this.invokeMulti(inputs.filter((input) => input.cardIds.length > 0).map((input) => ({
+ action: "changeDeck",
+ params: {
+ cards: input.cardIds,
+ deck: input.deckName,
+ },
+ })));
+ }
+
async storeMedia(asset: MediaAsset): Promise {
await this.invoke("storeMediaFile", {
filename: asset.fileName,
@@ -109,6 +158,16 @@ export class AnkiConnectGateway implements AnkiGateway {
});
}
+ async storeMediaFiles(assets: MediaAsset[]): Promise {
+ await this.invokeMulti(assets.map((asset) => ({
+ action: "storeMediaFile",
+ params: {
+ filename: asset.fileName,
+ path: asset.absolutePath,
+ },
+ })));
+ }
+
private async invoke(action: string, params: Record): Promise {
const response = await requestUrl({
url: this.getBaseUrl(),
@@ -128,4 +187,12 @@ export class AnkiConnectGateway implements AnkiGateway {
return parsed.result;
}
+
+ private async invokeMulti(actions: Array<{ action: string; params: Record }>): Promise {
+ if (actions.length === 0) {
+ return [];
+ }
+
+ return this.invoke("multi", { actions });
+ }
}
\ No newline at end of file
diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts
index df8e723..4df5ae6 100644
--- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts
+++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts
@@ -1,6 +1,7 @@
import { TFile } from "obsidian";
import type { App } from "obsidian";
+import type { MarkdownFileReference, ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import { MarkdownFileNotFoundError, MarkdownWriteConflictError, type VaultGateway } from "@/application/ports/VaultGateway";
import { hashString } from "@/domain/shared/hash";
import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation";
@@ -8,9 +9,18 @@ import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "tiff"]);
const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "webm", "ogg"]);
-export class ObsidianVaultGateway implements VaultGateway {
+export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway {
constructor(private readonly app: App) {}
+ async listMarkdownFileRefs(): Promise {
+ return this.app.vault.getMarkdownFiles().map((file) => ({
+ path: file.path,
+ basename: file.basename,
+ mtime: file.stat.mtime,
+ size: file.stat.size,
+ }));
+ }
+
async listMarkdownFiles() {
const files = this.app.vault.getMarkdownFiles();
@@ -27,6 +37,10 @@ export class ObsidianVaultGateway implements VaultGateway {
return this.toSourceFile(abstractFile);
}
+ async readMarkdownFile(path: string) {
+ return this.getMarkdownFile(path);
+ }
+
async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) {
const abstractFile = this.app.vault.getAbstractFileByPath(path);
diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
index 4612adf..0d76195 100644
--- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
+++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts
@@ -1,9 +1,11 @@
import { DEFAULT_SETTINGS, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings";
import type { PluginConfigRepository } from "@/application/ports/PluginConfigRepository";
import type { PluginDataStore } from "@/application/ports/PluginDataStore";
+import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
export interface PluginDataSnapshot {
settings?: Partial;
+ pluginState?: PluginState;
syncRegistry?: {
records: Array<{
cardKey: string;
diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
new file mode 100644
index 0000000..0346fe2
--- /dev/null
+++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import type { PluginDataStore } from "@/application/ports/PluginDataStore";
+import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState";
+
+import { DataJsonPluginStateRepository } from "./DataJsonPluginStateRepository";
+import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
+
+class InMemoryPluginDataStore implements PluginDataStore {
+ constructor(private snapshot: PluginDataSnapshot | null = null) {}
+
+ async load(): Promise {
+ return this.snapshot;
+ }
+
+ async save(data: PluginDataSnapshot): Promise {
+ this.snapshot = data;
+ }
+}
+
+describe("DataJsonPluginStateRepository", () => {
+ it("loads an empty plugin state when none has been saved", async () => {
+ const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore());
+
+ await expect(repository.load()).resolves.toEqual(createEmptyPluginState());
+ });
+
+ it("persists pluginState independently from settings", async () => {
+ const store = new InMemoryPluginDataStore({
+ settings: {
+ defaultDeck: "Deck",
+ },
+ });
+ const repository = new DataJsonPluginStateRepository(store);
+
+ await repository.save({
+ files: {
+ "notes/example.md": {
+ filePath: "notes/example.md",
+ fileHash: "hash",
+ fileStamp: "1:1",
+ lastIndexedAt: 1,
+ cardIds: ["ahs_1"],
+ },
+ },
+ cards: {},
+ pendingWriteBack: [],
+ });
+
+ const snapshot = await store.load();
+ expect(snapshot?.pluginState?.files["notes/example.md"]?.cardIds).toEqual(["ahs_1"]);
+ expect(snapshot?.settings?.defaultDeck).toBe("Deck");
+ });
+});
\ No newline at end of file
diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts
new file mode 100644
index 0000000..b75a21d
--- /dev/null
+++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts
@@ -0,0 +1,23 @@
+import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
+import type { PluginDataStore } from "@/application/ports/PluginDataStore";
+import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
+
+import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository";
+
+export class DataJsonPluginStateRepository implements PluginStateRepository {
+ constructor(private readonly pluginDataStore: PluginDataStore) {}
+
+ async load(): Promise {
+ const snapshot = (await this.pluginDataStore.load()) ?? {};
+ return snapshot.pluginState ?? createEmptyPluginState();
+ }
+
+ async save(state: PluginState): Promise {
+ const snapshot = (await this.pluginDataStore.load()) ?? {};
+
+ await this.pluginDataStore.save({
+ ...snapshot,
+ pluginState: state,
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts
index c06082d..2acefc9 100644
--- a/src/presentation/AnkiHeadingSyncPlugin.ts
+++ b/src/presentation/AnkiHeadingSyncPlugin.ts
@@ -2,18 +2,18 @@ import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
-import { ScanAndPlanSyncUseCase } from "@/application/use-cases/ScanAndPlanSyncUseCase";
-import { ExecuteSyncPlanUseCase } from "@/application/use-cases/ExecuteSyncPlanUseCase";
-import { SyncCurrentFileUseCase } from "@/application/use-cases/SyncCurrentFileUseCase";
-import { SyncVaultUseCase } from "@/application/use-cases/SyncVaultUseCase";
+import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase";
+import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase";
+import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase";
import { AnkiConnectGateway } from "@/infrastructure/anki/AnkiConnectGateway";
import { ObsidianPluginDataStore } from "@/infrastructure/obsidian/ObsidianPluginDataStore";
import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGateway";
import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository";
-import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository";
+import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository";
import { registerCommands } from "@/presentation/commands/registerCommands";
import { NoticeService } from "@/presentation/notices/NoticeService";
import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab";
+import { ManualSyncService } from "@/application/services/ManualSyncService";
export default class AnkiHeadingSyncPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
@@ -21,14 +21,15 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
private readonly noticeService = new NoticeService();
private readonly ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl);
- private syncCurrentFileUseCase?: SyncCurrentFileUseCase;
- private syncVaultUseCase?: SyncVaultUseCase;
+ private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase;
+ private syncVaultUseCase?: ManualSyncVaultUseCase;
+ private rebuildCardIndexUseCase?: RebuildCardIndexUseCase;
private pluginConfigRepository?: DataJsonPluginConfigRepository;
async onload(): Promise {
const pluginDataStore = new ObsidianPluginDataStore(this);
this.pluginConfigRepository = new DataJsonPluginConfigRepository(pluginDataStore);
- const syncRegistryRepository = new DataJsonSyncRegistryRepository(pluginDataStore);
+ const pluginStateRepository = new DataJsonPluginStateRepository(pluginDataStore);
const vaultGateway = new ObsidianVaultGateway(this.app);
try {
@@ -39,10 +40,10 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory.");
}
- const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository);
- const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository, vaultGateway);
- this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
- this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase);
+ const manualSyncService = new ManualSyncService(vaultGateway, pluginStateRepository, this.ankiGateway);
+ this.syncCurrentFileUseCase = new ManualSyncCurrentFileUseCase(manualSyncService);
+ this.syncVaultUseCase = new ManualSyncVaultUseCase(manualSyncService);
+ this.rebuildCardIndexUseCase = new RebuildCardIndexUseCase(manualSyncService);
registerCommands(this);
this.addSettingTab(new AnkiHeadingSyncSettingTab(this));
@@ -89,7 +90,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
try {
const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings);
- this.noticeService.showSyncSummary("Current file sync finished", result);
+ this.noticeService.showSyncSummary("当前文件同步完成", result);
} catch (error) {
console.error("Current file sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed.");
@@ -104,10 +105,25 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
try {
const result = await this.syncVaultUseCase.execute(this.settings);
- this.noticeService.showSyncSummary("Vault sync finished", result);
+ this.noticeService.showSyncSummary("全库同步完成", result);
} catch (error) {
console.error("Vault sync failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed.");
}
}
+
+ async runRebuildCardIndex(): Promise {
+ if (!this.rebuildCardIndexUseCase) {
+ this.noticeService.error("Sync use case is not initialized.");
+ return;
+ }
+
+ try {
+ const result = await this.rebuildCardIndexUseCase.execute(this.settings);
+ this.noticeService.showRebuildSummary("卡片索引重建完成", result);
+ } catch (error) {
+ console.error("Card index rebuild failed.", error);
+ this.noticeService.error(error instanceof Error ? error.message : "Card index rebuild failed.");
+ }
+ }
}
\ No newline at end of file
diff --git a/src/presentation/commands/registerCommands.ts b/src/presentation/commands/registerCommands.ts
index c99b9c2..4e47939 100644
--- a/src/presentation/commands/registerCommands.ts
+++ b/src/presentation/commands/registerCommands.ts
@@ -3,7 +3,7 @@ import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin";
export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
plugin.addCommand({
id: "sync-current-file-to-anki",
- name: "Sync current file to Anki",
+ name: "同步当前文件到 Anki",
callback: () => {
void plugin.runSyncCurrentFile();
},
@@ -11,9 +11,17 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void {
plugin.addCommand({
id: "sync-vault-to-anki",
- name: "Sync vault to Anki",
+ name: "同步全库到 Anki",
callback: () => {
void plugin.runSyncVault();
},
});
+
+ plugin.addCommand({
+ id: "rebuild-card-index",
+ name: "重建卡片索引",
+ callback: () => {
+ void plugin.runRebuildCardIndex();
+ },
+ });
}
\ No newline at end of file
diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts
index 01ead8a..f866761 100644
--- a/src/presentation/notices/NoticeService.ts
+++ b/src/presentation/notices/NoticeService.ts
@@ -1,6 +1,6 @@
import { Notice } from "obsidian";
-import type { ExecuteSyncPlanResult } from "@/application/use-cases/types";
+import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
export class NoticeService {
info(message: string): void {
@@ -11,9 +11,21 @@ export class NoticeService {
new Notice(message, 8000);
}
- showSyncSummary(prefix: string, result: ExecuteSyncPlanResult): void {
- this.info(
- `${prefix}: scanned ${result.scanned}, created ${result.created}, updated ${result.updated}, orphaned ${result.markedOrphan}, media ${result.uploadedMedia}.`,
- );
+ 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 conflicts = result.markerWriteConflictFiles.length > 0
+ ? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
+ : "";
+
+ this.info(`${summary}${conflicts}`);
+ }
+
+ 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 conflicts = result.markerWriteConflictFiles.length > 0
+ ? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
+ : "";
+
+ this.info(`${summary}${conflicts}`);
}
}
\ No newline at end of file
diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts
new file mode 100644
index 0000000..98fc2c4
--- /dev/null
+++ b/src/test-support/manualSyncFakes.ts
@@ -0,0 +1,199 @@
+import type { PluginSettings } from "@/application/config/PluginSettings";
+import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings";
+import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway";
+import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
+import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
+import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
+import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
+import type { SourceFile } from "@/domain/card/entities/SourceFile";
+import type { MediaAsset } from "@/domain/card/entities/RenderedFields";
+import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState";
+
+export class InMemoryPluginStateRepository implements PluginStateRepository {
+ public savedState: PluginState | null = null;
+
+ constructor(private state: PluginState = createEmptyPluginState()) {}
+
+ async load(): Promise {
+ return this.state;
+ }
+
+ async save(state: PluginState): Promise {
+ this.state = state;
+ this.savedState = state;
+ }
+}
+
+export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway {
+ public readCalls: string[] = [];
+ public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = [];
+ public conflictPaths = new Set();
+ public errorPaths = new Map();
+
+ private readonly files = new Map();
+
+ constructor(initialFiles: Record = {}) {
+ let nextMtime = 1;
+ for (const [path, content] of Object.entries(initialFiles)) {
+ this.files.set(path, { content, mtime: nextMtime, size: content.length });
+ nextMtime += 1;
+ }
+ }
+
+ async listMarkdownFileRefs() {
+ return Array.from(this.files.entries()).map(([path, file]) => ({
+ path,
+ basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
+ mtime: file.mtime,
+ size: file.size,
+ }));
+ }
+
+ async readMarkdownFile(path: string): Promise {
+ this.readCalls.push(path);
+ const file = this.files.get(path);
+ if (!file) {
+ return null;
+ }
+
+ return {
+ path,
+ basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path,
+ content: file.content,
+ };
+ }
+
+ async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise {
+ this.replaceCalls.push({ path, expectedContent, nextContent });
+
+ if (this.errorPaths.has(path)) {
+ throw this.errorPaths.get(path);
+ }
+
+ if (this.conflictPaths.has(path)) {
+ throw new MarkdownWriteConflictError(path);
+ }
+
+ const current = this.files.get(path);
+ if (!current || current.content !== expectedContent) {
+ throw new MarkdownWriteConflictError(path);
+ }
+
+ this.files.set(path, {
+ content: nextContent,
+ mtime: current.mtime + 1,
+ size: nextContent.length,
+ });
+ }
+
+ resolveWikiLink(rawTarget: string) {
+ return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget };
+ }
+
+ resolveEmbed() {
+ return null;
+ }
+
+ createBacklink(location: { filePath: string; headingText: string }) {
+ return `obsidian://open?vault=Vault&file=${location.filePath}#${location.headingText}`;
+ }
+
+ getFileContent(path: string): string | undefined {
+ return this.files.get(path)?.content;
+ }
+}
+
+export class FakeManualSyncAnkiGateway implements AnkiGateway {
+ public ensuredDecks: string[][] = [];
+ public addedNotes: AddAnkiNoteInput[] = [];
+ public updatedNotes: UpdateAnkiNoteInput[] = [];
+ public changedDecks: ChangeDeckInput[] = [];
+ public storedMedia: MediaAsset[] = [];
+ public noteSummariesById = new Map();
+ public modelDetailsByName: Record = {
+ Basic: { fieldNames: ["Front", "Back"], isCloze: false },
+ Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
+ };
+
+ private nextNoteId = 9000;
+
+ async ensureDeckExists(deckName: string): Promise {
+ await this.ensureDecks([deckName]);
+ }
+
+ async ensureDecks(deckNames: string[]): Promise {
+ this.ensuredDecks.push(deckNames);
+ }
+
+ async listNoteModels(): Promise {
+ return Object.keys(this.modelDetailsByName);
+ }
+
+ async getModelDetails(modelName: string): Promise {
+ return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
+ }
+
+ async getNoteSummaries(noteIds: number[]): Promise {
+ return noteIds.flatMap((noteId) => {
+ const summary = this.noteSummariesById.get(noteId);
+ return summary ? [summary] : [];
+ });
+ }
+
+ async addNote(input: AddAnkiNoteInput): Promise {
+ const [noteId] = await this.addNotes([input]);
+ return noteId;
+ }
+
+ async addNotes(inputs: AddAnkiNoteInput[]): Promise {
+ return inputs.map((input) => {
+ this.addedNotes.push(input);
+ this.nextNoteId += 1;
+ return this.nextNoteId;
+ });
+ }
+
+ async updateNote(input: UpdateAnkiNoteInput): Promise {
+ await this.updateNotes([input]);
+ }
+
+ async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise {
+ this.updatedNotes.push(...inputs);
+ }
+
+ async changeDecks(inputs: ChangeDeckInput[]): Promise {
+ this.changedDecks.push(...inputs);
+ }
+
+ async storeMedia(asset: MediaAsset): Promise {
+ await this.storeMediaFiles([asset]);
+ }
+
+ async storeMediaFiles(assets: MediaAsset[]): Promise {
+ this.storedMedia.push(...assets);
+ }
+}
+
+export function createModule3Settings(overrides: Partial = {}): PluginSettings {
+ return {
+ ...DEFAULT_SETTINGS,
+ noteFieldMappings: {
+ "basic:Basic": {
+ cardType: "basic",
+ modelName: "Basic",
+ loadedFieldNames: ["Front", "Back"],
+ titleField: "Front",
+ bodyField: "Back",
+ loadedAt: 1,
+ },
+ "cloze:Cloze": {
+ cardType: "cloze",
+ modelName: "Cloze",
+ loadedFieldNames: ["Text", "Extra"],
+ mainField: "Text",
+ loadedAt: 1,
+ },
+ },
+ ...overrides,
+ };
+}
\ No newline at end of file