mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
657 lines
13 KiB
Markdown
657 lines
13 KiB
Markdown
# 模块 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 执行层必须按类型批处理
|