feat: implement module 3 manual sync architecture

This commit is contained in:
Dusk 2026-04-18 10:07:49 +08:00
parent be0277d089
commit d3d356fb10
46 changed files with 3672 additions and 37 deletions

657
docs/2026-04-18PLAN4.md Normal file
View file

@ -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. 标记格式
- 块尾机器标记固定为:
- `<!-- AHS:card=<cardId> note=<noteId> -->`
- 在首次发现卡片但尚未创建 Anki note 时,允许写成:
- `<!-- AHS:card=<cardId> -->`
### 3. 标记位置
- 统一放在标题块末尾:
- 当前标题块最后一个非空内容行之后
- 下一个同级或更高层级标题之前
- 目标示例:
```md
#### 标题
正文内容
<!-- AHS:card=ahs_k83jf29 note=1776442768197 -->
```
### 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 1Identity And Marker
- 实现 `cardId` 生成规则
- 实现 `AHS` marker 解析与块尾写回
- 为卡片块补写 `cardId`
### Phase 2File And Card Index
- 文件路径过滤
- 文件级 hash
- 卡片块提取
- 卡片级 `rawBlockHash`
### Phase 3Local State
- 文件层状态持久化
- 卡片层状态持久化
- pending write-back 状态持久化
### Phase 4Planner
- `toCreate`
- `toUpdate`
- `toOrphan`
- `toRewriteMarker`
### Phase 5Renderer
- 只为待同步卡渲染字段
- 解析 media / wikilink / cloze
### Phase 6Anki Batch Executor
- 批量 `notesInfo`
- 批量 `addNote`
- 批量 `updateNoteFields`
- deck 分组 `changeDeck`
- media 批量上传
### Phase 7Commands 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<string, FileState>`
- `cards: Record<string, CardState>`
- `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 执行层必须按类型批处理

View file

@ -0,0 +1,79 @@
# 模块 3 实现决策
本文锁定模块 3 的具体实现决策,范围以 `docs/2026-04-18PLAN4.md` 为准。
## 1. 主链路替换策略
- 模块 3 采用新的索引/状态/规划/执行主链路。
- 现有模块 1/2 代码保留在仓库中,但插件命令入口改接模块 3 use case。
- 不为旧 `legacy cardKey` / `embeddedNoteId` 做兼容适配。
## 2. marker 与身份
- 统一 marker 格式:`<!-- AHS:card=<cardId> note=<noteId> -->`
- 允许仅写 `cardId``<!-- AHS:card=<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` 为空数组,但批处理执行层保留接口位置

View file

@ -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` 只写 `<!-- AHS:<noteId> -->`
- `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 接管插件命令入口

View file

@ -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<void>;
ensureDecks(deckNames: string[]): Promise<void>;
listNoteModels(): Promise<string[]>;
getModelDetails(modelName: string): Promise<NoteModelDetails>;
getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]>;
addNote(input: AddAnkiNoteInput): Promise<number>;
addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]>;
updateNote(input: UpdateAnkiNoteInput): Promise<void>;
updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void>;
changeDecks(inputs: ChangeDeckInput[]): Promise<void>;
storeMedia(asset: MediaAsset): Promise<void>;
storeMediaFiles(assets: MediaAsset[]): Promise<void>;
}

View file

@ -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<MarkdownFileReference[]>;
readMarkdownFile(path: string): Promise<SourceFile | null>;
replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>;
}

View file

@ -0,0 +1,6 @@
import type { PluginState } from "@/domain/manual-sync/entities/PluginState";
export interface PluginStateRepository {
load(): Promise<PluginState>;
save(state: PluginState): Promise<void>;
}

View file

@ -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<string, number | undefined>;
touchedCardIds: Set<string>;
}
export class AnkiBatchExecutor {
constructor(
private readonly ankiGateway: AnkiGateway,
private readonly noteFieldMappingService = new NoteFieldMappingService(),
private readonly batchScheduler = new BatchScheduler(),
) {}
async execute(
plan: ManualSyncPlan,
renderedCards: Map<string, RenderedSyncCard>,
renderOnDemand: (plannedCard: PlannedCard) => Promise<RenderedSyncCard>,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): Promise<AnkiBatchExecutionResult> {
const resolvedNoteIds = new Map<string, number | undefined>();
const touchedCardIds = new Set<string>();
const markerWriteMap = new Map<string, PlannedCard>();
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<string, number[]>();
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<string, RenderedSyncCard>,
renderOnDemand: (plannedCard: PlannedCard) => Promise<RenderedSyncCard>,
): Promise<RenderedSyncCard> {
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<string, NoteModelFieldMapping>,
): Promise<Record<string, string>> {
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<number> {
const uniqueMedia = new Map<string, MediaAsset>();
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;
}
}

View file

@ -0,0 +1,50 @@
export class BatchScheduler {
async runCollectBatches<TInput, TResult>(
items: TInput[],
batchSize: number,
concurrency: number,
worker: (batch: TInput[]) => Promise<TResult[]>,
): Promise<TResult[]> {
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<TInput>(
items: TInput[],
batchSize: number,
concurrency: number,
worker: (batch: TInput[]) => Promise<void>,
): Promise<void> {
await this.runCollectBatches(items, batchSize, concurrency, async (batch) => {
await worker(batch);
return [];
});
}
}
function chunk<T>(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;
}

View file

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

View file

@ -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<FileIndexerResult> {
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<FileIndexerResult> {
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<ReturnType<ManualSyncVaultGateway["listMarkdownFileRefs"]>>,
settings: PluginSettings,
state: PluginState,
forceReadAll: boolean,
): Promise<FileIndexerResult> {
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}`;
}

View file

@ -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("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001");
expect(stateRepository.savedState?.pendingWriteBack).toEqual([]);
expect(Object.values(stateRepository.savedState?.cards ?? {})).toHaveLength(1);
});
it("records pending write-back and reports conflict files when markdown changed before write-back", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
vaultGateway.conflictPaths.add("notes/example.md");
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.markerWriteConflictFiles).toEqual(["notes/example.md"]);
expect(stateRepository.savedState?.pendingWriteBack).toHaveLength(1);
expect(stateRepository.savedState?.pendingWriteBack[0]).toMatchObject({ filePath: "notes/example.md", noteId: 9001 });
});
it("rebuilds the card index and writes card-only markers without calling Anki", 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.rebuildIndex(createModule3Settings());
expect(result.created).toBe(0);
expect(result.rewrittenMarkers).toBe(1);
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("note=");
});
});

View file

@ -0,0 +1,232 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { validatePluginSettings } from "@/application/config/PluginSettings";
import type { AnkiGateway } from "@/application/ports/AnkiGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import type { PluginStateRepository } from "@/application/ports/PluginStateRepository";
import { AnkiBatchExecutor } from "@/application/services/AnkiBatchExecutor";
import { FileIndexerService } from "@/application/services/FileIndexerService";
import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBackService";
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 { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard";
import { DiffPlannerService } from "@/domain/manual-sync/services/DiffPlannerService";
import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manual-sync/services/ManualCardRenderer";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
export class ManualSyncService {
constructor(
private readonly vaultGateway: ManualSyncVaultGateway,
private readonly pluginStateRepository: PluginStateRepository,
private readonly ankiGateway: AnkiGateway,
private readonly fileIndexerService = new FileIndexerService(vaultGateway),
private readonly diffPlannerService = new DiffPlannerService(),
private readonly renderer = new ManualCardRenderer(),
private readonly ankiBatchExecutor = new AnkiBatchExecutor(ankiGateway),
private readonly markdownWriteBackService = new MarkdownWriteBackService(vaultGateway),
private readonly renderConfigService = new RenderConfigService(),
private readonly now: () => number = () => Date.now(),
) {}
async syncVault(settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
const state = await this.pluginStateRepository.load();
const indexResult = await this.fileIndexerService.indexVault(settings, state);
return this.syncIndexedResult(indexResult, state, settings);
}
async syncFile(filePath: string, settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
const state = await this.pluginStateRepository.load();
const indexResult = await this.fileIndexerService.indexFile(filePath, settings, state);
return this.syncIndexedResult(indexResult, state, settings);
}
async rebuildIndex(settings: PluginSettings): Promise<ManualSyncResult> {
validatePluginSettings(settings);
const state = await this.pluginStateRepository.load();
const indexResult = await this.fileIndexerService.indexVault(settings, state, true);
const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings);
const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file]));
const markerWrites = [
...plan.toRewriteMarker,
...plan.toCreate.filter((plannedCard) => plannedCard.card.markerState === "missing"),
];
const writeBackResult = await this.markdownWriteBackService.write(markerWrites, indexedFilesByPath);
const nextState = this.buildNextState(
state,
indexResult.cards,
indexResult.indexedFiles,
settings,
new Set(writeBackResult.writtenCardIds),
new Map(),
plan.toOrphan,
);
nextState.pendingWriteBack = [
...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)),
...writeBackResult.pendingEntries,
];
await this.pluginStateRepository.save(nextState);
if (writeBackResult.failureFiles.length > 0) {
throw new Error(this.createWriteBackFailureMessage(writeBackResult.failureFiles));
}
return {
scannedFiles: indexResult.scannedFiles,
scannedCards: indexResult.cards.length,
created: 0,
updated: 0,
orphaned: plan.toOrphan.length,
uploadedMedia: 0,
skippedUnchangedCards: indexResult.skippedUnchangedCards,
rewrittenMarkers: writeBackResult.writtenCardIds.length,
markerWriteConflictFiles: writeBackResult.conflictFiles,
};
}
private async syncIndexedResult(
indexResult: Awaited<ReturnType<FileIndexerService["indexVault"]>>,
state: PluginState,
settings: PluginSettings,
): Promise<ManualSyncResult> {
const plan = this.diffPlannerService.plan(indexResult.cards, state, indexResult.scopedFilePaths, settings);
const renderContext: ManualCardRenderContext = {
addObsidianBacklink: settings.addObsidianBacklink,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
resourceResolver: this.vaultGateway,
};
const renderedCards = new Map<string, RenderedSyncCard>();
for (const plannedCard of [...plan.toCreate, ...plan.toUpdate]) {
if (!renderedCards.has(plannedCard.card.cardId)) {
renderedCards.set(plannedCard.card.cardId, this.renderer.render(plannedCard, renderContext));
}
}
const executionResult = await this.ankiBatchExecutor.execute(
plan,
renderedCards,
async (plannedCard) => this.renderer.render(plannedCard, renderContext),
settings.noteFieldMappings,
);
const indexedFilesByPath = new Map(indexResult.indexedFiles.map((file) => [file.filePath, file]));
const writeBackResult = await this.markdownWriteBackService.write(executionResult.markerWrites, indexedFilesByPath);
const nextState = this.buildNextState(
state,
indexResult.cards,
indexResult.indexedFiles,
settings,
new Set([...executionResult.touchedCardIds, ...writeBackResult.writtenCardIds]),
executionResult.resolvedNoteIds,
plan.toOrphan,
);
nextState.pendingWriteBack = [
...state.pendingWriteBack.filter((pending) => !new Set(indexResult.scopedFilePaths).has(pending.filePath)),
...writeBackResult.pendingEntries,
];
await this.pluginStateRepository.save(nextState);
if (writeBackResult.failureFiles.length > 0) {
throw new Error(this.createWriteBackFailureMessage(writeBackResult.failureFiles));
}
return {
scannedFiles: indexResult.scannedFiles,
scannedCards: indexResult.cards.length,
created: executionResult.created,
updated: executionResult.updated,
orphaned: plan.toOrphan.length,
uploadedMedia: executionResult.uploadedMedia,
skippedUnchangedCards: indexResult.skippedUnchangedCards,
rewrittenMarkers: writeBackResult.writtenCardIds.length,
markerWriteConflictFiles: writeBackResult.conflictFiles,
};
}
private buildNextState(
previousState: PluginState,
cards: IndexedCard[],
indexedFiles: Array<{ filePath: string; fileHash: string; fileStamp: string; content?: string; cards: IndexedCard[] }>,
settings: PluginSettings,
touchedCardIds: Set<string>,
resolvedNoteIds: Map<string, number | undefined>,
orphanCards: Array<{ cardId: string }>,
): PluginState {
const now = this.now();
const nextState: PluginState = {
files: { ...previousState.files },
cards: { ...previousState.cards },
pendingWriteBack: [...previousState.pendingWriteBack],
};
for (const indexedFile of indexedFiles) {
if (indexedFile.content === undefined) {
continue;
}
nextState.files[indexedFile.filePath] = {
filePath: indexedFile.filePath,
fileHash: indexedFile.fileHash,
fileStamp: indexedFile.fileStamp,
lastIndexedAt: now,
cardIds: indexedFile.cards.map((card) => card.cardId),
};
}
for (const card of cards) {
const existingState = previousState.cards[card.cardId];
const renderPlan = this.renderConfigService.resolve(card, settings);
const noteId = resolvedNoteIds.get(card.cardId) ?? existingState?.noteId ?? card.noteId;
nextState.cards[card.cardId] = {
cardId: card.cardId,
noteId,
filePath: card.filePath,
heading: card.heading,
headingLevel: card.headingLevel,
bodyMarkdown: card.bodyMarkdown,
cardType: card.cardType,
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,
renderConfigHash: renderPlan.renderConfigHash,
deck: renderPlan.deck,
deckHint: card.deckHint,
tagsHint: card.tagsHint,
lastSyncedAt: touchedCardIds.has(card.cardId) ? now : existingState?.lastSyncedAt ?? 0,
orphan: false,
};
}
for (const orphanCard of orphanCards) {
const existing = nextState.cards[orphanCard.cardId];
if (!existing) {
continue;
}
nextState.cards[orphanCard.cardId] = {
...existing,
orphan: true,
};
}
return nextState;
}
private createWriteBackFailureMessage(failures: Array<{ filePath: string; message: string }>): string {
return `Markdown marker write-back failed for ${failures.length} file(s).\n${failures.map((failure) => `${failure.filePath}: ${failure.message}`).join("\n")}`;
}
}

