mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: implement module 3 manual sync architecture
This commit is contained in:
parent
be0277d089
commit
d3d356fb10
46 changed files with 3672 additions and 37 deletions
657
docs/2026-04-18PLAN4.md
Normal file
657
docs/2026-04-18PLAN4.md
Normal 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 1:Identity And Marker
|
||||
|
||||
- 实现 `cardId` 生成规则
|
||||
- 实现 `AHS` marker 解析与块尾写回
|
||||
- 为卡片块补写 `cardId`
|
||||
|
||||
### Phase 2:File And Card Index
|
||||
|
||||
- 文件路径过滤
|
||||
- 文件级 hash
|
||||
- 卡片块提取
|
||||
- 卡片级 `rawBlockHash`
|
||||
|
||||
### Phase 3:Local State
|
||||
|
||||
- 文件层状态持久化
|
||||
- 卡片层状态持久化
|
||||
- pending write-back 状态持久化
|
||||
|
||||
### Phase 4:Planner
|
||||
|
||||
- `toCreate`
|
||||
- `toUpdate`
|
||||
- `toOrphan`
|
||||
- `toRewriteMarker`
|
||||
|
||||
### Phase 5:Renderer
|
||||
|
||||
- 只为待同步卡渲染字段
|
||||
- 解析 media / wikilink / cloze
|
||||
|
||||
### Phase 6:Anki Batch Executor
|
||||
|
||||
- 批量 `notesInfo`
|
||||
- 批量 `addNote`
|
||||
- 批量 `updateNoteFields`
|
||||
- deck 分组 `changeDeck`
|
||||
- media 批量上传
|
||||
|
||||
### Phase 7:Commands And Notice
|
||||
|
||||
- 当前文件同步命令
|
||||
- 全库同步命令
|
||||
- 重建索引命令
|
||||
- 结果 notice
|
||||
|
||||
## Public Interfaces / Types
|
||||
|
||||
### Marker
|
||||
|
||||
- `AhsMarker`
|
||||
- `cardId: string`
|
||||
- `noteId?: number`
|
||||
- `raw: string`
|
||||
- `lineIndex: number`
|
||||
|
||||
### File Index
|
||||
|
||||
- `IndexedFile`
|
||||
- `filePath: string`
|
||||
- `fileHash: string`
|
||||
- `cards: IndexedCard[]`
|
||||
|
||||
### Card Index
|
||||
|
||||
- `IndexedCard`
|
||||
- `cardId: string`
|
||||
- `noteId?: number`
|
||||
- `filePath: string`
|
||||
- `cardType: "basic" | "cloze"`
|
||||
- `heading: string`
|
||||
- `rawBlockHash: string`
|
||||
- `markerLine?: number`
|
||||
|
||||
### Planner
|
||||
|
||||
- `SyncPlan`
|
||||
- `toCreate: IndexedCard[]`
|
||||
- `toUpdate: IndexedCard[]`
|
||||
- `toOrphan: string[]`
|
||||
- `toRewriteMarker: IndexedCard[]`
|
||||
|
||||
### Local State
|
||||
|
||||
- `PluginState`
|
||||
- `files: Record<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 执行层必须按类型批处理
|
||||
79
docs/implementation-decisions.md
Normal file
79
docs/implementation-decisions.md
Normal 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` 为空数组,但批处理执行层保留接口位置
|
||||
144
docs/implementation-gap-report.md
Normal file
144
docs/implementation-gap-report.md
Normal 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 接管插件命令入口
|
||||
|
|
@ -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>;
|
||||
}
|
||||
15
src/application/ports/ManualSyncVaultGateway.ts
Normal file
15
src/application/ports/ManualSyncVaultGateway.ts
Normal 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>;
|
||||
}
|
||||
6
src/application/ports/PluginStateRepository.ts
Normal file
6
src/application/ports/PluginStateRepository.ts
Normal 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>;
|
||||
}
|
||||
210
src/application/services/AnkiBatchExecutor.ts
Normal file
210
src/application/services/AnkiBatchExecutor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
50
src/application/services/BatchScheduler.ts
Normal file
50
src/application/services/BatchScheduler.ts
Normal 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;
|
||||
}
|
||||
75
src/application/services/FileIndexerService.test.ts
Normal file
75
src/application/services/FileIndexerService.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
155
src/application/services/FileIndexerService.ts
Normal file
155
src/application/services/FileIndexerService.ts
Normal 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}`;
|
||||
}
|
||||
57
src/application/services/ManualSyncService.test.ts
Normal file
57
src/application/services/ManualSyncService.test.ts
Normal 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=");
|
||||
});
|
||||
});
|
||||
232
src/application/services/ManualSyncService.ts
Normal file
232
src/application/services/ManualSyncService.ts
Normal 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")}`;
|
||||
}
|
||||
}
|
||||
102
src/application/services/MarkdownWriteBackService.ts
Normal file
102
src/application/services/MarkdownWriteBackService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
|
|
|
|||
33
src/application/services/RenderConfigService.ts
Normal file
33
src/application/services/RenderConfigService.ts
Normal 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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
11
src/application/use-cases/ManualSyncCurrentFileUseCase.ts
Normal file
11
src/application/use-cases/ManualSyncCurrentFileUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/application/use-cases/ManualSyncVaultUseCase.ts
Normal file
11
src/application/use-cases/ManualSyncVaultUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/application/use-cases/RebuildCardIndexUseCase.ts
Normal file
11
src/application/use-cases/RebuildCardIndexUseCase.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
11
src/application/use-cases/manualSyncTypes.ts
Normal file
11
src/application/use-cases/manualSyncTypes.ts
Normal 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[];
|
||||
}
|
||||
8
src/domain/manual-sync/entities/AhsMarker.ts
Normal file
8
src/domain/manual-sync/entities/AhsMarker.ts
Normal 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";
|
||||
26
src/domain/manual-sync/entities/IndexedCard.ts
Normal file
26
src/domain/manual-sync/entities/IndexedCard.ts
Normal 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;
|
||||
}
|
||||
9
src/domain/manual-sync/entities/IndexedFile.ts
Normal file
9
src/domain/manual-sync/entities/IndexedFile.ts
Normal 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[];
|
||||
}
|
||||
57
src/domain/manual-sync/entities/PluginState.ts
Normal file
57
src/domain/manual-sync/entities/PluginState.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
12
src/domain/manual-sync/entities/RenderedSyncCard.ts
Normal file
12
src/domain/manual-sync/entities/RenderedSyncCard.ts
Normal 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[];
|
||||
}
|
||||
83
src/domain/manual-sync/services/CardIndexingService.test.ts
Normal file
83
src/domain/manual-sync/services/CardIndexingService.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
354
src/domain/manual-sync/services/CardIndexingService.ts
Normal file
354
src/domain/manual-sync/services/CardIndexingService.ts
Normal 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;
|
||||
}
|
||||
52
src/domain/manual-sync/services/CardMarkerService.test.ts
Normal file
52
src/domain/manual-sync/services/CardMarkerService.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
117
src/domain/manual-sync/services/CardMarkerService.ts
Normal file
117
src/domain/manual-sync/services/CardMarkerService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
147
src/domain/manual-sync/services/DiffPlannerService.test.ts
Normal file
147
src/domain/manual-sync/services/DiffPlannerService.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
81
src/domain/manual-sync/services/DiffPlannerService.ts
Normal file
81
src/domain/manual-sync/services/DiffPlannerService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
187
src/domain/manual-sync/services/ManualCardRenderer.ts
Normal file
187
src/domain/manual-sync/services/ManualCardRenderer.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
18
src/domain/manual-sync/value-objects/ManualSyncPlan.ts
Normal file
18
src/domain/manual-sync/value-objects/ManualSyncPlan.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
199
src/test-support/manualSyncFakes.ts
Normal file
199
src/test-support/manualSyncFakes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue