diff --git a/docs/2026-04-18PLAN4.md b/docs/2026-04-18PLAN4.md new file mode 100644 index 0000000..1abadbd --- /dev/null +++ b/docs/2026-04-18PLAN4.md @@ -0,0 +1,657 @@ +# 模块 3:全新手动同步型插件的高性能架构开发计划 + +## Summary + +- 本模块不再基于当前插件做兼容式演进,而是按“全新插件”设计。 +- 核心交互改为手动触发,不做默认自动同步。 +- 核心目标不是最少开发量,而是“手动同步体验稳定 + 全库同步性能尽可能高 + 后续可扩展”。 +- 不考虑老卡兼容、不考虑迁移、不考虑复用旧的 `legacy cardKey` 身份体系。 +- 允许插件在“用户主动点击同步”时回写 Markdown 标记,但禁止在日常编辑过程中自动改正文。 + +## Product Goals + +- 支持两个主命令: + - `同步当前文件到 Anki` + - `同步全库到 Anki` +- 支持一个维护命令: + - `重建卡片索引` +- 支持标题块卡片模型: + - 某一级标题 = Basic 卡 + - 某一级标题 = Cloze 卡 +- 同步身份稳定: + - Markdown 侧身份使用 `cardId` + - Anki 侧身份使用 `noteId` +- 全库同步性能最优先: + - 先过滤文件路径,再读文件内容 + - 文件没变则跳过 + - 文件变了也只更新变动卡片块 + - Anki 请求按批次执行,不按单卡逐条发 + +## Non Goals + +- 不做自动实时同步 +- 不做旧插件数据迁移 +- 不做旧卡兼容逻辑 +- 不做双向同步 +- 不做 Anki 卡片模板 HTML/CSS 编辑器 +- 不支持无 marker 的长期身份推断策略 + +## User Interaction Model + +### 1. 默认模式:手动同步 + +- 插件默认不监听文件改动后自动同步。 +- 只有用户主动点击命令时,才允许: + - 扫描文件 + - 规划同步 + - 调用 Anki + - 回写 Markdown 标记 + +### 2. 命令设计 + +- `同步当前文件到 Anki` + - 读取当前激活 Markdown 文件 + - 仅处理该文件内的卡片块 +- `同步全库到 Anki` + - 处理配置范围内全部 Markdown 文件 + - 使用文件级和卡片级缓存跳过未变内容 +- `重建卡片索引` + - 重建本地状态 + - 不默认执行全量 Anki 字段更新 + +### 3. Notice / Result 输出 + +- 每次同步结束后展示: + - 扫描文件数 + - 扫描卡片数 + - 新增卡数 + - 更新卡数 + - orphan / 删除数 + - 上传 media 数 + - 跳过未变化卡数 +- 若发生 Markdown 回写冲突: + - 明确提示哪些文件未完成 marker 写回 + - 不使用泛化报错文案 + +## Identity Model + +### 1. 双身份设计 + +- `cardId` + - Markdown 侧身份 + - 代表某个标题块本身 + - 一旦生成,不再修改 +- `noteId` + - Anki 侧身份 + - 代表 Anki 中的 note + +### 2. 标记格式 + +- 块尾机器标记固定为: + - `` +- 在首次发现卡片但尚未创建 Anki note 时,允许写成: + - `` + +### 3. 标记位置 + +- 统一放在标题块末尾: + - 当前标题块最后一个非空内容行之后 + - 下一个同级或更高层级标题之前 +- 目标示例: + ```md + #### 标题 + 正文内容 + + ``` + +### 4. 身份规则 + +- 插件长期只认 `cardId` +- `noteId` 是 `cardId` 指向的远端目标 +- 不再使用 `标题 + 行号 + 路径` 作为正式身份 +- 不做无 marker 时的长期启发式身份匹配 + +## Card Model + +### 1. 卡片边界 + +- 从当前目标标题开始 +- 到下一个同级或更高层级标题前结束 + +### 2. 卡片类型 + +- `basic` +- `cloze` + +### 3. 每张卡的最小索引字段 + +- `cardId` +- `noteId?` +- `filePath` +- `heading` +- `headingLevel` +- `cardType` +- `blockStartOffset` +- `blockEndOffset` +- `rawBlockText` +- `rawBlockHash` +- `markerLine?` +- `deckHint?` +- `tagsHint[]` + +### 4. 每张卡的渲染字段 + +- `renderedTitle` +- `renderedBody` +- `renderedFields` +- `mediaManifest` +- `renderConfigHash` + +## Architecture + +### 1. Card Marker Service + +负责: + +- 解析 `AHS` 标记 +- 生成新的 `cardId` +- 生成标准 marker 文本 +- 在块尾插入或替换 marker + +必须保证: + +- 一个标题块最多一个合法 marker +- marker 不进入正文内容 +- 块尾写回顺序稳定 + +### 2. File Indexer + +负责: + +- 列出候选 Markdown 文件 +- 先按 include/exclude 过滤路径 +- 读取变更文件内容 +- 提取卡片块 +- 生成文件级 hash 与卡片级 hash + +必须保证: + +- 不在扫描阶段做完整 HTML render +- 不在这里调用 Anki + +### 3. Diff Planner + +负责: + +- 比较“当前索引”和“本地状态” +- 输出: + - `toCreate` + - `toUpdate` + - `toOrphan` + - `toDelete`(如果启用) + - `toRewriteMarker` + +### 4. Renderer + +负责: + +- 只渲染待同步卡片 +- 解析 markdown / cloze / embeds / wikilinks +- 生成最终 Anki 字段 + +必须保证: + +- 未变化卡片不进入 renderer + +### 5. Anki Batch Executor + +负责: + +- 批量 createDeck +- 批量 notesInfo +- 批量 addNote +- 批量 updateNoteFields +- 按 deck 分组批量 changeDeck +- 批量 tag 操作 +- 批量 storeMedia + +### 6. Markdown Write Back Service + +负责: + +- 按文件聚合 marker 写回 +- 同一文件只写一次 +- optimistic concurrency 校验 +- 失败时记录待补写状态 + +### 7. Local State Store + +负责: + +- 持久化文件索引 +- 持久化卡片索引 +- 持久化 `cardId -> noteId` +- 持久化待补写 marker 状态 + +## File Discovery And Scan Strategy + +### 1. 文件发现顺序 + +- 先列出全部 Markdown 文件路径 +- 再做 include/exclude 过滤 +- 只读取命中的文件 + +### 2. 文件级缓存 + +本地状态必须保存: + +- `filePath` +- `fileHash` +- `lastIndexedAt` +- `cardIds[]` + +### 3. 文件级跳过策略 + +- 文件 `fileHash` 未变化: + - 全文件跳过 + - 不提取卡片 + - 不 render + - 不发 Anki 请求 + +### 4. 卡片级提取 + +对变更文件: + +- 提取标题块 +- 提取 marker +- 计算 `rawBlockHash` +- 与本地卡片状态对比 + +### 5. 卡片级跳过策略 + +- `rawBlockHash` 未变化: + - 卡片跳过 + - 不 render + - 不 update Anki + +## Rendering Strategy + +### 1. 渲染延后 + +- 扫描阶段只做轻量提取 +- 只有 `toCreate` / `toUpdate` 才进入渲染阶段 + +### 2. 渲染配置哈希 + +除了 `rawBlockHash`,还必须保存: + +- `renderConfigHash` + +它至少包含: + +- note type +- 字段映射 +- deck 规则 +- backlink 开关 +- cloze 转换开关 + +这样即使 Markdown 没变,但配置变了,也会触发 update。 + +### 3. 渲染产物 + +- `title` +- `body` +- `fields` +- `mediaManifest` + +## Sync Planning Rules + +### 1. `toCreate` + +条件: + +- 卡片有 `cardId` +- 本地状态中没有 `noteId` + +### 2. `toUpdate` + +条件满足任一: + +- 本地有 `noteId` 且 `rawBlockHash` 变化 +- 本地有 `noteId` 且 `renderConfigHash` 变化 +- 本地记录为 pending write-back + +### 3. `toOrphan` + +条件: + +- 本地状态里存在 `cardId` +- 当前索引中该 `cardId` 已消失 + +第一版默认: + +- 仅标记 orphan +- 不自动删 Anki note + +### 4. `toRewriteMarker` + +条件: + +- 缺失 `cardId` +- 已有 `cardId` 但缺失 `noteId` +- marker 格式可恢复但内容过时 + +## Anki Execution Strategy + +### 1. 请求批处理原则 + +- 所有 Anki 操作按“操作类型”分批 +- 禁止按单卡逐条独立请求 +- 支持受控 batch size +- 支持受控并发 + +### 2. 推荐执行顺序 + +1. `createDeck` +2. `notesInfo(existing noteIds)` +3. `addNote` +4. `updateNoteFields` +5. `changeDeck` 按目标 deck 分组 +6. `removeTags` / `addTags` +7. `storeMedia` +8. Markdown marker 写回 + +### 3. `notesInfo` + +- 对全部待更新 noteId 一次拉取 +- 不能在 `updateNote` 内部每张卡再补一轮 `notesInfo` + +### 4. `changeDeck` + +- 先拿 note 对应 cardIds +- 再按 deck 分组聚合 +- 同目标 deck 的卡一次 `changeDeck` + +### 5. Media 上传 + +- 先全局去重 +- key 至少包含: + - `absolutePath` + - `targetFileName` + - `mtime` 或 content hash +- 用 3 到 5 并发上传 +- 避免无脑全并发 + +### 6. 批处理调度器 + +建议新增 `BatchScheduler` + +负责: + +- 切分 batch +- 控制并发数 +- 失败收集 +- 对可重试错误做有限重试 + +## Markdown Write Back Strategy + +### 1. 写回时机 + +- 仅在用户主动点击同步命令时允许写回 +- 平时编辑过程中绝不自动改正文 + +### 2. 写回内容 + +- 补 `cardId` +- 补 `noteId` +- 修复过时 marker + +### 3. 写回粒度 + +- 同一文件一次写回 +- 同步结束后按文件聚合执行 + +### 4. 写回顺序 + +- 同一文件多个块从下往上处理 +- 避免上方插入影响下方定位 + +### 5. 冲突控制 + +- 写回前比较当前文件 hash / expected content +- 若文件已变化: + - 不覆盖 + - 记录 pending write-back + - 在结果 notice 中明确提示 + +## Local State Design + +### 1. 文件层状态 + +- `filePath` +- `fileHash` +- `lastIndexedAt` +- `cardIds[]` + +### 2. 卡片层状态 + +- `cardId` +- `noteId?` +- `filePath` +- `rawBlockHash` +- `renderConfigHash` +- `lastSyncedAt` +- `cardType` +- `deck` +- `orphan` + +### 3. 待补写状态 + +- `filePath` +- `cardId` +- `noteId` +- `expectedFileHash` +- `targetMarker` + +### 4. 存储要求 + +- 单次同步结束统一保存 +- 必须支持局部覆盖和完整重建 + +## Commands And UX + +### 1. 同步当前文件 + +流程: + +1. 获取当前 Markdown 文件 +2. 提取卡片块 +3. 补齐缺失 `cardId` +4. 生成计划 +5. 执行 Anki 批同步 +6. 按文件回写 marker +7. 更新本地状态 +8. 展示结果 notice + +### 2. 同步全库 + +流程: + +1. 列出全部 Markdown 文件路径 +2. include/exclude 过滤 +3. 文件 hash 对比,跳过未变化文件 +4. 对变更文件做块级 diff +5. 渲染待同步卡 +6. 批量执行 Anki 请求 +7. 批量写回 marker +8. 保存本地状态 +9. 展示结果 notice + +### 3. 重建卡片索引 + +流程: + +1. 全库重建文件索引与卡片索引 +2. 不默认发送所有更新到 Anki +3. 修复本地状态与文件 marker 的一致性 + +## Recommended Implementation Order + +### Phase 1:Identity And Marker + +- 实现 `cardId` 生成规则 +- 实现 `AHS` marker 解析与块尾写回 +- 为卡片块补写 `cardId` + +### Phase 2:File And Card Index + +- 文件路径过滤 +- 文件级 hash +- 卡片块提取 +- 卡片级 `rawBlockHash` + +### Phase 3:Local State + +- 文件层状态持久化 +- 卡片层状态持久化 +- pending write-back 状态持久化 + +### Phase 4:Planner + +- `toCreate` +- `toUpdate` +- `toOrphan` +- `toRewriteMarker` + +### Phase 5:Renderer + +- 只为待同步卡渲染字段 +- 解析 media / wikilink / cloze + +### Phase 6:Anki Batch Executor + +- 批量 `notesInfo` +- 批量 `addNote` +- 批量 `updateNoteFields` +- deck 分组 `changeDeck` +- media 批量上传 + +### Phase 7:Commands And Notice + +- 当前文件同步命令 +- 全库同步命令 +- 重建索引命令 +- 结果 notice + +## Public Interfaces / Types + +### Marker + +- `AhsMarker` + - `cardId: string` + - `noteId?: number` + - `raw: string` + - `lineIndex: number` + +### File Index + +- `IndexedFile` + - `filePath: string` + - `fileHash: string` + - `cards: IndexedCard[]` + +### Card Index + +- `IndexedCard` + - `cardId: string` + - `noteId?: number` + - `filePath: string` + - `cardType: "basic" | "cloze"` + - `heading: string` + - `rawBlockHash: string` + - `markerLine?: number` + +### Planner + +- `SyncPlan` + - `toCreate: IndexedCard[]` + - `toUpdate: IndexedCard[]` + - `toOrphan: string[]` + - `toRewriteMarker: IndexedCard[]` + +### Local State + +- `PluginState` + - `files: Record` + - `cards: Record` + - `pendingWriteBack: PendingWriteBackState[]` + +## Test Plan + +### 1. Marker / Identity + +- 缺失 marker 的卡片块会生成 `cardId` +- 合法 `AHS` marker 能正确解析 `cardId` 与 `noteId` +- 非法 marker 报错 +- 同一块多个 marker 报错 +- marker 不进入正文 + +### 2. File Index + +- include/exclude 过滤先于文件读取 +- 文件未变化时整文件跳过 +- 文件变化但卡片块未变化时,卡片跳过 +- 标题块边界提取正确 + +### 3. Planner + +- 无 `noteId` 的 `cardId` 卡进入 `toCreate` +- `rawBlockHash` 变化进入 `toUpdate` +- `renderConfigHash` 变化进入 `toUpdate` +- 已消失卡进入 `toOrphan` +- pending write-back 卡进入 `toRewriteMarker` 或 `toUpdate` + +### 4. Renderer + +- 只渲染 `toCreate/toUpdate` +- cloze 渲染正确 +- media manifest 正确 +- wikilink / backlink 正确 + +### 5. Anki Batch Executor + +- `notesInfo` 对全部待更新 noteId 批量请求 +- `updateNoteFields` 按 batch 执行 +- `changeDeck` 按目标 deck 分组 +- media 上传去重 +- 失败时能返回批次级错误信息 + +### 6. Markdown Write Back + +- 同一文件多个 marker 一次写回 +- 写回顺序自底向上 +- 文件变化时触发冲突,不覆盖 +- 冲突后生成 pending write-back + +### 7. Commands + +- `同步当前文件` 只影响当前文件 +- `同步全库` 会跳过未变化文件 +- `重建卡片索引` 不默认触发全量远端更新 + +## Risks + +- `cardId` 设计一旦上线不宜再频繁变更 +- 文件级和卡片级缓存同时存在,状态结构会比当前插件复杂 +- Anki 批处理若 batch 过大,可能触发单次请求过重的问题 +- Markdown 块尾写回必须非常稳定,否则会直接影响用户信任 + +## Decisions Locked For Module 3 + +- 新插件默认只支持手动同步 +- 不做旧插件迁移 +- 不继续使用 legacy `cardKey` +- 正式身份采用 `cardId + noteId` +- 块尾 `AHS` marker 是唯一支持的机器标记形式 +- 全库同步必须实现文件级和卡片级跳过 +- Anki 执行层必须按类型批处理 diff --git a/docs/implementation-decisions.md b/docs/implementation-decisions.md new file mode 100644 index 0000000..583f5d6 --- /dev/null +++ b/docs/implementation-decisions.md @@ -0,0 +1,79 @@ +# 模块 3 实现决策 + +本文锁定模块 3 的具体实现决策,范围以 `docs/2026-04-18PLAN4.md` 为准。 + +## 1. 主链路替换策略 + +- 模块 3 采用新的索引/状态/规划/执行主链路。 +- 现有模块 1/2 代码保留在仓库中,但插件命令入口改接模块 3 use case。 +- 不为旧 `legacy cardKey` / `embeddedNoteId` 做兼容适配。 + +## 2. marker 与身份 + +- 统一 marker 格式:`` +- 允许仅写 `cardId`:`` +- `cardId` 采用 `ahs_` 前缀加随机唯一片段生成 +- 缺失 marker 的块在用户执行命令时生成 `cardId`,平时不自动回写 + +## 3. 文件级跳过实现 + +- 为满足“先过滤路径,再读文件内容”,新增文件元数据扫描接口 +- 文件状态除计划要求的 `fileHash` 外,额外保存内部 `fileStamp` +- `fileStamp` 采用 `mtime:size`,只用于决定是否需要重新读取文件 +- `fileHash` 在真正读取文件内容后计算并持久化 + +## 4. pending write-back 恢复策略 + +- 计划要求 pending write-back 可恢复,但 marker 缺失时无法仅靠 marker 重新定位 +- 因此增加一个严格受限的内部恢复规则:仅允许在“同一文件 + 相同 `rawBlockHash`”时复用既有 `cardId/noteId` +- 这不是通用无 marker 身份推断,只用于本地已知 pending 或已存在状态的恢复 + +## 5. 渲染策略 + +- 扫描阶段只建立 `IndexedFile/IndexedCard` +- 仅 `toCreate / toUpdate` 进入 renderer +- `renderConfigHash` 独立计算,不与 `rawBlockHash` 混用 +- 现有 markdown 渲染逻辑保留并抽到新的模块 3 渲染服务里复用 + +## 6. Anki 批处理策略 + +- 新增 `BatchScheduler` +- `notesInfo / addNotes / updateNoteFields / changeDeck / storeMedia` 按批执行 +- 默认使用保守常量: + - `notesInfo` batch size 100 + - `addNotes / updateNoteFields` batch size 50 + - `storeMedia` batch size 10,并发 3 +- 不为当前模块额外开放用户设置项,先保持范围收敛 + +## 7. 本地状态结构 + +- 新状态统一存入 `pluginState` +- 结构包含: + - `files` + - `cards` + - `pendingWriteBack` +- 当前实现不做旧 `syncRegistry` 自动迁移;旧数据保留但模块 3 不读取 + +## 8. rebuild index + +- rebuild index 不调用 Anki 字段更新 +- 它会: + - 全量重建文件状态与卡片状态 + - 为缺失 `cardId` 的块补齐 marker + - 尝试用本地状态恢复已知 `noteId` + +## 9. 命令与提示 + +- 命令名称按计划使用中文: + - `同步当前文件到 Anki` + - `同步全库到 Anki` + - `重建卡片索引` +- Notice 展示:扫描文件数、扫描卡片数、新增、更新、orphan、media、跳过未变化卡数 +- 若有 markdown 回写冲突,明确列出失败文件 + +## 10. 范围控制 + +- 不实现旧插件迁移 +- 不实现双向同步 +- 不实现实时监听自动同步 +- 不为 tags 引入额外语法;当前阶段 `tagsHint` 为空数组,但批处理执行层保留接口位置 \ No newline at end of file diff --git a/docs/implementation-gap-report.md b/docs/implementation-gap-report.md new file mode 100644 index 0000000..6b8d2d2 --- /dev/null +++ b/docs/implementation-gap-report.md @@ -0,0 +1,144 @@ +# 模块 3 实现差距报告 + +本文以 `docs/2026-04-18PLAN4.md` 为唯一实现基准,对当前仓库实现做差距审计。 + +## 结论 + +当前仓库主体仍停留在模块 1/2 的架构: + +- 身份模型仍以 `cardKey` 和 `embeddedNoteId` 为核心,而不是 `cardId + noteId` +- 扫描阶段会对全部卡片立即 render,而不是先索引、后规划、再延迟渲染 +- 本地状态仍是 `syncRegistry` 单层结构,缺失文件层状态和独立 pending write-back 层 +- Anki 执行除了 `notesInfo` 外基本仍是逐卡顺序调用 +- 命令层缺少 `重建卡片索引` + +模块 3 不能在当前 `legacy-card-key` 体系上继续增量补丁,必须引入新的索引、状态、规划和执行主链路。 + +## 主要差距 + +### 1. 身份与 marker + +当前实现: + +- `CardIdentityPolicy` 生成 `cardKey` +- `HeadingSyncMarkerService` 只写 `` +- `CardExtractionService` 只解析旧 marker 里的 `noteId` + +与计划冲突: + +- 缺失永久 `cardId` +- marker 格式错误,未写入 `cardId` +- 仍保留 `embeddedNoteId` / `identityMode` 这类模块 2 兼容逻辑 + +### 2. 文件发现与索引 + +当前实现: + +- `ScanAndPlanSyncUseCase` 先取所有 markdown 内容,再过滤,再提取,再 render + +与计划冲突: + +- 没有路径先过滤再读文件的流程 +- 没有文件级跳过 +- 没有文件层 `fileHash/lastIndexedAt/cardIds[]` +- 没有卡片级 `rawBlockHash` + +### 3. 本地状态 + +当前实现: + +- 仅有 `SyncRegistry` +- 仓库存储只有 `syncRegistry.records` + +与计划冲突: + +- 缺失 `files` +- 缺失新的 `cards` +- 缺失独立 `pendingWriteBack` +- 缺失 `renderConfigHash` + +### 4. Planner + +当前实现: + +- `SyncPlanningService` 只产出 `toAdd / toUpdate / toMarkOrphan` + +与计划冲突: + +- 缺失 `toRewriteMarker` +- `toUpdate` 依据的是旧 `contentHash`,不是 `rawBlockHash + renderConfigHash` +- 仍包含 `embeddedNoteId` 的特殊逻辑 + +### 5. Renderer + +当前实现: + +- 所有卡片都在扫描阶段立即 render + +与计划冲突: + +- 未实现“只 render `toCreate / toUpdate`” +- 缺失 `renderConfigHash` +- 渲染输出与原始块变化耦合在一个 `contentHash` 中 + +### 6. Anki 执行层 + +当前实现: + +- `notesInfo` 已批量化 +- `addNote / updateNote / storeMedia` 仍主要是逐卡/逐资源调用 + +与计划冲突: + +- 缺少批处理调度器 +- 缺少按操作类型分批执行 +- 缺少 deck 分组 `changeDeck` +- 缺少明确的媒体并发控制 + +### 7. Markdown 回写 + +当前实现: + +- 已有“同文件一次写回、自底向上写回”的局部能力 + +与计划冲突: + +- marker 仍是旧格式 +- pending write-back 仍挂在旧 `identityMode` +- 结果汇总里缺少回写冲突文件明细 + +### 8. 命令与 UX + +当前实现: + +- 只有“同步当前文件”和“同步全库”命令 +- Notice 仅展示少量计数 + +与计划冲突: + +- 缺少“重建卡片索引” +- 结果 notice 缺少扫描文件数、扫描卡片数、跳过数、回写冲突文件明细 + +### 9. 测试 + +当前实现: + +- 模块 1/2 的 extraction/planning/write-back 测试较多 + +与计划冲突: + +- 缺少 `cardId` 生成与 marker 新格式测试 +- 缺少文件级跳过测试 +- 缺少 `renderConfigHash` 驱动 update 测试 +- 缺少 rebuild index 测试 +- 缺少批处理调度与结果汇总测试 + +## 修复方向 + +模块 3 将按以下方向落地: + +1. 新建 `cardId + noteId` 为核心的 marker / index / state / planner / executor 主链路 +2. 保留现有 markdown 渲染逻辑中可复用的部分,但改为延迟渲染 +3. 新增文件层状态与 pending write-back 层 +4. 新增 rebuild index 命令与 use case +5. 用新的同步 use case 接管插件命令入口 \ No newline at end of file diff --git a/src/application/ports/AnkiGateway.ts b/src/application/ports/AnkiGateway.ts index e22ad1c..961106f 100644 --- a/src/application/ports/AnkiGateway.ts +++ b/src/application/ports/AnkiGateway.ts @@ -17,14 +17,25 @@ export interface UpdateAnkiNoteInput { export interface AnkiNoteSummary { noteId: number; modelName: string; + cardIds: number[]; +} + +export interface ChangeDeckInput { + deckName: string; + cardIds: number[]; } export interface AnkiGateway { ensureDeckExists(deckName: string): Promise; + ensureDecks(deckNames: string[]): Promise; listNoteModels(): Promise; getModelDetails(modelName: string): Promise; getNoteSummaries(noteIds: number[]): Promise; addNote(input: AddAnkiNoteInput): Promise; + addNotes(inputs: AddAnkiNoteInput[]): Promise; updateNote(input: UpdateAnkiNoteInput): Promise; + updateNotes(inputs: UpdateAnkiNoteInput[]): Promise; + changeDecks(inputs: ChangeDeckInput[]): Promise; storeMedia(asset: MediaAsset): Promise; + storeMediaFiles(assets: MediaAsset[]): Promise; } \ No newline at end of file diff --git a/src/application/ports/ManualSyncVaultGateway.ts b/src/application/ports/ManualSyncVaultGateway.ts new file mode 100644 index 0000000..b09b9a5 --- /dev/null +++ b/src/application/ports/ManualSyncVaultGateway.ts @@ -0,0 +1,15 @@ +import type { SourceFile } from "@/domain/card/entities/SourceFile"; +import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver"; + +export interface MarkdownFileReference { + path: string; + basename: string; + mtime: number; + size: number; +} + +export interface ManualSyncVaultGateway extends RenderResourceResolver { + listMarkdownFileRefs(): Promise; + readMarkdownFile(path: string): Promise; + replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise; +} \ No newline at end of file diff --git a/src/application/ports/PluginStateRepository.ts b/src/application/ports/PluginStateRepository.ts new file mode 100644 index 0000000..1c28ebc --- /dev/null +++ b/src/application/ports/PluginStateRepository.ts @@ -0,0 +1,6 @@ +import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; + +export interface PluginStateRepository { + load(): Promise; + save(state: PluginState): Promise; +} \ No newline at end of file diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts new file mode 100644 index 0000000..a34fee8 --- /dev/null +++ b/src/application/services/AnkiBatchExecutor.ts @@ -0,0 +1,210 @@ +import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +import type { AnkiGateway } from "@/application/ports/AnkiGateway"; +import { BatchScheduler } from "@/application/services/BatchScheduler"; +import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; +import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; +import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; +import type { PlannedCard, ManualSyncPlan } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; + +export interface AnkiBatchExecutionResult { + created: number; + updated: number; + uploadedMedia: number; + markerWrites: PlannedCard[]; + resolvedNoteIds: Map; + touchedCardIds: Set; +} + +export class AnkiBatchExecutor { + constructor( + private readonly ankiGateway: AnkiGateway, + private readonly noteFieldMappingService = new NoteFieldMappingService(), + private readonly batchScheduler = new BatchScheduler(), + ) {} + + async execute( + plan: ManualSyncPlan, + renderedCards: Map, + renderOnDemand: (plannedCard: PlannedCard) => Promise, + noteFieldMappings: Record, + ): Promise { + const resolvedNoteIds = new Map(); + const touchedCardIds = new Set(); + const markerWriteMap = new Map(); + let created = 0; + let updated = 0; + + const summaryIds = Array.from(new Set([...plan.toUpdate, ...plan.toRewriteMarker].flatMap((plannedCard) => plannedCard.noteId ? [plannedCard.noteId] : []))); + const noteSummaries = await this.batchScheduler.runCollectBatches(summaryIds, 100, 1, (batch) => this.ankiGateway.getNoteSummaries(batch)); + const noteSummariesById = new Map(noteSummaries.map((summary) => [summary.noteId, summary])); + + const addQueue: RenderedSyncCard[] = []; + const updateQueue: Array<{ plannedCard: PlannedCard; renderedCard: RenderedSyncCard }> = []; + + for (const plannedCard of plan.toCreate) { + addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); + } + + for (const plannedCard of plan.toUpdate) { + if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) { + updateQueue.push({ plannedCard, renderedCard: await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand) }); + continue; + } + + addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); + markerWriteMap.set(plannedCard.card.cardId, plannedCard); + } + + for (const plannedCard of plan.toRewriteMarker) { + if (markerWriteMap.has(plannedCard.card.cardId)) { + continue; + } + + if (plannedCard.noteId && noteSummariesById.has(plannedCard.noteId)) { + markerWriteMap.set(plannedCard.card.cardId, plannedCard); + resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId); + continue; + } + + addQueue.push(await this.requireRenderedCard(plannedCard, renderedCards, renderOnDemand)); + markerWriteMap.set(plannedCard.card.cardId, plannedCard); + } + + await this.batchScheduler.runVoidBatches( + Array.from(new Set([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)].map((card) => card.deck))), + 50, + 1, + (batch) => this.ankiGateway.ensureDecks(batch), + ); + + const uploadedMedia = await this.uploadMedia([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)]); + + const addedNoteIds = await this.batchScheduler.runCollectBatches(addQueue, 50, 1, async (batch) => { + return this.ankiGateway.addNotes( + await Promise.all(batch.map(async (renderedCard) => ({ + deckName: renderedCard.deck, + modelName: renderedCard.noteModel, + fields: await this.mapFields(renderedCard, noteFieldMappings), + tags: renderedCard.card.tagsHint, + }))), + ); + }); + + for (let index = 0; index < addQueue.length; index += 1) { + const renderedCard = addQueue[index]; + const noteId = addedNoteIds[index]; + created += 1; + touchedCardIds.add(renderedCard.card.cardId); + resolvedNoteIds.set(renderedCard.card.cardId, noteId); + + const existingMarkerWrite = markerWriteMap.get(renderedCard.card.cardId); + markerWriteMap.set(renderedCard.card.cardId, { + ...(existingMarkerWrite ?? { + card: renderedCard.card, + deck: renderedCard.deck, + noteModel: renderedCard.noteModel, + renderConfigHash: renderedCard.renderConfigHash, + }), + noteId, + }); + } + + await this.batchScheduler.runVoidBatches(updateQueue, 50, 1, async (batch) => { + await this.ankiGateway.updateNotes( + await Promise.all(batch.map(async ({ plannedCard, renderedCard }) => ({ + noteId: plannedCard.noteId ?? 0, + deckName: renderedCard.deck, + fields: await this.mapFields(renderedCard, noteFieldMappings), + }))), + ); + }); + + updated += updateQueue.length; + for (const { plannedCard } of updateQueue) { + if (plannedCard.noteId) { + touchedCardIds.add(plannedCard.card.cardId); + resolvedNoteIds.set(plannedCard.card.cardId, plannedCard.noteId); + } + } + + const deckChanges = new Map(); + for (const { plannedCard } of updateQueue) { + const noteId = plannedCard.noteId; + if (!noteId) { + continue; + } + + const summary = noteSummariesById.get(noteId); + if (!summary || summary.cardIds.length === 0) { + continue; + } + + const cardIds = deckChanges.get(plannedCard.deck) ?? []; + cardIds.push(...summary.cardIds); + deckChanges.set(plannedCard.deck, cardIds); + } + + await this.batchScheduler.runVoidBatches( + Array.from(deckChanges.entries()).map(([deckName, cardIds]) => ({ deckName, cardIds })), + 25, + 1, + (batch) => this.ankiGateway.changeDecks(batch), + ); + + return { + created, + updated, + uploadedMedia, + markerWrites: Array.from(markerWriteMap.values()).map((plannedCard) => ({ + ...plannedCard, + noteId: resolvedNoteIds.get(plannedCard.card.cardId) ?? plannedCard.noteId, + })), + resolvedNoteIds, + touchedCardIds, + }; + } + + private async requireRenderedCard( + plannedCard: PlannedCard, + renderedCards: Map, + renderOnDemand: (plannedCard: PlannedCard) => Promise, + ): Promise { + const existing = renderedCards.get(plannedCard.card.cardId); + if (existing) { + return existing; + } + + const rendered = await renderOnDemand(plannedCard); + renderedCards.set(plannedCard.card.cardId, rendered); + return rendered; + } + + private async mapFields( + renderedCard: RenderedSyncCard, + noteFieldMappings: Record, + ): Promise> { + const modelDetails = await this.ankiGateway.getModelDetails(renderedCard.noteModel); + return this.noteFieldMappingService.mapRenderedCard( + { + type: renderedCard.card.cardType, + noteModel: renderedCard.noteModel, + renderedFields: renderedCard.renderedFields, + }, + modelDetails, + noteFieldMappings, + ); + } + + private async uploadMedia(renderedCards: RenderedSyncCard[]): Promise { + const uniqueMedia = new Map(); + + for (const renderedCard of renderedCards) { + for (const asset of renderedCard.media) { + uniqueMedia.set(`${asset.kind}:${asset.absolutePath}:${asset.fileName}`, asset); + } + } + + await this.batchScheduler.runVoidBatches(Array.from(uniqueMedia.values()), 10, 3, (batch) => this.ankiGateway.storeMediaFiles(batch)); + return uniqueMedia.size; + } +} \ No newline at end of file diff --git a/src/application/services/BatchScheduler.ts b/src/application/services/BatchScheduler.ts new file mode 100644 index 0000000..a54d979 --- /dev/null +++ b/src/application/services/BatchScheduler.ts @@ -0,0 +1,50 @@ +export class BatchScheduler { + async runCollectBatches( + items: TInput[], + batchSize: number, + concurrency: number, + worker: (batch: TInput[]) => Promise, + ): Promise { + if (items.length === 0) { + return []; + } + + const batches = chunk(items, batchSize); + const results: TResult[][] = new Array(batches.length); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: Math.min(concurrency, batches.length) }, async () => { + while (nextIndex < batches.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(batches[currentIndex]); + } + }), + ); + + return results.flat(); + } + + async runVoidBatches( + items: TInput[], + batchSize: number, + concurrency: number, + worker: (batch: TInput[]) => Promise, + ): Promise { + await this.runCollectBatches(items, batchSize, concurrency, async (batch) => { + await worker(batch); + return []; + }); + } +} + +function chunk(items: T[], batchSize: number): T[][] { + const batches: T[][] = []; + + for (let index = 0; index < items.length; index += batchSize) { + batches.push(items.slice(index, index + batchSize)); + } + + return batches; +} \ No newline at end of file diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts new file mode 100644 index 0000000..296f353 --- /dev/null +++ b/src/application/services/FileIndexerService.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; +import { createModule3Settings, FakeManualSyncVaultGateway } from "@/test-support/manualSyncFakes"; + +import { FileIndexerService } from "./FileIndexerService"; + +describe("FileIndexerService", () => { + it("filters by path before reading file content", async () => { + const vaultGateway = new FakeManualSyncVaultGateway({ + "keep/one.md": ["#### One", "Body"].join("\n"), + "skip/two.md": ["#### Two", "Body"].join("\n"), + }); + const service = new FileIndexerService(vaultGateway); + + await service.indexVault( + createModule3Settings({ + includeFolders: ["keep"], + }), + createEmptyPluginState(), + ); + + expect(vaultGateway.readCalls).toEqual(["keep/one.md"]); + }); + + it("skips unchanged files by file stamp and reuses stored card state", async () => { + const vaultGateway = new FakeManualSyncVaultGateway({ + "notes/one.md": ["#### One", "Body"].join("\n"), + }); + const service = new FileIndexerService(vaultGateway); + const state = { + files: { + "notes/one.md": { + filePath: "notes/one.md", + fileHash: "hash-a", + fileStamp: `1:${["#### One", "Body"].join("\n").length}`, + lastIndexedAt: 1, + cardIds: ["ahs_1"], + }, + }, + cards: { + ahs_1: { + cardId: "ahs_1", + noteId: 10, + filePath: "notes/one.md", + heading: "One", + headingLevel: 4, + bodyMarkdown: "Body", + cardType: "basic" as const, + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText: ["#### One", "Body"].join("\n"), + rawBlockHash: "hash-card", + renderConfigHash: "render-hash", + deck: "Obsidian", + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [], + }; + + const result = await service.indexVault(createModule3Settings(), state); + + expect(result.skippedUnchangedFiles).toBe(1); + expect(result.skippedUnchangedCards).toBe(1); + expect(vaultGateway.readCalls).toEqual([]); + expect(result.cards[0]).toMatchObject({ cardId: "ahs_1", noteId: 10 }); + }); +}); \ No newline at end of file diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts new file mode 100644 index 0000000..d338d06 --- /dev/null +++ b/src/application/services/FileIndexerService.ts @@ -0,0 +1,155 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; +import { ScanScopeService } from "@/application/services/ScanScopeService"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; +import type { CardState, PluginState } from "@/domain/manual-sync/entities/PluginState"; +import { CardIndexingService } from "@/domain/manual-sync/services/CardIndexingService"; + +export interface FileIndexerResult { + scopedFilePaths: string[]; + scannedFiles: number; + indexedFiles: IndexedFile[]; + cards: IndexedCard[]; + skippedUnchangedFiles: number; + skippedUnchangedCards: number; +} + +export class FileIndexerService { + constructor( + private readonly vaultGateway: ManualSyncVaultGateway, + private readonly scanScopeService = new ScanScopeService(), + private readonly cardIndexingService = new CardIndexingService(), + ) {} + + async indexVault(settings: PluginSettings, state: PluginState, forceReadAll = false): Promise { + const refs = this.scanScopeService.filter(await this.vaultGateway.listMarkdownFileRefs(), settings.includeFolders, settings.excludeFolders); + return this.indexRefs(refs, settings, state, forceReadAll); + } + + async indexFile(filePath: string, settings: PluginSettings, state: PluginState): Promise { + const refs = await this.vaultGateway.listMarkdownFileRefs(); + const reference = refs.find((entry) => entry.path === filePath); + const sourceFile = await this.vaultGateway.readMarkdownFile(filePath); + + if (!sourceFile) { + throw new Error(`Markdown file not found: ${filePath}`); + } + + const fileStamp = reference ? createFileStamp(reference.mtime, reference.size) : `${Date.now()}:${sourceFile.content.length}`; + const indexedFile = this.cardIndexingService.index(sourceFile, { + qaHeadingLevel: settings.qaHeadingLevel, + clozeHeadingLevel: settings.clozeHeadingLevel, + fileStamp, + knownCards: Object.values(state.cards).filter((card) => card.filePath === filePath), + pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath === filePath), + }); + + return { + scopedFilePaths: [filePath], + scannedFiles: 1, + indexedFiles: [indexedFile], + cards: indexedFile.cards, + skippedUnchangedFiles: 0, + skippedUnchangedCards: 0, + }; + } + + private async indexRefs( + refs: Awaited>, + settings: PluginSettings, + state: PluginState, + forceReadAll: boolean, + ): Promise { + const indexedFiles: IndexedFile[] = []; + const cards: IndexedCard[] = []; + let skippedUnchangedFiles = 0; + let skippedUnchangedCards = 0; + + for (const ref of refs) { + const fileStamp = createFileStamp(ref.mtime, ref.size); + const existingFileState = state.files[ref.path]; + const hasPendingWriteBack = state.pendingWriteBack.some((pending) => pending.filePath === ref.path); + const knownCards = Object.values(state.cards).filter((card) => card.filePath === ref.path); + const hasMissingKnownCard = existingFileState?.cardIds.some((cardId) => !state.cards[cardId]) ?? false; + const shouldRead = + forceReadAll || + hasPendingWriteBack || + !existingFileState || + existingFileState.fileStamp !== fileStamp || + hasMissingKnownCard; + + if (!shouldRead) { + skippedUnchangedFiles += 1; + skippedUnchangedCards += existingFileState?.cardIds.length ?? 0; + const restoredCards = (existingFileState?.cardIds ?? []) + .map((cardId) => state.cards[cardId]) + .filter((card): card is CardState => Boolean(card)) + .map((card) => restoreIndexedCard(card)); + + indexedFiles.push({ + filePath: ref.path, + fileHash: existingFileState?.fileHash ?? "", + fileStamp, + cards: restoredCards, + }); + cards.push(...restoredCards); + continue; + } + + const sourceFile = await this.vaultGateway.readMarkdownFile(ref.path); + if (!sourceFile) { + continue; + } + + const indexedFile = this.cardIndexingService.index(sourceFile, { + qaHeadingLevel: settings.qaHeadingLevel, + clozeHeadingLevel: settings.clozeHeadingLevel, + fileStamp, + knownCards, + pendingWriteBack: state.pendingWriteBack.filter((pending) => pending.filePath === ref.path), + }); + + indexedFiles.push(indexedFile); + cards.push(...indexedFile.cards); + } + + return { + scopedFilePaths: refs.map((ref) => ref.path), + scannedFiles: refs.length, + indexedFiles, + cards, + skippedUnchangedFiles, + skippedUnchangedCards, + }; + } +} + +function restoreIndexedCard(card: CardState): IndexedCard { + return { + cardId: card.cardId, + noteId: card.noteId, + markerNoteId: card.noteId, + filePath: card.filePath, + cardType: card.cardType, + heading: card.heading, + headingLevel: card.headingLevel, + bodyMarkdown: card.bodyMarkdown, + blockStartOffset: card.blockStartOffset, + blockEndOffset: card.blockEndOffset, + blockStartLine: card.blockStartLine, + bodyStartLine: card.bodyStartLine, + blockEndLine: card.blockEndLine, + contentEndLine: card.contentEndLine, + markerLine: card.markerLine, + rawBlockText: card.rawBlockText, + rawBlockHash: card.rawBlockHash, + deckHint: card.deckHint, + tagsHint: card.tagsHint, + markerState: card.noteId ? "card-and-note" : "card-only", + }; +} + +export function createFileStamp(mtime: number, size: number): string { + return `${mtime}:${size}`; +} \ No newline at end of file diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts new file mode 100644 index 0000000..e1d50fd --- /dev/null +++ b/src/application/services/ManualSyncService.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { ManualSyncService } from "./ManualSyncService"; +import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes"; + +describe("ManualSyncService", () => { + it("syncs one file, creates Anki notes, writes the new marker format, and saves plugin state", async () => { + const vaultGateway = new FakeManualSyncVaultGateway({ + "notes/example.md": ["#### Prompt", "Answer"].join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); + + const result = await service.syncFile("notes/example.md", createModule3Settings()); + + expect(result.created).toBe(1); + expect(result.updated).toBe(0); + expect(vaultGateway.getFileContent("notes/example.md")).toContain("", + rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")), + }, + ], + }, + ); + + expect(indexedFile.cards[0]).toMatchObject({ + cardId: "ahs_known", + noteId: 42, + }); + }); + + it("rejects misplaced or multiple markers inside a single heading block", () => { + const service = new CardIndexingService(); + + expect(() => + service.index( + { + path: "notes/example.md", + basename: "example", + content: ["#### Prompt", "", "Answer", ""].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + fileStamp: "1:1", + knownCards: [], + pendingWriteBack: [], + }, + ), + ).toThrow("Multiple AHS markers"); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts new file mode 100644 index 0000000..4bdd174 --- /dev/null +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -0,0 +1,354 @@ +import type { SourceFile } from "@/domain/card/entities/SourceFile"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { IndexedFile } from "@/domain/manual-sync/entities/IndexedFile"; +import type { CardState, PendingWriteBackState } from "@/domain/manual-sync/entities/PluginState"; +import { hashString } from "@/domain/shared/hash"; + +import { CardMarkerError, CardMarkerService } from "./CardMarkerService"; + +interface HeadingMatch { + level: number; + text: string; + lineIndex: number; +} + +interface MarkerExtractionResult { + bodyLines: string[]; + markerCardId?: string; + markerNoteId?: number; + contentEndLine: number; + markerLine?: number; + markerState: IndexedCard["markerState"]; +} + +export interface CardIndexingContext { + qaHeadingLevel: number; + clozeHeadingLevel: number; + fileStamp: string; + knownCards: CardState[]; + pendingWriteBack: PendingWriteBackState[]; +} + +const HEADING_REGEXP = /^(#{1,6})\s+(.*?)\s*$/; +const TARGET_DECK_REGEXP = /^\s*TARGET DECK\s*:\s*(.+?)\s*$/i; + +export class CardIndexingService { + constructor(private readonly markerService = new CardMarkerService()) {} + + index(sourceFile: SourceFile, context: CardIndexingContext): IndexedFile { + validateHeadingPolicy(context.qaHeadingLevel, context.clozeHeadingLevel); + + const lines = sourceFile.content.split(/\r?\n/); + const lineStartOffsets = computeLineStartOffsets(sourceFile.content); + const headings = collectHeadings(lines); + const targetDeck = extractTargetDeck(lines); + const cards: IndexedCard[] = []; + const knownCardsById = new Map(context.knownCards.map((card) => [card.cardId, card])); + const pendingByCardId = new Map(context.pendingWriteBack.map((pending) => [pending.cardId, pending])); + const reusableKnownCards = context.knownCards.filter((card) => !card.orphan); + const usedCardIds = new Set(); + + for (let headingIndex = 0; headingIndex < headings.length; headingIndex += 1) { + const heading = headings[headingIndex]; + const cardType = resolveCardType(heading.level, context.qaHeadingLevel, context.clozeHeadingLevel); + if (!cardType) { + continue; + } + + const blockEndLineIndex = findBlockEndLineIndex(headings, headingIndex, lines.length); + const bodyLines = lines.slice(heading.lineIndex + 1, blockEndLineIndex); + const marker = extractMarker(bodyLines, heading.lineIndex + 2, heading.lineIndex + 1, this.markerService); + const trimmedBodyLines = trimBlankEdges(marker.bodyLines); + const bodyMarkdown = trimmedBodyLines.join("\n"); + const rawBlockText = [lines[heading.lineIndex], ...trimmedBodyLines].join("\n").trimEnd(); + const rawBlockHash = hashString(rawBlockText); + const resolvedIdentity = this.resolveIdentity( + sourceFile.path, + rawBlockHash, + marker.markerCardId, + marker.markerNoteId, + knownCardsById, + pendingByCardId, + reusableKnownCards, + usedCardIds, + ); + + usedCardIds.add(resolvedIdentity.cardId); + + cards.push({ + cardId: resolvedIdentity.cardId, + noteId: resolvedIdentity.noteId, + markerNoteId: marker.markerNoteId, + filePath: sourceFile.path, + cardType, + heading: heading.text, + headingLevel: heading.level, + bodyMarkdown, + blockStartOffset: lineStartOffsets[heading.lineIndex] ?? 0, + blockEndOffset: blockEndLineIndex < lines.length ? (lineStartOffsets[blockEndLineIndex] ?? sourceFile.content.length) : sourceFile.content.length, + blockStartLine: heading.lineIndex + 1, + bodyStartLine: heading.lineIndex + 2, + blockEndLine: blockEndLineIndex, + contentEndLine: marker.contentEndLine, + markerLine: marker.markerLine, + rawBlockText, + rawBlockHash, + deckHint: targetDeck, + tagsHint: [], + markerState: marker.markerState, + sourceContent: sourceFile.content, + }); + } + + return { + filePath: sourceFile.path, + fileHash: hashString(sourceFile.content), + fileStamp: context.fileStamp, + content: sourceFile.content, + cards, + }; + } + + private resolveIdentity( + filePath: string, + rawBlockHash: string, + markerCardId: string | undefined, + markerNoteId: number | undefined, + knownCardsById: Map, + pendingByCardId: Map, + reusableKnownCards: CardState[], + usedCardIds: Set, + ): { cardId: string; noteId?: number } { + if (markerCardId) { + const pending = pendingByCardId.get(markerCardId); + const known = knownCardsById.get(markerCardId); + + return { + cardId: markerCardId, + noteId: pending?.noteId ?? known?.noteId ?? markerNoteId, + }; + } + + const pendingMatch = Array.from(pendingByCardId.values()).find( + (pending) => pending.filePath === filePath && pending.rawBlockHash === rawBlockHash && !usedCardIds.has(pending.cardId), + ); + if (pendingMatch) { + return { + cardId: pendingMatch.cardId, + noteId: pendingMatch.noteId, + }; + } + + const knownMatch = reusableKnownCards.find( + (card) => card.filePath === filePath && card.rawBlockHash === rawBlockHash && !usedCardIds.has(card.cardId), + ); + if (knownMatch) { + return { + cardId: knownMatch.cardId, + noteId: knownMatch.noteId, + }; + } + + return { + cardId: this.markerService.generateCardId(), + }; + } +} + +function validateHeadingPolicy(qaHeadingLevel: number, clozeHeadingLevel: number): void { + if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) { + throw new Error("QA heading level must be an integer between 1 and 6."); + } + + if (!Number.isInteger(clozeHeadingLevel) || clozeHeadingLevel < 1 || clozeHeadingLevel > 6) { + throw new Error("Cloze heading level must be an integer between 1 and 6."); + } + + if (qaHeadingLevel === clozeHeadingLevel) { + throw new Error("QA and Cloze heading levels must be different."); + } +} + +function collectHeadings(lines: string[]): HeadingMatch[] { + const headings: HeadingMatch[] = []; + let fenceMarker: string | null = null; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + fenceMarker = fenceMarker ? null : trimmed.slice(0, 3); + continue; + } + + if (fenceMarker) { + continue; + } + + const match = line.match(HEADING_REGEXP); + if (!match) { + continue; + } + + headings.push({ + level: match[1].length, + text: match[2].replace(/\s+#+\s*$/, "").trim(), + lineIndex, + }); + } + + return headings; +} + +function extractTargetDeck(lines: string[]): string | undefined { + let fenceMarker: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + fenceMarker = fenceMarker ? null : trimmed.slice(0, 3); + continue; + } + + if (fenceMarker) { + continue; + } + + const match = line.match(TARGET_DECK_REGEXP); + if (match) { + return match[1].trim(); + } + } + + return undefined; +} + +function resolveCardType(level: number, qaHeadingLevel: number, clozeHeadingLevel: number): IndexedCard["cardType"] | null { + if (level === qaHeadingLevel) { + return "basic"; + } + + if (level === clozeHeadingLevel) { + return "cloze"; + } + + return null; +} + +function findBlockEndLineIndex(headings: HeadingMatch[], currentHeadingIndex: number, totalLineCount: number): number { + const currentHeading = headings[currentHeadingIndex]; + + for (let headingIndex = currentHeadingIndex + 1; headingIndex < headings.length; headingIndex += 1) { + const nextHeading = headings[headingIndex]; + if (nextHeading.level <= currentHeading.level) { + return nextHeading.lineIndex; + } + } + + return totalLineCount; +} + +function trimBlankEdges(lines: string[]): string[] { + let startIndex = 0; + let endIndex = lines.length; + + while (startIndex < endIndex && !lines[startIndex].trim()) { + startIndex += 1; + } + + while (endIndex > startIndex && !lines[endIndex - 1].trim()) { + endIndex -= 1; + } + + return lines.slice(startIndex, endIndex); +} + +function extractMarker( + bodyLines: string[], + bodyStartLine: number, + headingLine: number, + markerService: CardMarkerService, +): MarkerExtractionResult { + const candidates = bodyLines + .map((line, index) => ({ line, index })) + .filter(({ line }) => markerService.isCandidate(line)); + const validMarkers = candidates + .map(({ line, index }) => markerService.parse(line, bodyStartLine + index)) + .filter((marker): marker is NonNullable => Boolean(marker)); + + if (validMarkers.length > 1) { + throw new CardMarkerError(`Multiple AHS markers found in heading block at line ${headingLine}.`); + } + + let lastNonEmptyIndex = bodyLines.length - 1; + while (lastNonEmptyIndex >= 0 && !bodyLines[lastNonEmptyIndex].trim()) { + lastNonEmptyIndex -= 1; + } + + if (lastNonEmptyIndex < 0) { + if (candidates.length > 0) { + throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`); + } + + return { + bodyLines, + contentEndLine: headingLine, + markerState: "missing", + }; + } + + const lastLine = bodyLines[lastNonEmptyIndex]; + const lastMarker = markerService.parse(lastLine, bodyStartLine + lastNonEmptyIndex); + + if (lastMarker) { + for (const candidate of candidates) { + if (candidate.index !== lastNonEmptyIndex) { + throw new CardMarkerError(`Multiple or misplaced AHS markers found in heading block at line ${headingLine}.`); + } + } + + const nextBodyLines = bodyLines.filter((_line, index) => index !== lastNonEmptyIndex); + return { + bodyLines: nextBodyLines, + markerCardId: lastMarker.cardId, + markerNoteId: lastMarker.noteId, + contentEndLine: findContentEndLine(nextBodyLines, bodyStartLine, headingLine), + markerLine: bodyStartLine + lastNonEmptyIndex, + markerState: lastMarker.noteId ? "card-and-note" : "card-only", + }; + } + + if (candidates.length > 0) { + throw new CardMarkerError(`Invalid AHS marker found in heading block at line ${headingLine}.`); + } + + return { + bodyLines, + contentEndLine: findContentEndLine(bodyLines, bodyStartLine, headingLine), + markerState: "missing", + }; +} + +function findContentEndLine(bodyLines: string[], bodyStartLine: number, headingLine: number): number { + for (let index = bodyLines.length - 1; index >= 0; index -= 1) { + if (bodyLines[index].trim()) { + return bodyStartLine + index; + } + } + + return headingLine; +} + +function computeLineStartOffsets(content: string): number[] { + const offsets = [0]; + + for (let index = 0; index < content.length; index += 1) { + if (content[index] === "\n") { + offsets.push(index + 1); + } + } + + return offsets; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardMarkerService.test.ts b/src/domain/manual-sync/services/CardMarkerService.test.ts new file mode 100644 index 0000000..0d9c06e --- /dev/null +++ b/src/domain/manual-sync/services/CardMarkerService.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { CardMarkerService } from "./CardMarkerService"; + +describe("CardMarkerService", () => { + it("parses cardId and noteId from the module 3 marker format", () => { + const service = new CardMarkerService(() => 1); + + expect(service.parse("", 3)).toEqual({ + cardId: "ahs_123", + noteId: 42, + raw: "", + lineIndex: 3, + }); + }); + + it("writes multiple markers bottom-up in a single file", () => { + const service = new CardMarkerService(() => 1); + const sourceContent = ["#### Top", "Body 1", "", "#### Bottom", "Body 2"].join("\n"); + + const nextContent = service.applyBatch(sourceContent, [ + { + filePath: "notes/example.md", + cardId: "ahs_top", + noteId: 11, + blockStartLine: 1, + contentEndLine: 2, + blockEndLine: 3, + sourceContent, + }, + { + filePath: "notes/example.md", + cardId: "ahs_bottom", + noteId: 22, + blockStartLine: 4, + contentEndLine: 5, + blockEndLine: 5, + sourceContent, + }, + ]); + + expect(nextContent).toBe([ + "#### Top", + "Body 1", + "", + "", + "#### Bottom", + "Body 2", + "", + ].join("\n")); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardMarkerService.ts b/src/domain/manual-sync/services/CardMarkerService.ts new file mode 100644 index 0000000..15274c9 --- /dev/null +++ b/src/domain/manual-sync/services/CardMarkerService.ts @@ -0,0 +1,117 @@ +import { hashString } from "@/domain/shared/hash"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { AhsMarker } from "@/domain/manual-sync/entities/AhsMarker"; + +export interface MarkerWriteRequest { + filePath: string; + cardId: string; + noteId?: number; + blockStartLine: number; + contentEndLine: number; + blockEndLine: number; + markerLine?: number; + sourceContent: string; +} + +export class CardMarkerError extends Error { + constructor(message: string) { + super(message); + this.name = "CardMarkerError"; + } +} + +const AHS_MARKER_CANDIDATE_REGEXP = /\s*$/; + +export class CardMarkerService { + private counter = 0; + + constructor(private readonly now: () => number = () => Date.now()) {} + + isCandidate(line: string): boolean { + return AHS_MARKER_CANDIDATE_REGEXP.test(line); + } + + parse(line: string, lineIndex: number): AhsMarker | null { + const match = line.match(AHS_MARKER_REGEXP); + if (!match) { + return null; + } + + return { + cardId: match[1], + noteId: match[2] ? Number(match[2]) : undefined, + raw: match[0].trim(), + lineIndex, + }; + } + + create(cardId: string, noteId?: number): AhsMarker { + return { + cardId, + noteId, + raw: noteId ? `` : ``, + lineIndex: -1, + }; + } + + createForCard(card: IndexedCard): AhsMarker { + return this.create(card.cardId, card.noteId); + } + + generateCardId(): string { + this.counter += 1; + return `ahs_${hashString(`${this.now()}|${this.counter}|${Math.random()}`).slice(0, 12)}`; + } + + applyBatch(sourceContent: string, writes: MarkerWriteRequest[]): string { + if (writes.length === 0) { + return sourceContent; + } + + const filePath = writes[0]?.filePath; + const seenBlocks = new Set(); + const lineEnding = sourceContent.includes("\r\n") ? "\r\n" : "\n"; + const lines = sourceContent.split(/\r?\n/); + + for (const write of writes) { + if (write.filePath !== filePath) { + throw new CardMarkerError("Batch marker writes must belong to the same Markdown file."); + } + + if (write.sourceContent !== sourceContent) { + throw new CardMarkerError(`Marker writes for ${write.filePath} must share the scanned source content.`); + } + + if (write.markerLine === undefined && write.contentEndLine > write.blockEndLine) { + throw new CardMarkerError(`Cannot insert marker outside the heading block in ${write.filePath}.`); + } + + if (write.markerLine !== undefined && write.markerLine < write.blockStartLine) { + throw new CardMarkerError(`Cannot replace marker outside the heading block in ${write.filePath}.`); + } + + if (seenBlocks.has(write.blockStartLine)) { + throw new CardMarkerError(`Duplicate marker write detected for block ${write.blockStartLine} in ${write.filePath}.`); + } + + seenBlocks.add(write.blockStartLine); + } + + const sortedWrites = [...writes].sort((left, right) => right.blockStartLine - left.blockStartLine); + for (const write of sortedWrites) { + const adjustedBlockEndLine = write.markerLine ? write.blockEndLine - 1 : write.blockEndLine; + if (write.contentEndLine < write.blockStartLine || write.contentEndLine > adjustedBlockEndLine) { + throw new CardMarkerError(`Cannot write marker outside the heading block in ${write.filePath}.`); + } + + if (write.markerLine) { + lines.splice(write.markerLine - 1, 1); + } + + lines.splice(write.contentEndLine, 0, this.create(write.cardId, write.noteId).raw); + } + + return lines.join(lineEnding); + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts new file mode 100644 index 0000000..2a336aa --- /dev/null +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { createModule3Settings } from "@/test-support/manualSyncFakes"; +import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; + +import { DiffPlannerService } from "./DiffPlannerService"; + +describe("DiffPlannerService", () => { + it("places cardId-only cards without noteId into toCreate", () => { + const service = new DiffPlannerService(); + const plan = service.plan( + [ + { + cardId: "ahs_1", + filePath: "notes/example.md", + cardType: "basic", + heading: "Prompt", + headingLevel: 4, + bodyMarkdown: "Body", + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText: ["#### Prompt", "Body"].join("\n"), + rawBlockHash: "hash-card", + tagsHint: [], + markerState: "card-only", + }, + ], + createEmptyPluginState(), + ["notes/example.md"], + createModule3Settings(), + ); + + expect(plan.toCreate).toHaveLength(1); + }); + + it("uses rawBlockHash, renderConfigHash, and pending entries to schedule updates and rewrites", () => { + const service = new DiffPlannerService(); + const state = { + files: {}, + cards: { + ahs_1: { + cardId: "ahs_1", + noteId: 42, + filePath: "notes/example.md", + heading: "Prompt", + headingLevel: 4, + bodyMarkdown: "Body", + cardType: "basic" as const, + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText: ["#### Prompt", "Body"].join("\n"), + rawBlockHash: "old-hash", + renderConfigHash: "old-render", + deck: "Old", + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [ + { + filePath: "notes/example.md", + cardId: "ahs_1", + noteId: 42, + expectedFileHash: "hash", + targetMarker: "", + rawBlockHash: "hash-card", + }, + ], + }; + + const plan = service.plan( + [ + { + cardId: "ahs_1", + noteId: 42, + markerNoteId: 41, + filePath: "notes/example.md", + cardType: "basic", + heading: "Prompt", + headingLevel: 4, + bodyMarkdown: "Body", + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText: ["#### Prompt", "Body"].join("\n"), + rawBlockHash: "hash-card", + tagsHint: [], + markerState: "card-and-note", + }, + ], + state, + ["notes/example.md"], + createModule3Settings(), + ); + + expect(plan.toUpdate).toHaveLength(1); + expect(plan.toRewriteMarker).toHaveLength(1); + }); + + it("marks missing scoped cards as orphan", () => { + const service = new DiffPlannerService(); + const state = { + files: {}, + cards: { + ahs_missing: { + cardId: "ahs_missing", + noteId: 1, + filePath: "notes/example.md", + heading: "Prompt", + headingLevel: 4, + bodyMarkdown: "Body", + cardType: "basic" as const, + blockStartOffset: 0, + blockEndOffset: 16, + blockStartLine: 1, + bodyStartLine: 2, + blockEndLine: 2, + contentEndLine: 2, + rawBlockText: ["#### Prompt", "Body"].join("\n"), + rawBlockHash: "hash-card", + renderConfigHash: "render-hash", + deck: "Obsidian", + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [], + }; + + const plan = service.plan([], state, ["notes/example.md"], createModule3Settings()); + + expect(plan.toOrphan.map((card) => card.cardId)).toEqual(["ahs_missing"]); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts new file mode 100644 index 0000000..80dc6b9 --- /dev/null +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -0,0 +1,81 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; +import { RenderConfigService } from "@/application/services/RenderConfigService"; +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; +import type { ManualSyncPlan, PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; + +export class DiffPlannerService { + constructor(private readonly renderConfigService = new RenderConfigService()) {} + + plan(cards: IndexedCard[], state: PluginState, scopedFilePaths: string[], settings: PluginSettings): ManualSyncPlan { + const cardsById = new Map(); + const pendingByCardId = new Map(state.pendingWriteBack.map((pending) => [pending.cardId, pending])); + const toCreate: PlannedCard[] = []; + const toUpdate: PlannedCard[] = []; + const toRewriteMarker: PlannedCard[] = []; + let unchangedCards = 0; + + for (const card of cards) { + if (cardsById.has(card.cardId)) { + throw new Error(`Duplicate cardId detected in current scan: ${card.cardId}`); + } + + cardsById.set(card.cardId, card); + const existingState = state.cards[card.cardId]; + const renderPlan = this.renderConfigService.resolve(card, settings); + const resolvedNoteId = existingState?.noteId ?? card.noteId; + const plannedCard: PlannedCard = { + card: { + ...card, + noteId: resolvedNoteId, + }, + noteId: resolvedNoteId, + deck: renderPlan.deck, + noteModel: renderPlan.noteModel, + renderConfigHash: renderPlan.renderConfigHash, + }; + + if (!resolvedNoteId) { + toCreate.push(plannedCard); + continue; + } + + if ( + card.markerState === "missing" || + card.markerState === "card-only" || + card.markerNoteId !== resolvedNoteId || + pendingByCardId.has(card.cardId) + ) { + toRewriteMarker.push(plannedCard); + } + + if (!existingState) { + toUpdate.push(plannedCard); + continue; + } + + if ( + existingState.rawBlockHash !== card.rawBlockHash || + existingState.renderConfigHash !== renderPlan.renderConfigHash || + existingState.orphan || + pendingByCardId.has(card.cardId) + ) { + toUpdate.push(plannedCard); + continue; + } + + unchangedCards += 1; + } + + const scopedPaths = new Set(scopedFilePaths); + const toOrphan = Object.values(state.cards).filter((card) => scopedPaths.has(card.filePath) && !cardsById.has(card.cardId)); + + return { + toCreate, + toUpdate, + toRewriteMarker, + toOrphan, + unchangedCards, + }; + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/ManualCardRenderer.ts b/src/domain/manual-sync/services/ManualCardRenderer.ts new file mode 100644 index 0000000..3973217 --- /dev/null +++ b/src/domain/manual-sync/services/ManualCardRenderer.ts @@ -0,0 +1,187 @@ +import MarkdownIt from "markdown-it"; + +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; +import type { PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan"; +import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; +import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; + +const markdown = new MarkdownIt({ + breaks: true, + html: true, + linkify: true, +}); + +const INLINE_CODE_PATTERN = /`[^`\n]+`/g; +const FENCED_CODE_PATTERN = /```[\s\S]*?```|~~~[\s\S]*?~~~/g; +const DISPLAY_MATH_PATTERN = /(?Open in Obsidian

` + : ""; + + return { + card: plannedCard.card, + noteId: plannedCard.noteId, + deck: plannedCard.deck, + noteModel: plannedCard.noteModel, + renderConfigHash: plannedCard.renderConfigHash, + renderedFields: { + title: headingResult.html, + body: bodyResult.html + backlinkHtml, + }, + media: dedupeMedia([...headingResult.media, ...bodyResult.media]), + }; + } + + private renderMarkdown( + markdownText: string, + plannedCard: PlannedCard, + context: ManualCardRenderContext, + cloze: boolean, + inline: boolean, + ): RenderResult { + const protectedBlocks = protectSegments(markdownText, FENCED_CODE_PATTERN, "FENCED_CODE"); + const protectedInline = protectSegments(protectedBlocks.text, INLINE_CODE_PATTERN, "INLINE_CODE"); + let transformed = protectedInline.text; + const media: MediaAsset[] = []; + let nextClozeIndex = 1; + + transformed = transformed.replace(DISPLAY_MATH_PATTERN, (_match, content: string) => `\\[${content.trim()}\\]`); + transformed = transformed.replace(INLINE_MATH_PATTERN, (_match, content: string) => `\\(${content.trim()}\\)`); + + if (cloze && context.convertHighlightsToCloze) { + transformed = transformed.replace(HIGHLIGHT_PATTERN, "{$1}"); + } + + if (cloze) { + transformed = transformed.replace(CLOZE_PATTERN, (_match, explicitIndex: string | undefined, content: string) => { + const clozeIndex = explicitIndex ? Number(explicitIndex) : nextClozeIndex++; + return `{{c${clozeIndex}::${content}}}`; + }); + } + + transformed = transformed.replace(EMBED_PATTERN, (_match, rawTarget: string) => { + const resolvedEmbed = context.resourceResolver.resolveEmbed(rawTarget, plannedCard.card.filePath); + + if (!resolvedEmbed) { + return _match; + } + + media.push({ + kind: resolvedEmbed.kind, + fileName: resolvedEmbed.fileName, + absolutePath: resolvedEmbed.absolutePath, + altText: resolvedEmbed.altText, + }); + + if (resolvedEmbed.kind === "audio") { + return `[sound:${resolvedEmbed.fileName}]`; + } + + return `${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 `${escapeHtml(resolvedLink.displayText)}`; + }); + + transformed = transformed.replace(HIGHLIGHT_PATTERN, "$1"); + const protectedMath = protectSegments(transformed, ANKI_MATH_PATTERN, "MATH"); + transformed = protectedMath.text; + transformed = protectedInline.restore(transformed); + transformed = protectedBlocks.restore(transformed); + + const renderedHtml = (inline ? markdown.renderInline(transformed) : markdown.render(transformed)).trim(); + + return { + html: protectedMath.restore(renderedHtml, escapeHtml), + media, + }; + } +} + +function protectSegments( + text: string, + pattern: RegExp, + prefix: string, +): { text: string; restore: (value: string, formatter?: (segment: string) => string) => string } { + const matches: string[] = []; + const nextText = text.replace(pattern, (segment) => { + const token = `@@${prefix}_${matches.length}@@`; + matches.push(segment); + return token; + }); + + return { + text: nextText, + restore: (value: string, formatter = (segment: string) => segment) => + matches.reduce((current, segment, index) => current.split(`@@${prefix}_${index}@@`).join(formatter(segment)), value), + }; +} + +function dedupeMedia(media: MediaAsset[]): MediaAsset[] { + const seen = new Set(); + + return media.filter((asset) => { + const key = `${asset.kind}:${asset.absolutePath}:${asset.fileName}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} \ No newline at end of file diff --git a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts new file mode 100644 index 0000000..818da87 --- /dev/null +++ b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts @@ -0,0 +1,18 @@ +import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; +import type { CardState } from "@/domain/manual-sync/entities/PluginState"; + +export interface PlannedCard { + card: IndexedCard; + noteId?: number; + deck: string; + noteModel: string; + renderConfigHash: string; +} + +export interface ManualSyncPlan { + toCreate: PlannedCard[]; + toUpdate: PlannedCard[]; + toRewriteMarker: PlannedCard[]; + toOrphan: CardState[]; + unchangedCards: number; +} \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.test.ts b/src/infrastructure/anki/AnkiConnectGateway.test.ts index 50b5e2b..6a246f8 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.test.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.test.ts @@ -76,8 +76,8 @@ describe("AnkiConnectGateway", () => { const summaries = await gateway.getNoteSummaries([100, 101, 102]); expect(summaries).toEqual([ - { noteId: 100, modelName: "Basic" }, - { noteId: 102, modelName: "Cloze" }, + { noteId: 100, modelName: "Basic", cardIds: [1] }, + { noteId: 102, modelName: "Cloze", cardIds: [2] }, ]); expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ action: "notesInfo", @@ -87,4 +87,148 @@ describe("AnkiConnectGateway", () => { }, }); }); + + it("batches add note calls through AnkiConnect multi", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: [9001, 9002], + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + const noteIds = await gateway.addNotes([ + { + deckName: "Deck", + modelName: "Basic", + fields: { Front: "A", Back: "B" }, + tags: [], + }, + { + deckName: "Deck", + modelName: "Basic", + fields: { Front: "C", Back: "D" }, + tags: ["tag"], + }, + ]); + + expect(noteIds).toEqual([9001, 9002]); + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "addNote", + params: { + note: { + deckName: "Deck", + modelName: "Basic", + fields: { Front: "A", Back: "B" }, + options: { + allowDuplicate: false, + duplicateScope: "deck", + }, + tags: [], + }, + }, + }, + { + action: "addNote", + params: { + note: { + deckName: "Deck", + modelName: "Basic", + fields: { Front: "C", Back: "D" }, + options: { + allowDuplicate: false, + duplicateScope: "deck", + }, + tags: ["tag"], + }, + }, + }, + ], + }, + }); + }); + + it("batches update, changeDeck, and media operations through multi", async () => { + requestUrlMock.mockResolvedValue({ + json: { + error: null, + result: [null, null], + }, + }); + + const gateway = new AnkiConnectGateway(() => "http://127.0.0.1:8765"); + await gateway.updateNotes([ + { + noteId: 10, + deckName: "Deck A", + fields: { Front: "Prompt", Back: "Answer" }, + }, + ]); + await gateway.changeDecks([ + { + deckName: "Deck B", + cardIds: [1, 2], + }, + ]); + await gateway.storeMediaFiles([ + { + kind: "image", + fileName: "asset.png", + absolutePath: "/tmp/asset.png", + }, + ]); + + expect(JSON.parse(requestUrlMock.mock.calls[0][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "updateNoteFields", + params: { + note: { + id: 10, + fields: { Front: "Prompt", Back: "Answer" }, + }, + }, + }, + ], + }, + }); + expect(JSON.parse(requestUrlMock.mock.calls[1][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "changeDeck", + params: { + cards: [1, 2], + deck: "Deck B", + }, + }, + ], + }, + }); + expect(JSON.parse(requestUrlMock.mock.calls[2][0].body)).toEqual({ + action: "multi", + version: 6, + params: { + actions: [ + { + action: "storeMediaFile", + params: { + filename: "asset.png", + path: "/tmp/asset.png", + }, + }, + ], + }, + }); + }); }); \ No newline at end of file diff --git a/src/infrastructure/anki/AnkiConnectGateway.ts b/src/infrastructure/anki/AnkiConnectGateway.ts index d585859..41a03f3 100644 --- a/src/infrastructure/anki/AnkiConnectGateway.ts +++ b/src/infrastructure/anki/AnkiConnectGateway.ts @@ -1,7 +1,7 @@ import { requestUrl } from "obsidian"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; interface AnkiResponse { @@ -24,6 +24,14 @@ export class AnkiConnectGateway implements AnkiGateway { await this.invoke("createDeck", { deck: deckName }); } + async ensureDecks(deckNames: string[]): Promise { + const uniqueDeckNames = Array.from(new Set(deckNames)); + await this.invokeMulti(uniqueDeckNames.map((deckName) => ({ + action: "createDeck", + params: { deck: deckName }, + }))); + } + async listNoteModels(): Promise { return this.invoke("modelNames", {}); } @@ -62,6 +70,7 @@ export class AnkiConnectGateway implements AnkiGateway { return [{ noteId: entry.noteId, modelName: entry.modelName, + cardIds: Array.isArray(entry.cards) ? entry.cards : [], }]; }); } @@ -81,6 +90,24 @@ export class AnkiConnectGateway implements AnkiGateway { }); } + async addNotes(inputs: AddAnkiNoteInput[]): Promise { + return this.invokeMulti(inputs.map((input) => ({ + action: "addNote", + params: { + note: { + deckName: input.deckName, + modelName: input.modelName, + fields: input.fields, + options: { + allowDuplicate: false, + duplicateScope: "deck", + }, + tags: input.tags, + }, + }, + }))); + } + async updateNote(input: UpdateAnkiNoteInput): Promise { await this.invoke("updateNoteFields", { note: { @@ -102,6 +129,28 @@ export class AnkiConnectGateway implements AnkiGateway { } } + async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise { + await this.invokeMulti(inputs.map((input) => ({ + action: "updateNoteFields", + params: { + note: { + id: input.noteId, + fields: input.fields, + }, + }, + }))); + } + + async changeDecks(inputs: ChangeDeckInput[]): Promise { + await this.invokeMulti(inputs.filter((input) => input.cardIds.length > 0).map((input) => ({ + action: "changeDeck", + params: { + cards: input.cardIds, + deck: input.deckName, + }, + }))); + } + async storeMedia(asset: MediaAsset): Promise { await this.invoke("storeMediaFile", { filename: asset.fileName, @@ -109,6 +158,16 @@ export class AnkiConnectGateway implements AnkiGateway { }); } + async storeMediaFiles(assets: MediaAsset[]): Promise { + await this.invokeMulti(assets.map((asset) => ({ + action: "storeMediaFile", + params: { + filename: asset.fileName, + path: asset.absolutePath, + }, + }))); + } + private async invoke(action: string, params: Record): Promise { const response = await requestUrl({ url: this.getBaseUrl(), @@ -128,4 +187,12 @@ export class AnkiConnectGateway implements AnkiGateway { return parsed.result; } + + private async invokeMulti(actions: Array<{ action: string; params: Record }>): Promise { + if (actions.length === 0) { + return []; + } + + return this.invoke("multi", { actions }); + } } \ No newline at end of file diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts index df8e723..4df5ae6 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts @@ -1,6 +1,7 @@ import { TFile } from "obsidian"; import type { App } from "obsidian"; +import type { MarkdownFileReference, ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import { MarkdownFileNotFoundError, MarkdownWriteConflictError, type VaultGateway } from "@/application/ports/VaultGateway"; import { hashString } from "@/domain/shared/hash"; import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation"; @@ -8,9 +9,18 @@ import type { SourceLocation } from "@/domain/card/value-objects/SourceLocation" const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "tiff"]); const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "webm", "ogg"]); -export class ObsidianVaultGateway implements VaultGateway { +export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway { constructor(private readonly app: App) {} + async listMarkdownFileRefs(): Promise { + return this.app.vault.getMarkdownFiles().map((file) => ({ + path: file.path, + basename: file.basename, + mtime: file.stat.mtime, + size: file.stat.size, + })); + } + async listMarkdownFiles() { const files = this.app.vault.getMarkdownFiles(); @@ -27,6 +37,10 @@ export class ObsidianVaultGateway implements VaultGateway { return this.toSourceFile(abstractFile); } + async readMarkdownFile(path: string) { + return this.getMarkdownFile(path); + } + async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string) { const abstractFile = this.app.vault.getAbstractFileByPath(path); diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts index 4612adf..0d76195 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.ts @@ -1,9 +1,11 @@ import { DEFAULT_SETTINGS, type PluginSettings, validatePluginSettings } from "@/application/config/PluginSettings"; import type { PluginConfigRepository } from "@/application/ports/PluginConfigRepository"; import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; export interface PluginDataSnapshot { settings?: Partial; + pluginState?: PluginState; syncRegistry?: { records: Array<{ cardKey: string; diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts new file mode 100644 index 0000000..0346fe2 --- /dev/null +++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; + +import { DataJsonPluginStateRepository } from "./DataJsonPluginStateRepository"; +import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; + +class InMemoryPluginDataStore implements PluginDataStore { + constructor(private snapshot: PluginDataSnapshot | null = null) {} + + async load(): Promise { + return this.snapshot; + } + + async save(data: PluginDataSnapshot): Promise { + this.snapshot = data; + } +} + +describe("DataJsonPluginStateRepository", () => { + it("loads an empty plugin state when none has been saved", async () => { + const repository = new DataJsonPluginStateRepository(new InMemoryPluginDataStore()); + + await expect(repository.load()).resolves.toEqual(createEmptyPluginState()); + }); + + it("persists pluginState independently from settings", async () => { + const store = new InMemoryPluginDataStore({ + settings: { + defaultDeck: "Deck", + }, + }); + const repository = new DataJsonPluginStateRepository(store); + + await repository.save({ + files: { + "notes/example.md": { + filePath: "notes/example.md", + fileHash: "hash", + fileStamp: "1:1", + lastIndexedAt: 1, + cardIds: ["ahs_1"], + }, + }, + cards: {}, + pendingWriteBack: [], + }); + + const snapshot = await store.load(); + expect(snapshot?.pluginState?.files["notes/example.md"]?.cardIds).toEqual(["ahs_1"]); + expect(snapshot?.settings?.defaultDeck).toBe("Deck"); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginStateRepository.ts b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts new file mode 100644 index 0000000..b75a21d --- /dev/null +++ b/src/infrastructure/persistence/DataJsonPluginStateRepository.ts @@ -0,0 +1,23 @@ +import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; +import type { PluginDataStore } from "@/application/ports/PluginDataStore"; +import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; + +import type { PluginDataSnapshot } from "./DataJsonPluginConfigRepository"; + +export class DataJsonPluginStateRepository implements PluginStateRepository { + constructor(private readonly pluginDataStore: PluginDataStore) {} + + async load(): Promise { + const snapshot = (await this.pluginDataStore.load()) ?? {}; + return snapshot.pluginState ?? createEmptyPluginState(); + } + + async save(state: PluginState): Promise { + const snapshot = (await this.pluginDataStore.load()) ?? {}; + + await this.pluginDataStore.save({ + ...snapshot, + pluginState: state, + }); + } +} \ No newline at end of file diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index c06082d..2acefc9 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -2,18 +2,18 @@ import { Plugin } from "obsidian"; import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings"; import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; -import { ScanAndPlanSyncUseCase } from "@/application/use-cases/ScanAndPlanSyncUseCase"; -import { ExecuteSyncPlanUseCase } from "@/application/use-cases/ExecuteSyncPlanUseCase"; -import { SyncCurrentFileUseCase } from "@/application/use-cases/SyncCurrentFileUseCase"; -import { SyncVaultUseCase } from "@/application/use-cases/SyncVaultUseCase"; +import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase"; +import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase"; +import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase"; import { AnkiConnectGateway } from "@/infrastructure/anki/AnkiConnectGateway"; import { ObsidianPluginDataStore } from "@/infrastructure/obsidian/ObsidianPluginDataStore"; import { ObsidianVaultGateway } from "@/infrastructure/obsidian/ObsidianVaultGateway"; import { DataJsonPluginConfigRepository, type PluginDataSnapshot } from "@/infrastructure/persistence/DataJsonPluginConfigRepository"; -import { DataJsonSyncRegistryRepository } from "@/infrastructure/persistence/DataJsonSyncRegistryRepository"; +import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/DataJsonPluginStateRepository"; import { registerCommands } from "@/presentation/commands/registerCommands"; import { NoticeService } from "@/presentation/notices/NoticeService"; import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab"; +import { ManualSyncService } from "@/application/services/ManualSyncService"; export default class AnkiHeadingSyncPlugin extends Plugin { settings: PluginSettings = DEFAULT_SETTINGS; @@ -21,14 +21,15 @@ export default class AnkiHeadingSyncPlugin extends Plugin { private readonly noticeService = new NoticeService(); private readonly ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl); - private syncCurrentFileUseCase?: SyncCurrentFileUseCase; - private syncVaultUseCase?: SyncVaultUseCase; + private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase; + private syncVaultUseCase?: ManualSyncVaultUseCase; + private rebuildCardIndexUseCase?: RebuildCardIndexUseCase; private pluginConfigRepository?: DataJsonPluginConfigRepository; async onload(): Promise { const pluginDataStore = new ObsidianPluginDataStore(this); this.pluginConfigRepository = new DataJsonPluginConfigRepository(pluginDataStore); - const syncRegistryRepository = new DataJsonSyncRegistryRepository(pluginDataStore); + const pluginStateRepository = new DataJsonPluginStateRepository(pluginDataStore); const vaultGateway = new ObsidianVaultGateway(this.app); try { @@ -39,10 +40,10 @@ export default class AnkiHeadingSyncPlugin extends Plugin { this.noticeService.error("Invalid plugin settings were detected. Default settings were restored in memory."); } - const scanAndPlanSyncUseCase = new ScanAndPlanSyncUseCase(vaultGateway, syncRegistryRepository); - const executeSyncPlanUseCase = new ExecuteSyncPlanUseCase(this.ankiGateway, syncRegistryRepository, vaultGateway); - this.syncCurrentFileUseCase = new SyncCurrentFileUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase); - this.syncVaultUseCase = new SyncVaultUseCase(scanAndPlanSyncUseCase, executeSyncPlanUseCase); + const manualSyncService = new ManualSyncService(vaultGateway, pluginStateRepository, this.ankiGateway); + this.syncCurrentFileUseCase = new ManualSyncCurrentFileUseCase(manualSyncService); + this.syncVaultUseCase = new ManualSyncVaultUseCase(manualSyncService); + this.rebuildCardIndexUseCase = new RebuildCardIndexUseCase(manualSyncService); registerCommands(this); this.addSettingTab(new AnkiHeadingSyncSettingTab(this)); @@ -89,7 +90,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin { try { const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings); - this.noticeService.showSyncSummary("Current file sync finished", result); + this.noticeService.showSyncSummary("当前文件同步完成", result); } catch (error) { console.error("Current file sync failed.", error); this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed."); @@ -104,10 +105,25 @@ export default class AnkiHeadingSyncPlugin extends Plugin { try { const result = await this.syncVaultUseCase.execute(this.settings); - this.noticeService.showSyncSummary("Vault sync finished", result); + this.noticeService.showSyncSummary("全库同步完成", result); } catch (error) { console.error("Vault sync failed.", error); this.noticeService.error(error instanceof Error ? error.message : "Vault sync failed."); } } + + async runRebuildCardIndex(): Promise { + if (!this.rebuildCardIndexUseCase) { + this.noticeService.error("Sync use case is not initialized."); + return; + } + + try { + const result = await this.rebuildCardIndexUseCase.execute(this.settings); + this.noticeService.showRebuildSummary("卡片索引重建完成", result); + } catch (error) { + console.error("Card index rebuild failed.", error); + this.noticeService.error(error instanceof Error ? error.message : "Card index rebuild failed."); + } + } } \ No newline at end of file diff --git a/src/presentation/commands/registerCommands.ts b/src/presentation/commands/registerCommands.ts index c99b9c2..4e47939 100644 --- a/src/presentation/commands/registerCommands.ts +++ b/src/presentation/commands/registerCommands.ts @@ -3,7 +3,7 @@ import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; export function registerCommands(plugin: AnkiHeadingSyncPlugin): void { plugin.addCommand({ id: "sync-current-file-to-anki", - name: "Sync current file to Anki", + name: "同步当前文件到 Anki", callback: () => { void plugin.runSyncCurrentFile(); }, @@ -11,9 +11,17 @@ export function registerCommands(plugin: AnkiHeadingSyncPlugin): void { plugin.addCommand({ id: "sync-vault-to-anki", - name: "Sync vault to Anki", + name: "同步全库到 Anki", callback: () => { void plugin.runSyncVault(); }, }); + + plugin.addCommand({ + id: "rebuild-card-index", + name: "重建卡片索引", + callback: () => { + void plugin.runRebuildCardIndex(); + }, + }); } \ No newline at end of file diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index 01ead8a..f866761 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -1,6 +1,6 @@ import { Notice } from "obsidian"; -import type { ExecuteSyncPlanResult } from "@/application/use-cases/types"; +import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes"; export class NoticeService { info(message: string): void { @@ -11,9 +11,21 @@ export class NoticeService { new Notice(message, 8000); } - showSyncSummary(prefix: string, result: ExecuteSyncPlanResult): void { - this.info( - `${prefix}: scanned ${result.scanned}, created ${result.created}, updated ${result.updated}, orphaned ${result.markedOrphan}, media ${result.uploadedMedia}.`, - ); + showSyncSummary(prefix: string, result: ManualSyncResult): void { + const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, created ${result.created}, updated ${result.updated}, orphaned ${result.orphaned}, media ${result.uploadedMedia}, skipped ${result.skippedUnchangedCards}.`; + const conflicts = result.markerWriteConflictFiles.length > 0 + ? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.` + : ""; + + this.info(`${summary}${conflicts}`); + } + + showRebuildSummary(prefix: string, result: ManualSyncResult): void { + const summary = `${prefix}: files ${result.scannedFiles}, cards ${result.scannedCards}, orphaned ${result.orphaned}, rewritten markers ${result.rewrittenMarkers}, skipped ${result.skippedUnchangedCards}.`; + const conflicts = result.markerWriteConflictFiles.length > 0 + ? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.` + : ""; + + this.info(`${summary}${conflicts}`); } } \ No newline at end of file diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts new file mode 100644 index 0000000..98fc2c4 --- /dev/null +++ b/src/test-support/manualSyncFakes.ts @@ -0,0 +1,199 @@ +import type { PluginSettings } from "@/application/config/PluginSettings"; +import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; +import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; +import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; +import { MarkdownWriteConflictError } from "@/application/ports/VaultGateway"; +import type { NoteModelDetails } from "@/application/dto/NoteModelDetails"; +import type { SourceFile } from "@/domain/card/entities/SourceFile"; +import type { MediaAsset } from "@/domain/card/entities/RenderedFields"; +import { createEmptyPluginState, type PluginState } from "@/domain/manual-sync/entities/PluginState"; + +export class InMemoryPluginStateRepository implements PluginStateRepository { + public savedState: PluginState | null = null; + + constructor(private state: PluginState = createEmptyPluginState()) {} + + async load(): Promise { + return this.state; + } + + async save(state: PluginState): Promise { + this.state = state; + this.savedState = state; + } +} + +export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway { + public readCalls: string[] = []; + public replaceCalls: Array<{ path: string; expectedContent: string; nextContent: string }> = []; + public conflictPaths = new Set(); + public errorPaths = new Map(); + + private readonly files = new Map(); + + constructor(initialFiles: Record = {}) { + let nextMtime = 1; + for (const [path, content] of Object.entries(initialFiles)) { + this.files.set(path, { content, mtime: nextMtime, size: content.length }); + nextMtime += 1; + } + } + + async listMarkdownFileRefs() { + return Array.from(this.files.entries()).map(([path, file]) => ({ + path, + basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path, + mtime: file.mtime, + size: file.size, + })); + } + + async readMarkdownFile(path: string): Promise { + this.readCalls.push(path); + const file = this.files.get(path); + if (!file) { + return null; + } + + return { + path, + basename: path.split("/").pop()?.replace(/\.md$/i, "") ?? path, + content: file.content, + }; + } + + async replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise { + this.replaceCalls.push({ path, expectedContent, nextContent }); + + if (this.errorPaths.has(path)) { + throw this.errorPaths.get(path); + } + + if (this.conflictPaths.has(path)) { + throw new MarkdownWriteConflictError(path); + } + + const current = this.files.get(path); + if (!current || current.content !== expectedContent) { + throw new MarkdownWriteConflictError(path); + } + + this.files.set(path, { + content: nextContent, + mtime: current.mtime + 1, + size: nextContent.length, + }); + } + + resolveWikiLink(rawTarget: string) { + return { url: `obsidian://open?vault=Vault&file=${rawTarget}`, displayText: rawTarget }; + } + + resolveEmbed() { + return null; + } + + createBacklink(location: { filePath: string; headingText: string }) { + return `obsidian://open?vault=Vault&file=${location.filePath}#${location.headingText}`; + } + + getFileContent(path: string): string | undefined { + return this.files.get(path)?.content; + } +} + +export class FakeManualSyncAnkiGateway implements AnkiGateway { + public ensuredDecks: string[][] = []; + public addedNotes: AddAnkiNoteInput[] = []; + public updatedNotes: UpdateAnkiNoteInput[] = []; + public changedDecks: ChangeDeckInput[] = []; + public storedMedia: MediaAsset[] = []; + public noteSummariesById = new Map(); + public modelDetailsByName: Record = { + Basic: { fieldNames: ["Front", "Back"], isCloze: false }, + Cloze: { fieldNames: ["Text", "Extra"], isCloze: true }, + }; + + private nextNoteId = 9000; + + async ensureDeckExists(deckName: string): Promise { + await this.ensureDecks([deckName]); + } + + async ensureDecks(deckNames: string[]): Promise { + this.ensuredDecks.push(deckNames); + } + + async listNoteModels(): Promise { + return Object.keys(this.modelDetailsByName); + } + + async getModelDetails(modelName: string): Promise { + return this.modelDetailsByName[modelName] ?? { fieldNames: ["Front", "Back"], isCloze: false }; + } + + async getNoteSummaries(noteIds: number[]): Promise { + return noteIds.flatMap((noteId) => { + const summary = this.noteSummariesById.get(noteId); + return summary ? [summary] : []; + }); + } + + async addNote(input: AddAnkiNoteInput): Promise { + const [noteId] = await this.addNotes([input]); + return noteId; + } + + async addNotes(inputs: AddAnkiNoteInput[]): Promise { + return inputs.map((input) => { + this.addedNotes.push(input); + this.nextNoteId += 1; + return this.nextNoteId; + }); + } + + async updateNote(input: UpdateAnkiNoteInput): Promise { + await this.updateNotes([input]); + } + + async updateNotes(inputs: UpdateAnkiNoteInput[]): Promise { + this.updatedNotes.push(...inputs); + } + + async changeDecks(inputs: ChangeDeckInput[]): Promise { + this.changedDecks.push(...inputs); + } + + async storeMedia(asset: MediaAsset): Promise { + await this.storeMediaFiles([asset]); + } + + async storeMediaFiles(assets: MediaAsset[]): Promise { + this.storedMedia.push(...assets); + } +} + +export function createModule3Settings(overrides: Partial = {}): PluginSettings { + return { + ...DEFAULT_SETTINGS, + noteFieldMappings: { + "basic:Basic": { + cardType: "basic", + modelName: "Basic", + loadedFieldNames: ["Front", "Back"], + titleField: "Front", + bodyField: "Back", + loadedAt: 1, + }, + "cloze:Cloze": { + cardType: "cloze", + modelName: "Cloze", + loadedFieldNames: ["Text", "Extra"], + mainField: "Text", + loadedAt: 1, + }, + }, + ...overrides, + }; +} \ No newline at end of file