View file

@ -0,0 +1,102 @@
import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway";
import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway";
import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile";
import type { PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState";
import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
import { CardMarkerService, type MarkerWriteRequest } from "@/domain/manual-sync/services/CardMarkerService";
export interface MarkdownWriteBackResult {
writtenCardIds: string[];
conflictFiles: string[];
failureFiles: Array<{ filePath: string; message: string }>;
pendingEntries: PendingWriteBackState[];
}
export class MarkdownWriteBackService {
constructor(
private readonly vaultGateway: ManualSyncVaultGateway,
private readonly markerService = new CardMarkerService(),
) {}
async write(plannedCards: PlannedCard[], indexedFilesByPath: Map<string, IndexedFile>): Promise<MarkdownWriteBackResult> {
const plannedByFile = new Map<string, PlannedCard[]>();
const writtenCardIds: string[] = [];
const conflictFiles: string[] = [];
const failureFiles: Array<{ filePath: string; message: string }> = [];
const pendingEntries: PendingWriteBackState[] = [];
for (const plannedCard of plannedCards) {
const entries = plannedByFile.get(plannedCard.card.filePath);
if (entries) {
entries.push(plannedCard);
continue;
}
plannedByFile.set(plannedCard.card.filePath, [plannedCard]);
}
for (const [filePath, fileCards] of plannedByFile.entries()) {
const indexedFile = indexedFilesByPath.get(filePath);
const sourceContent = indexedFile?.content ?? fileCards[0]?.card.sourceContent;
if (!sourceContent || !indexedFile) {
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile?.fileHash ?? "")));
failureFiles.push({ filePath, message: `Missing scanned source content for ${filePath}.` });
continue;
}
try {
const nextContent = this.markerService.applyBatch(
sourceContent,
fileCards.map((plannedCard) => this.toWriteRequest(plannedCard, sourceContent)),
);
await this.vaultGateway.replaceMarkdownFile(filePath, sourceContent, nextContent);
writtenCardIds.push(...fileCards.map((plannedCard) => plannedCard.card.cardId));
} catch (error) {
pendingEntries.push(...fileCards.map((plannedCard) => this.createPendingEntry(filePath, plannedCard, indexedFile.fileHash)));
if (error instanceof MarkdownWriteConflictError) {
conflictFiles.push(filePath);
continue;
}
failureFiles.push({
filePath,
message: error instanceof Error ? error.message : String(error),
});
}
}
return {
writtenCardIds,
conflictFiles,
failureFiles,
pendingEntries,
};
}
private toWriteRequest(plannedCard: PlannedCard, sourceContent: string): MarkerWriteRequest {
return {
filePath: plannedCard.card.filePath,
cardId: plannedCard.card.cardId,
noteId: plannedCard.noteId,
blockStartLine: plannedCard.card.blockStartLine,
contentEndLine: plannedCard.card.contentEndLine,
blockEndLine: plannedCard.card.blockEndLine,
markerLine: plannedCard.card.markerLine,
sourceContent,
};
}
private createPendingEntry(filePath: string, plannedCard: PlannedCard, expectedFileHash: string): PendingWriteBackState {
return {
filePath,
cardId: plannedCard.card.cardId,
noteId: plannedCard.noteId,
expectedFileHash,
targetMarker: this.markerService.create(plannedCard.card.cardId, plannedCard.noteId).raw,
rawBlockHash: plannedCard.card.rawBlockHash,
};
}
}

View file

@ -1,12 +1,27 @@
import type { Card } from "@/domain/card/entities/Card";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import type { CardType, RenderedFields } from "@/domain/card/entities/RenderedFields";
interface RenderedCardInput {
type: CardType;
noteModel: string;
renderedFields: RenderedFields;
}
export class NoteFieldMappingService {
map(
card: Card,
noteModelDetails: NoteModelDetails,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): Record<string, string> {
return this.mapRenderedCard(card, noteModelDetails, noteFieldMappings);
}
mapRenderedCard(
card: RenderedCardInput,
noteModelDetails: NoteModelDetails,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): Record<string, string> {
const mapping = this.getRequiredMapping(card, noteFieldMappings);
this.validateMapping(mapping, noteModelDetails);
@ -76,7 +91,7 @@ export class NoteFieldMappingService {
}
}
private mapBasic(card: Card, mapping: NoteModelFieldMapping): Record<string, string> {
private mapBasic(card: RenderedCardInput, mapping: NoteModelFieldMapping): Record<string, string> {
const titleFieldName = mapping.titleField;
const bodyFieldName = mapping.bodyField;
@ -90,7 +105,7 @@ export class NoteFieldMappingService {
};
}
private mapCloze(card: Card, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
private mapCloze(card: RenderedCardInput, noteModelDetails: NoteModelDetails, mapping: NoteModelFieldMapping): Record<string, string> {
if (!noteModelDetails.isCloze) {
throw new Error(`Cloze note type "${card.noteModel}" is not cloze-compatible in Anki.`);
}
@ -105,7 +120,7 @@ export class NoteFieldMappingService {
}
private getRequiredMapping(
card: Card,
card: RenderedCardInput,
noteFieldMappings: Record<string, NoteModelFieldMapping>,
): NoteModelFieldMapping {
const mapping = noteFieldMappings[createNoteFieldMappingKey(card.type, card.noteModel)];

View file

@ -0,0 +1,33 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { hashString } from "@/domain/shared/hash";
export interface RenderPlan {
deck: string;
noteModel: string;
renderConfigHash: string;
}
export class RenderConfigService {
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
const noteModel = card.cardType === "basic" ? settings.qaNoteType : settings.clozeNoteType;
const deck = card.deckHint?.trim() || settings.defaultDeck;
const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
return {
deck,
noteModel,
renderConfigHash: hashString(
JSON.stringify({
cardType: card.cardType,
noteModel,
deck,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
}),
),
};
}
}

View file

@ -1,7 +1,5 @@
import type { SourceFile } from "@/domain/card/entities/SourceFile";
export class ScanScopeService {
filter(files: SourceFile[], includeFolders: string[], excludeFolders: string[]): SourceFile[] {
filter<T extends { path: string }>(files: T[], includeFolders: string[], excludeFolders: string[]): T[] {
const normalizedIncludes = includeFolders.map(normalizeFolderPath).filter(Boolean);
const normalizedExcludes = excludeFolders.map(normalizeFolderPath).filter(Boolean);

View file

@ -109,6 +109,10 @@ class FakeAnkiGateway implements AnkiGateway {
this.ensuredDecks.push(deckName);
}
async ensureDecks(deckNames: string[]): Promise<void> {
this.ensuredDecks.push(...deckNames);
}
async listNoteModels(): Promise<string[]> {
return Object.keys(this.modelDetailsByName);
}
@ -130,13 +134,31 @@ class FakeAnkiGateway implements AnkiGateway {
return this.nextAddedNoteId;
}
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
return Promise.all(inputs.map((input) => this.addNote(input)));
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updatedNotes.push(input);
}
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
for (const input of inputs) {
await this.updateNote(input);
}
}
async changeDecks(): Promise<void> {}
async storeMedia(asset: { fileName: string }): Promise<void> {
this.storedMedia.push(asset.fileName);
}
async storeMediaFiles(assets: Array<{ fileName: string }>): Promise<void> {
for (const asset of assets) {
await this.storeMedia(asset);
}
}
}
function createCard(overrides: Partial<Card> = {}): Card {
@ -623,7 +645,7 @@ describe("ExecuteSyncPlanUseCase", () => {
[card.source.filePath]: card.source.sourceContent ?? "",
});
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", cardIds: [1] });
const existingRecord = {
cardKey: createCardKey("legacy-embedded-key"),
identityMode: "embedded-note-id" as const,
@ -716,7 +738,7 @@ describe("ExecuteSyncPlanUseCase", () => {
});
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", cardIds: [1] });
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
@ -785,7 +807,7 @@ describe("ExecuteSyncPlanUseCase", () => {
});
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", cardIds: [1] });
const repository = new InMemorySyncRegistryRepository();
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);
@ -848,7 +870,7 @@ describe("ExecuteSyncPlanUseCase", () => {
};
const vaultGateway = new FakeVaultGateway({ "notes/current.md": card.source.sourceContent ?? "" });
const ankiGateway = new FakeAnkiGateway();
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic" });
ankiGateway.noteSummariesById.set(42, { noteId: 42, modelName: "Basic", cardIds: [1] });
const repository = new InMemorySyncRegistryRepository(new SyncRegistry([pendingRecord]));
const useCase = new ExecuteSyncPlanUseCase(ankiGateway, repository, vaultGateway, undefined, () => 1234);

View file

@ -0,0 +1,11 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
import type { ManualSyncService } from "@/application/services/ManualSyncService";
export class ManualSyncCurrentFileUseCase {
constructor(private readonly manualSyncService: ManualSyncService) {}
async execute(filePath: string, settings: PluginSettings): Promise<ManualSyncResult> {
return this.manualSyncService.syncFile(filePath, settings);
}
}

View file

@ -0,0 +1,11 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
import type { ManualSyncService } from "@/application/services/ManualSyncService";
export class ManualSyncVaultUseCase {
constructor(private readonly manualSyncService: ManualSyncService) {}
async execute(settings: PluginSettings): Promise<ManualSyncResult> {
return this.manualSyncService.syncVault(settings);
}
}

View file

@ -0,0 +1,11 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes";
import type { ManualSyncService } from "@/application/services/ManualSyncService";
export class RebuildCardIndexUseCase {
constructor(private readonly manualSyncService: ManualSyncService) {}
async execute(settings: PluginSettings): Promise<ManualSyncResult> {
return this.manualSyncService.rebuildIndex(settings);
}
}

View file

@ -92,6 +92,10 @@ class FakeAnkiGateway implements AnkiGateway {
this.ensureDeckCalls.push(deckName);
}
async ensureDecks(deckNames: string[]): Promise<void> {
this.ensureDeckCalls.push(...deckNames);
}
async listNoteModels(): Promise<string[]> {
return ["Basic", "Cloze"];
}
@ -102,7 +106,7 @@ class FakeAnkiGateway implements AnkiGateway {
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string; cardIds: number[] }>> {
return [];
}
@ -111,13 +115,31 @@ class FakeAnkiGateway implements AnkiGateway {
return 500 + this.addCalls.length;
}
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
return Promise.all(inputs.map((input) => this.addNote(input)));
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updateCalls.push(input);
}
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
for (const input of inputs) {
await this.updateNote(input);
}
}
async changeDecks(): Promise<void> {}
async storeMedia(asset: { fileName: string }): Promise<void> {
this.storedMedia.push(asset.fileName);
}
async storeMediaFiles(assets: Array<{ fileName: string }>): Promise<void> {
for (const asset of assets) {
await this.storeMedia(asset);
}
}
}
function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings {

View file

@ -84,6 +84,10 @@ class FakeAnkiGateway implements AnkiGateway {
this.ensureDeckCalls.push(deckName);
}
async ensureDecks(deckNames: string[]): Promise<void> {
this.ensureDeckCalls.push(...deckNames);
}
async listNoteModels(): Promise<string[]> {
return ["Basic", "Cloze"];
}
@ -94,7 +98,7 @@ class FakeAnkiGateway implements AnkiGateway {
: { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string }>> {
async getNoteSummaries(): Promise<Array<{ noteId: number; modelName: string; cardIds: number[] }>> {
return [];
}
@ -103,11 +107,25 @@ class FakeAnkiGateway implements AnkiGateway {
return 200 + this.addCalls.length;
}
async addNotes(inputs: Array<{ deckName: string; modelName: string; fields: Record<string, string>; tags: string[] }>): Promise<number[]> {
return Promise.all(inputs.map((input) => this.addNote(input)));
}
async updateNote(input: { noteId: number; deckName: string; fields: Record<string, string> }): Promise<void> {
this.updateCalls.push(input);
}
async updateNotes(inputs: Array<{ noteId: number; deckName: string; fields: Record<string, string> }>): Promise<void> {
for (const input of inputs) {
await this.updateNote(input);
}
}
async changeDecks(): Promise<void> {}
async storeMedia(): Promise<void> {}
async storeMediaFiles(): Promise<void> {}
}
function createSettings(overrides: Partial<PluginSettings> = {}): PluginSettings {

View file

@ -0,0 +1,11 @@
export interface ManualSyncResult {
scannedFiles: number;
scannedCards: number;
created: number;
updated: number;
orphaned: number;
uploadedMedia: number;
skippedUnchangedCards: number;
rewrittenMarkers: number;
markerWriteConflictFiles: string[];
}

View file

@ -0,0 +1,8 @@
export interface AhsMarker {
cardId: string;
noteId?: number;
raw: string;
lineIndex: number;
}
export type MarkerState = "missing" | "card-only" | "card-and-note";

View file

@ -0,0 +1,26 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
import type { MarkerState } from "@/domain/manual-sync/entities/AhsMarker";
export interface IndexedCard {
cardId: string;
noteId?: number;
markerNoteId?: number;
filePath: string;
cardType: CardType;
heading: string;
headingLevel: number;
bodyMarkdown: string;
blockStartOffset: number;
blockEndOffset: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
rawBlockText: string;
rawBlockHash: string;
deckHint?: string;
tagsHint: string[];
markerState: MarkerState;
sourceContent?: string;
}

View file

@ -0,0 +1,9 @@
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
export interface IndexedFile {
filePath: string;
fileHash: string;
fileStamp: string;
content?: string;
cards: IndexedCard[];
}

View file

@ -0,0 +1,57 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
export interface FileState {
filePath: string;
fileHash: string;
fileStamp: string;
lastIndexedAt: number;
cardIds: string[];
}
export interface CardState {
cardId: string;
noteId?: number;
filePath: string;
heading: string;
headingLevel: number;
bodyMarkdown: string;
cardType: CardType;
blockStartOffset: number;
blockEndOffset: number;
blockStartLine: number;
bodyStartLine: number;
blockEndLine: number;
contentEndLine: number;
markerLine?: number;
rawBlockText: string;
rawBlockHash: string;
renderConfigHash: string;
deck: string;
deckHint?: string;
tagsHint: string[];
lastSyncedAt: number;
orphan: boolean;
}
export interface PendingWriteBackState {
filePath: string;
cardId: string;
noteId?: number;
expectedFileHash: string;
targetMarker: string;
rawBlockHash: string;
}
export interface PluginState {
files: Record<string, FileState>;
cards: Record<string, CardState>;
pendingWriteBack: PendingWriteBackState[];
}
export function createEmptyPluginState(): PluginState {
return {
files: {},
cards: {},
pendingWriteBack: [],
};
}

View file

@ -0,0 +1,12 @@
import type { MediaAsset, RenderedFields } from "@/domain/card/entities/RenderedFields";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
export interface RenderedSyncCard {
card: IndexedCard;
noteId?: number;
deck: string;
noteModel: string;
renderConfigHash: string;
renderedFields: RenderedFields;
media: MediaAsset[];
}

View file

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { hashString } from "@/domain/shared/hash";
import { CardIndexingService } from "./CardIndexingService";
describe("CardIndexingService", () => {
it("generates cardId for a missing marker and excludes the marker from body text", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "Answer"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
);
expect(indexedFile.cards).toHaveLength(1);
expect(indexedFile.cards[0]?.cardId.startsWith("ahs_")).toBe(true);
expect(indexedFile.cards[0]?.markerState).toBe("missing");
expect(indexedFile.cards[0]?.bodyMarkdown).toBe("Answer");
});
it("reuses cardId and noteId from pending write-back when marker is still missing", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: ["#### Prompt", "Answer"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [
{
filePath: "notes/example.md",
cardId: "ahs_known",
noteId: 42,
expectedFileHash: "hash",
targetMarker: "<!-- AHS:card=ahs_known note=42 -->",
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", "<!-- AHS:card=ahs_1 -->", "Answer", "<!-- AHS:card=ahs_2 -->"].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
},
),
).toThrow("Multiple AHS markers");
});
});

View file

@ -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<string>();
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<string, CardState>,
pendingByCardId: Map<string, PendingWriteBackState>,
reusableKnownCards: CardState[],
usedCardIds: Set<string>,
): { 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<typeof marker> => 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;
}

View file

@ -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("<!-- AHS:card=ahs_123 note=42 -->", 3)).toEqual({
cardId: "ahs_123",
noteId: 42,
raw: "<!-- AHS:card=ahs_123 note=42 -->",
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",
"<!-- AHS:card=ahs_top note=11 -->",
"",
"#### Bottom",
"Body 2",
"<!-- AHS:card=ahs_bottom note=22 -->",
].join("\n"));
});
});

View file

@ -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*AHS:/;
const AHS_MARKER_REGEXP = /^\s*<!--\s*AHS:card=([A-Za-z0-9_-]+)(?:\s+note=([1-9]\d*))?\s*-->\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 ? `<!-- AHS:card=${cardId} note=${noteId} -->` : `<!-- AHS:card=${cardId} -->`,
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<number>();
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);
}
}

View file

@ -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: "<!-- AHS:card=ahs_1 note=42 -->",
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"]);
});
});

View file

@ -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<string, IndexedCard>();
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,
};
}
}

View file

@ -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 = /(?<!\\)\$\$([\s\S]+?)(?<!\\)\$\$/g;
const INLINE_MATH_PATTERN = /(?<!\\)\$(?!\s)([^$\n]+?)(?<!\s)\$/g;
const ANKI_MATH_PATTERN = /(\\\[[\s\S]*?\\\])|(\\\([\s\S]*?\\\))/g;
const HIGHLIGHT_PATTERN = /==(.+?)==/g;
const CLOZE_PATTERN = /(?:(?<!{){(?:c?(\d+)[:|])?(?!{))((?:[^\n][\n]?)+?)(?:(?<!})}(?!}))/g;
const EMBED_PATTERN = /!\[\[([^\]]+)\]\]/g;
const WIKILINK_PATTERN = /(?<!!)\[\[([^\]]+)\]\]/g;
export interface ManualCardRenderContext {
addObsidianBacklink: boolean;
convertHighlightsToCloze: boolean;
resourceResolver: ManualSyncVaultGateway;
}
interface RenderResult {
html: string;
media: MediaAsset[];
}
export class ManualCardRenderer {
render(plannedCard: PlannedCard, context: ManualCardRenderContext): RenderedSyncCard {
const headingResult = this.renderMarkdown(plannedCard.card.heading, plannedCard, context, false, true);
const bodyResult = this.renderMarkdown(
plannedCard.card.bodyMarkdown,
plannedCard,
context,
plannedCard.card.cardType === "cloze",
false,
);
const backlinkHtml = context.addObsidianBacklink
? `<p><a class="anki-heading-sync-backlink" href="${escapeHtml(context.resourceResolver.createBacklink({
filePath: plannedCard.card.filePath,
sourceContent: plannedCard.card.sourceContent,
headingLine: plannedCard.card.blockStartLine,
blockStartLine: plannedCard.card.blockStartLine,
bodyStartLine: plannedCard.card.bodyStartLine,
blockEndLine: plannedCard.card.blockEndLine,
contentEndLine: plannedCard.card.contentEndLine,
markerLine: plannedCard.card.markerLine,
headingLevel: plannedCard.card.headingLevel,
headingText: plannedCard.card.heading,
}))}">Open in Obsidian</a></p>`
: "";
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 `<img src="${escapeHtml(resolvedEmbed.fileName)}" alt="${escapeHtml(resolvedEmbed.altText ?? resolvedEmbed.fileName)}">`;
});
transformed = transformed.replace(WIKILINK_PATTERN, (_match, rawTarget: string) => {
const resolvedLink = context.resourceResolver.resolveWikiLink(rawTarget, plannedCard.card.filePath);
if (!resolvedLink) {
return _match;
}
return `<a href="${escapeHtml(resolvedLink.url)}">${escapeHtml(resolvedLink.displayText)}</a>`;
});
transformed = transformed.replace(HIGHLIGHT_PATTERN, "<mark>$1</mark>");
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<string>();
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View file

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

View file

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

View file

@ -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<T> {
@ -24,6 +24,14 @@ export class AnkiConnectGateway implements AnkiGateway {
await this.invoke("createDeck", { deck: deckName });
}
async ensureDecks(deckNames: string[]): Promise<void> {
const uniqueDeckNames = Array.from(new Set(deckNames));
await this.invokeMulti<void>(uniqueDeckNames.map((deckName) => ({
action: "createDeck",
params: { deck: deckName },
})));
}
async listNoteModels(): Promise<string[]> {
return this.invoke<string[]>("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<number[]> {
return this.invokeMulti<number>(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<void> {
await this.invoke("updateNoteFields", {
note: {
@ -102,6 +129,28 @@ export class AnkiConnectGateway implements AnkiGateway {
}
}
async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
await this.invokeMulti<void>(inputs.map((input) => ({
action: "updateNoteFields",
params: {
note: {
id: input.noteId,
fields: input.fields,
},
},
})));
}
async changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
await this.invokeMulti<void>(inputs.filter((input) => input.cardIds.length > 0).map((input) => ({
action: "changeDeck",
params: {
cards: input.cardIds,
deck: input.deckName,
},
})));
}
async storeMedia(asset: MediaAsset): Promise<void> {
await this.invoke("storeMediaFile", {
filename: asset.fileName,
@ -109,6 +158,16 @@ export class AnkiConnectGateway implements AnkiGateway {
});
}
async storeMediaFiles(assets: MediaAsset[]): Promise<void> {
await this.invokeMulti<void>(assets.map((asset) => ({
action: "storeMediaFile",
params: {
filename: asset.fileName,
path: asset.absolutePath,
},
})));
}
private async invoke<TResult>(action: string, params: Record<string, unknown>): Promise<TResult> {
const response = await requestUrl({
url: this.getBaseUrl(),
@ -128,4 +187,12 @@ export class AnkiConnectGateway implements AnkiGateway {
return parsed.result;
}
private async invokeMulti<TResult>(actions: Array<{ action: string; params: Record<string, unknown> }>): Promise<TResult[]> {
if (actions.length === 0) {
return [];
}
return this.invoke<TResult[]>("multi", { actions });
}
}

View file

@ -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<MarkdownFileReference[]> {
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);

View file

@ -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<PluginSettings>;
pluginState?: PluginState;
syncRegistry?: {
records: Array<{
cardKey: string;

View file

@ -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<PluginDataSnapshot> {
constructor(private snapshot: PluginDataSnapshot | null = null) {}
async load(): Promise<PluginDataSnapshot | null> {
return this.snapshot;
}
async save(data: PluginDataSnapshot): Promise<void> {
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");
});
});

View file

@ -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<PluginDataSnapshot>) {}
async load(): Promise<PluginState> {
const snapshot = (await this.pluginDataStore.load()) ?? {};
return snapshot.pluginState ?? createEmptyPluginState();
}
async save(state: PluginState): Promise<void> {
const snapshot = (await this.pluginDataStore.load()) ?? {};
await this.pluginDataStore.save({
...snapshot,
pluginState: state,
});
}
}

View file

@ -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<void> {
const pluginDataStore = new ObsidianPluginDataStore<PluginDataSnapshot>(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<void> {
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.");
}
}
}

View file

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

View file

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

View file

@ -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<PluginState> {
return this.state;
}
async save(state: PluginState): Promise<void> {
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<string>();
public errorPaths = new Map<string, Error>();
private readonly files = new Map<string, { content: string; mtime: number; size: number }>();
constructor(initialFiles: Record<string, string> = {}) {
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<SourceFile | null> {
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<void> {
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<number, AnkiNoteSummary>();
public modelDetailsByName: Record<string, NoteModelDetails> = {
Basic: { fieldNames: ["Front", "Back"], isCloze: false },
Cloze: { fieldNames: ["Text", "Extra"], isCloze: true },
};
private nextNoteId = 9000;
async ensureDeckExists(deckName: string): Promise<void> {
await this.ensureDecks([deckName]);
}
async ensureDecks(deckNames: string[]): Promise<void> {
this.ensuredDecks.push(deckNames);
}
async listNoteModels(): Promise<string[]> {
return Object.keys(this.modelDetailsByName);
}
async getModelDetails(modelName: string): Promise<NoteModelDetails> {
return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false };
}
async getNoteSummaries(noteIds: number[]): Promise<AnkiNoteSummary[]> {
return noteIds.flatMap((noteId) => {
const summary = this.noteSummariesById.get(noteId);
return summary ? [summary] : [];
});
}
async addNote(input: AddAnkiNoteInput): Promise<number> {
const [noteId] = await this.addNotes([input]);
return noteId;
}
async addNotes(inputs: AddAnkiNoteInput[]): Promise<number[]> {
return inputs.map((input) => {
this.addedNotes.push(input);
this.nextNoteId += 1;
return this.nextNoteId;
});
}
async updateNote(input: UpdateAnkiNoteInput): Promise<void> {
await this.updateNotes([input]);
}
async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise<void> {
this.updatedNotes.push(...inputs);
}
async changeDecks(inputs: ChangeDeckInput[]): Promise<void> {
this.changedDecks.push(...inputs);
}
async storeMedia(asset: MediaAsset): Promise<void> {
await this.storeMediaFiles([asset]);
}
async storeMediaFiles(assets: MediaAsset[]): Promise<void> {
this.storedMedia.push(...assets);
}
}
export function createModule3Settings(overrides: Partial<PluginSettings> = {}): 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,
};
}