mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
删除已失效的 legacy sync use case、syncRegistry 持久化与旧 card pipeline,运行入口保持 ManualSyncService 主链。 Remove the obsolete legacy sync use cases, syncRegistry persistence, and old card pipeline while keeping the runtime on ManualSyncService.
1290 lines
26 KiB
Markdown
1290 lines
26 KiB
Markdown
# Obsidian Anki 新插件设计备忘(DDD 讨论稿)
|
||
|
||
> 归档说明(2026-04-21):本文包含已移除的 legacy sync / syncRegistry 架构讨论。
|
||
> 仓库当前有效实现以 `ManualSyncService` manual-sync 主链为准。
|
||
|
||
## 文档目的
|
||
|
||
这不是一个立即开工的实现文档,而是一份面向未来的设计备忘。
|
||
|
||
目标是把当前已经讨论清楚的方向、边界、约束、DDD 拆分方式、领域模型、同步策略、风险点与后续演进路径完整记录下来,避免之后重新开始时又回到模糊状态。
|
||
|
||
本文档面向的插件目标是:
|
||
|
||
- 重新做一个新的 Obsidian -> Anki 插件
|
||
- 吸收 `AnkiHelperForCoding` 与 `obsidian-to-anki-plugin-forCoding` 的优点
|
||
- 保留“好用的做卡与同步能力”
|
||
- 去掉过重、过泛化、过难维护的部分
|
||
- 明确以 Obsidian 为唯一真源
|
||
- 明确只做单向同步:`Obsidian -> Anki`
|
||
|
||
库一:/Users/panxiaorong/Documents/ObsidianPluginCode/AnkiHelperForCoding
|
||
库二:/Users/panxiaorong/Documents/ObsidianPluginCode/obsidian-to-anki-plugin-forCoding
|
||
|
||
## 背景与总体判断
|
||
|
||
现有两个仓库各有优点,但都不适合作为新插件的直接基础。
|
||
|
||
### 1. `AnkiHelperForCoding` 的优点
|
||
|
||
- 工作流聚焦
|
||
- 功能边界相对清楚
|
||
- 对标题式做卡思路明确
|
||
- 有轻量的批处理和增量处理思路
|
||
- 贴近 Obsidian 内的实际使用方式
|
||
|
||
### 2. `obsidian-to-anki-plugin-forCoding` 的优点
|
||
|
||
- 真正具备较完整的 AnkiConnect 同步链路
|
||
- 能创建牌组、添加卡片、更新卡片、处理媒体
|
||
- 有较成熟的 Markdown/媒体/公式/链接转 Anki 可显示内容的能力
|
||
- 有全库扫描、同步状态、hash、批量处理等工程经验
|
||
|
||
### 3. 为什么不能简单合并
|
||
|
||
`obsidian-to-anki-plugin-forCoding` 的问题不是“代码不好”,而是产品定位已经变成“通用同步平台”,而不是“聚焦标题式做卡的新插件”。
|
||
|
||
它变重主要来自以下六层:
|
||
|
||
1. 输入语法越来越多
|
||
2. 配置系统越来越像框架
|
||
3. 同步系统承担了太多职责
|
||
4. 格式转换链较长
|
||
5. 全库扫描和过滤系统复杂化
|
||
6. TypeScript 之外还有 Python 旁路与更重的工程负担
|
||
|
||
新插件的方向不是复制它,而是:
|
||
|
||
- 保留核心能力
|
||
- 重写核心结构
|
||
- 主动缩掉产品边界
|
||
|
||
## 当前已确定的产品方向
|
||
|
||
下面是当前讨论中已经较明确的决定。
|
||
|
||
### 1. 输入语法只做“标题段落式”
|
||
|
||
只支持标题段落式卡片,不做旧插件里的通用语法平台。
|
||
|
||
规则初步确定为:
|
||
|
||
- 问答题默认使用四级标题 `H4`
|
||
- 填空题默认使用五级标题 `H5`
|
||
- 两者都允许用户修改级别
|
||
- 一个标题对应一个卡片块
|
||
- 卡片内容范围是:
|
||
- 从该标题下一行开始
|
||
- 到下一个“同级或更高标题”之前结束
|
||
|
||
这意味着第一版明确不做:
|
||
|
||
- 自定义正则语法
|
||
- Inline Note
|
||
- Begin/End Note 块语法
|
||
- 任意用户定义的卡片语法体系
|
||
|
||
### 2. 配置系统不做大平台
|
||
|
||
配置系统要保留必要能力,但不再做成旧插件那种开放式框架。
|
||
|
||
当前已保留的配置方向:
|
||
|
||
- 问答题标题级别
|
||
- 填空题标题级别
|
||
- `TARGET DECK`
|
||
- `qa note type`
|
||
- `cloze note type`
|
||
- 默认 deck
|
||
- 扫描范围(包含/排除文件夹)
|
||
- 是否添加 Obsidian 文件回链
|
||
- 是否为 Cloze 启用高亮转空
|
||
|
||
明确不做:
|
||
|
||
- folder -> deck 映射表
|
||
- folder -> tag 映射表
|
||
- 通用 syntax setting 集合
|
||
- 大量用户可编程语法选项
|
||
- 复杂 glob 平台
|
||
|
||
### 3. 同步系统保留核心能力
|
||
|
||
同步系统虽然重,但其中很多能力是值得保留的。
|
||
|
||
建议保留的能力:
|
||
|
||
- 创建 deck
|
||
- 新增 note
|
||
- 更新已有 note
|
||
- 媒体上传
|
||
- 批量扫描
|
||
- 同步状态持久化
|
||
- 增量同步
|
||
- orphan 检测
|
||
|
||
暂不直接做:
|
||
|
||
- Anki -> Obsidian 反向回写
|
||
- 双向同步
|
||
- 高复杂度冲突解决
|
||
- 第一版直接自动删除 orphan 对应的 Anki 卡片
|
||
|
||
### 4. 格式保真是关键目标
|
||
|
||
新插件的一个核心目标是:
|
||
|
||
> 在 Obsidian 里看到的内容,进入 Anki 后也尽量保持可用、可读、可复习。
|
||
|
||
因此需要保留并重构以下能力:
|
||
|
||
- Markdown -> HTML
|
||
- 数学公式
|
||
- 代码块与行内代码尽量不被破坏
|
||
- 高亮
|
||
- Wikilink
|
||
- 图片 embed
|
||
- 音频 embed
|
||
- Obsidian 回链
|
||
|
||
### 5. 需要做全局扫描,但边界要收紧
|
||
|
||
当前讨论结果是:
|
||
|
||
- 需要全局扫描
|
||
- 但只支持用户指定包含文件夹和排除文件夹
|
||
- 不做复杂 glob 规则平台
|
||
|
||
推荐规则:
|
||
|
||
- `includeFolders` 为空时:扫描整个 vault
|
||
- `includeFolders` 非空时:只扫描指定文件夹
|
||
- `excludeFolders` 始终生效
|
||
- 仅扫描 Markdown 文件
|
||
|
||
### 6. 工程上不保留 Python 旁路
|
||
|
||
新插件只保留 TypeScript / Obsidian 插件实现。
|
||
|
||
明确不做:
|
||
|
||
- Python CLI
|
||
- Python 安装与依赖管理
|
||
- 双实现栈同步维护
|
||
|
||
## 核心产品原则
|
||
|
||
这是新插件最关键的一条原则:
|
||
|
||
`Obsidian 是唯一真源,Anki 是同步目标。`
|
||
|
||
这条原则一旦确定,后续很多设计就会变得稳定:
|
||
|
||
- Obsidian 中的内容决定 Anki 中的内容
|
||
- Anki 中手改正文不会回写
|
||
- 下次同步时,Anki 中相关字段会被 Obsidian 内容覆盖
|
||
- 不需要做双向冲突合并
|
||
- 不需要把 Anki HTML 反解析成 Markdown
|
||
- 不需要做“Anki 改动写回标题块”的定位逻辑
|
||
|
||
## 为什么不做双向同步
|
||
|
||
虽然理论上可以做 Anki -> Obsidian 回写,但这不适合第一版,也可能永远不值得做。
|
||
|
||
原因包括:
|
||
|
||
- 无法轻易决定谁是最终真源
|
||
- Anki 中修改的可能是字段、标签、牌组、模型,而不只是正文
|
||
- Obsidian 的标题段落结构并不是数据库记录
|
||
- 回写时必须重新定位标题块
|
||
- 标题改名、移动、层级变化都会让回写不稳定
|
||
- HTML/媒体/链接从 Anki 回写为 Markdown 时存在信息损失
|
||
|
||
因此当前明确决定:
|
||
|
||
- 只做 `Obsidian -> Anki`
|
||
- 不做双向同步
|
||
- 不做 Anki 正文回写
|
||
|
||
## DDD 总体视角
|
||
|
||
这个插件如果用 DDD 来看,核心不是 UI,也不是 AnkiConnect API,而是:
|
||
|
||
> 把 Obsidian 中的标题段落块建模为“可同步卡片”,并稳定地同步到 Anki。
|
||
|
||
因此真正的核心领域不是:
|
||
|
||
- 设置页
|
||
- 命令面板
|
||
- HTTP 请求
|
||
|
||
而是:
|
||
|
||
- 卡片识别
|
||
- 卡片身份稳定性
|
||
- 卡片渲染
|
||
- 同步计划
|
||
- 同步状态演进
|
||
|
||
## 统一语言(Ubiquitous Language)
|
||
|
||
下面这些术语建议在整个项目里统一使用,避免后续沟通与实现混乱。
|
||
|
||
### Source Note
|
||
|
||
Obsidian 中的一个 Markdown 文件。
|
||
|
||
### Heading Block
|
||
|
||
一个标题以及它所管辖的正文范围。
|
||
|
||
### Card Draft
|
||
|
||
从标题块中解析出来、但还没有完成渲染和同步决策的卡片草稿。
|
||
|
||
### Card
|
||
|
||
领域中的标准化卡片对象,是同步的基本单位。
|
||
|
||
### Card Type
|
||
|
||
卡片类型,目前只包括:
|
||
|
||
- `basic`
|
||
- `cloze`
|
||
|
||
### Deck Target
|
||
|
||
卡片最终应进入的 Anki 牌组。
|
||
|
||
### Note Model
|
||
|
||
Anki 中的 note type / model,例如:
|
||
|
||
- `Basic`
|
||
- `Cloze`
|
||
|
||
### Rendered Fields
|
||
|
||
已经转换成可发送给 Anki 的字段内容。
|
||
|
||
### Sync Record
|
||
|
||
本地保存的同步记录,用于维护 `cardKey -> noteId` 映射及同步状态。
|
||
|
||
### Sync Scope
|
||
|
||
本次扫描或同步覆盖的文件范围。
|
||
|
||
### Orphan Card
|
||
|
||
本地同步记录里曾存在,但本次扫描中已经找不到来源卡片的记录。
|
||
|
||
### Source Hash
|
||
|
||
源卡片内容计算得到的 hash,用于判断是否变化。
|
||
|
||
## Bounded Context 拆分
|
||
|
||
建议把系统拆成四个主要上下文。
|
||
|
||
## 1. Card Authoring Context
|
||
|
||
负责从 Obsidian 内容里识别卡片。
|
||
|
||
### 职责
|
||
|
||
- 判断哪些标题会被视为卡片
|
||
- 判断这是问答题还是填空题
|
||
- 提取标题文本
|
||
- 提取正文块范围
|
||
- 解析文件级 `TARGET DECK`
|
||
- 产出 `CardDraft`
|
||
|
||
### 不负责
|
||
|
||
- AnkiConnect 通信
|
||
- noteId 管理
|
||
- orphan 检测
|
||
- HTTP
|
||
- UI
|
||
|
||
### 这里的核心问题
|
||
|
||
- 标题块切分是否准确
|
||
- 标题级别策略是否稳定
|
||
- 内容范围是否与用户认知一致
|
||
|
||
## 2. Rendering Context
|
||
|
||
负责把卡片内容转成 Anki 可接收的字段内容。
|
||
|
||
### 职责
|
||
|
||
- Markdown -> HTML
|
||
- 数学公式处理
|
||
- 图片与音频处理
|
||
- Wikilink 处理
|
||
- Obsidian 回链处理
|
||
- 高亮与 Cloze 转换
|
||
- 生成最终字段值
|
||
|
||
### 不负责
|
||
|
||
- 扫描哪些文件
|
||
- 判断新增/更新/删除
|
||
- 生成 noteId
|
||
|
||
### 这里的核心问题
|
||
|
||
- 内容是否保真
|
||
- 代码块、公式、高亮等边界是否被破坏
|
||
- 媒体是否可上传并正确显示
|
||
|
||
## 3. Sync Context
|
||
|
||
负责决定“该如何把卡片同步到 Anki”。
|
||
|
||
### 职责
|
||
|
||
- 读取历史同步记录
|
||
- 判断新增与更新
|
||
- 检测 orphan
|
||
- 生成同步计划
|
||
- 执行同步动作
|
||
- 更新本地同步记录
|
||
|
||
### 不负责
|
||
|
||
- 标题块如何识别
|
||
- markdown 如何渲染
|
||
- 设置 UI
|
||
|
||
### 这里的核心问题
|
||
|
||
- 同一张卡如何稳定识别
|
||
- 什么情况下是更新
|
||
- 什么情况下视为 orphan
|
||
- 删除是否自动进行
|
||
|
||
## 4. Integration Context
|
||
|
||
负责和外部系统打交道。
|
||
|
||
### 对 Obsidian
|
||
|
||
- Vault 文件访问
|
||
- MetadataCache
|
||
- 当前活动文件
|
||
- 命令注册
|
||
- 设置页
|
||
|
||
### 对 Anki
|
||
|
||
- AnkiConnect 调用
|
||
- 创建 deck
|
||
- 新增 note
|
||
- 更新 note
|
||
- 删除 note
|
||
- 上传媒体
|
||
|
||
### 角色定位
|
||
|
||
这是防腐层,不应该污染领域模型。
|
||
|
||
## Core Domain / Supporting / Generic
|
||
|
||
为了防止后续又掉回“大而杂”,建议从战略设计上明确区分:
|
||
|
||
### Core Domain
|
||
|
||
- 标题块识别
|
||
- 卡片身份生成
|
||
- 同步计划生成
|
||
|
||
### Supporting Subdomains
|
||
|
||
- Markdown/媒体/链接渲染
|
||
- 扫描范围过滤
|
||
- orphan 检测
|
||
- 同步状态存储
|
||
|
||
### Generic Subdomains
|
||
|
||
- 设置页
|
||
- 命令注册
|
||
- Notice 提示
|
||
- JSON 持久化
|
||
- HTTP 请求
|
||
|
||
## 聚合设计建议
|
||
|
||
不建议在这个项目里设计过多聚合。两个主要聚合已经足够。
|
||
|
||
## 聚合一:SourceFile Aggregate
|
||
|
||
聚合根:`SourceFile`
|
||
|
||
### 包含内容
|
||
|
||
- 文件路径
|
||
- 文件内容
|
||
- metadata 快照
|
||
- 文件级 deck 信息
|
||
- 解析出的标题块
|
||
- 解析出的卡片草稿
|
||
|
||
### 职责
|
||
|
||
- 在单文件内完成标题块识别
|
||
- 保证一个文件中的卡片识别逻辑一致
|
||
- 处理文件级 deck 覆盖
|
||
|
||
### 不应该负责
|
||
|
||
- Anki noteId
|
||
- 全局同步状态
|
||
- orphan 判断
|
||
|
||
## 聚合二:SyncRegistry Aggregate
|
||
|
||
聚合根:`SyncRegistry`
|
||
|
||
### 包含内容
|
||
|
||
- `cardKey -> SyncRecord` 映射
|
||
- 历史 noteId
|
||
- source hash
|
||
- 上次同步时间
|
||
- orphan 标记
|
||
|
||
### 职责
|
||
|
||
- 管理卡片同步状态生命周期
|
||
- 帮助判断新增、更新、orphan
|
||
- 与数据持久化边界配合
|
||
|
||
### 不应该负责
|
||
|
||
- markdown 渲染
|
||
- 标题块切分
|
||
|
||
## 实体与值对象建议
|
||
|
||
这里要严格区分 Entity 与 Value Object。
|
||
|
||
## Entity
|
||
|
||
### SourceFile
|
||
|
||
身份由 `filePath` 决定。
|
||
|
||
### Card
|
||
|
||
身份由 `cardKey` 决定。
|
||
|
||
### SyncRecord
|
||
|
||
身份由 `cardKey` 决定,也可内部持有 `noteId`。
|
||
|
||
## Value Object
|
||
|
||
建议作为值对象的内容:
|
||
|
||
- `CardKey`
|
||
- `DeckName`
|
||
- `NoteModelName`
|
||
- `HeadingLevel`
|
||
- `HeadingText`
|
||
- `HeadingBlockRange`
|
||
- `CardType`
|
||
- `RenderedFields`
|
||
- `SourceLocation`
|
||
- `ContentHash`
|
||
- `SyncScope`
|
||
|
||
## Card 领域对象建议
|
||
|
||
建议把领域中的 `Card` 明确定义为“标准化同步对象”,而不是随便几段文本。
|
||
|
||
推荐形状如下:
|
||
|
||
```ts
|
||
type Card = {
|
||
key: CardKey;
|
||
source: SourceLocation;
|
||
type: CardType;
|
||
heading: string;
|
||
bodyMarkdown: string;
|
||
deck: DeckName;
|
||
noteModel: NoteModelName;
|
||
tags: string[];
|
||
fields: Record<string, string>;
|
||
contentHash: ContentHash;
|
||
};
|
||
```
|
||
|
||
### 字段意义
|
||
|
||
- `key`
|
||
- 这张卡在领域中的稳定身份
|
||
- `source`
|
||
- 它来自哪个文件、哪个标题位置
|
||
- `type`
|
||
- `basic` 或 `cloze`
|
||
- `heading`
|
||
- 标题文本
|
||
- `bodyMarkdown`
|
||
- 原始正文
|
||
- `deck`
|
||
- 最终牌组
|
||
- `noteModel`
|
||
- 最终 note type
|
||
- `fields`
|
||
- 已经渲染好的 Anki 字段
|
||
- `contentHash`
|
||
- 用于判断内容是否变化
|
||
|
||
### 一个重要原则
|
||
|
||
`bodyMarkdown` 与 `fields` 不应混为一谈。
|
||
|
||
前者是原始领域内容,后者是渲染后的派生内容。二者都应有清晰位置。
|
||
|
||
## Card Draft 建议
|
||
|
||
为了让解析与渲染解耦,建议先有 `CardDraft`。
|
||
|
||
```ts
|
||
type CardDraft = {
|
||
source: SourceLocation;
|
||
heading: string;
|
||
headingLevel: number;
|
||
type: "basic" | "cloze";
|
||
bodyMarkdown: string;
|
||
deckHint?: string;
|
||
};
|
||
```
|
||
|
||
然后由渲染层把 `CardDraft` 转成正式 `Card`。
|
||
|
||
这样做的好处是:
|
||
|
||
- 标题识别逻辑和渲染逻辑解耦
|
||
- deck 解析、字段映射可以单独演进
|
||
- 未来更容易测试
|
||
|
||
## Card Type 语义建议
|
||
|
||
当前只保留两类卡。
|
||
|
||
### Basic
|
||
|
||
语义建议:
|
||
|
||
- 标题 -> Front
|
||
- 正文 -> Back
|
||
|
||
### Cloze
|
||
|
||
语义建议:
|
||
|
||
- 正文 -> Cloze 主字段
|
||
- 标题不进入主 cloze 字段
|
||
- 标题可选进入额外字段,比如 `Extra` 或上下文字段
|
||
|
||
原因:
|
||
|
||
- 如果标题也拼进 Cloze 主字段,会干扰 Cloze 语义
|
||
- 标题更适合做上下文或辅助定位信息
|
||
|
||
## Note Model 与 Deck 的区别
|
||
|
||
必须明确区分:
|
||
|
||
### Note Model
|
||
|
||
决定 Anki 里字段结构,例如:
|
||
|
||
- `Basic`
|
||
- `Basic (and reversed card)`
|
||
- `Cloze`
|
||
|
||
### Deck
|
||
|
||
决定卡片进入哪个牌组,例如:
|
||
|
||
- `English`
|
||
- `English::Words`
|
||
- `History::Chapter1`
|
||
|
||
它们是两个完全不同的维度。
|
||
|
||
因此配置上建议分别建模,而不是混淆。
|
||
|
||
## Card Identity(cardKey)策略
|
||
|
||
这是整个系统最敏感的问题之一。
|
||
|
||
### 目标
|
||
|
||
`cardKey` 需要尽量满足:
|
||
|
||
- 同一文件内唯一
|
||
- 同一轮扫描内唯一
|
||
- 在小幅编辑下尽量稳定
|
||
- 不依赖 Anki noteId
|
||
|
||
### 第一版推荐策略
|
||
|
||
第一版可以先用较保守的组合:
|
||
|
||
```ts
|
||
cardKey = hash(
|
||
filePath +
|
||
headingLevel +
|
||
headingText +
|
||
blockStartLine +
|
||
cardType
|
||
)
|
||
```
|
||
|
||
### 优点
|
||
|
||
- 实现简单
|
||
- 同文件内冲突概率低
|
||
- 足够支撑第一版
|
||
|
||
### 缺点
|
||
|
||
- 标题改名会影响 key
|
||
- 前面插入内容导致行号变化会影响 key
|
||
- 文件移动会影响 key
|
||
|
||
### 未来可升级方向
|
||
|
||
未来如果需要更稳定,可以考虑:
|
||
|
||
- 持久化块级 anchor
|
||
- 在标题块内植入不可见标识
|
||
- 使用内部 source id 映射
|
||
|
||
### DDD 视角下的建议
|
||
|
||
不要把 key 生成逻辑散落在代码中,而应抽成一个领域策略:
|
||
|
||
- `CardIdentityPolicy`
|
||
|
||
它属于业务规则,不只是技术细节。
|
||
|
||
## Deck 解析策略
|
||
|
||
当前建议的 deck 决策顺序:
|
||
|
||
1. 文件中显式配置的 `TARGET DECK`
|
||
2. 插件默认 deck
|
||
|
||
第一版不做:
|
||
|
||
- folder -> deck 映射
|
||
- 基于路径的 deck 自动推导
|
||
- 标题级单独 deck 覆盖
|
||
|
||
### `TARGET DECK` 的定位
|
||
|
||
作为保留下来的最小 syntax setting:
|
||
|
||
- 只保留 `Target Deck Line`
|
||
- 不保留 Begin/End Note 等其它语法开关
|
||
|
||
## 标题级别策略
|
||
|
||
当前建议默认值:
|
||
|
||
- `qaHeadingLevel = 4`
|
||
- `clozeHeadingLevel = 5`
|
||
|
||
### 需要保证的业务规则
|
||
|
||
- 两个级别都允许用户修改
|
||
- 两个级别不应相同
|
||
- 如果相同,应在配置层校验并阻止
|
||
|
||
## 扫描范围模型
|
||
|
||
虽然保留全局扫描,但需要主动收缩边界。
|
||
|
||
### 推荐配置
|
||
|
||
```ts
|
||
type ScanScopeConfig = {
|
||
includeFolders: string[];
|
||
excludeFolders: string[];
|
||
};
|
||
```
|
||
|
||
### 推荐行为
|
||
|
||
- `includeFolders` 为空:扫描全部 Markdown 文件
|
||
- `includeFolders` 非空:只扫描这些文件夹下的 Markdown 文件
|
||
- `excludeFolders` 永远对候选集做排除
|
||
|
||
### 第一版不做
|
||
|
||
- 任意 glob
|
||
- 单文件模式表达式
|
||
- ignore 语法 DSL
|
||
- 复杂优先级覆盖规则
|
||
|
||
## Sync Record 建议
|
||
|
||
本地同步状态建议保存为独立模型。
|
||
|
||
```ts
|
||
type SyncRecord = {
|
||
cardKey: string;
|
||
noteId: number;
|
||
filePath: string;
|
||
sourceHash: string;
|
||
lastSyncedAt: number;
|
||
orphan: boolean;
|
||
};
|
||
```
|
||
|
||
### 字段说明
|
||
|
||
- `cardKey`
|
||
- 领域卡片身份
|
||
- `noteId`
|
||
- Anki 中对应 note 的 id
|
||
- `filePath`
|
||
- 便于追踪来源与调试
|
||
- `sourceHash`
|
||
- 判断是否发生变化
|
||
- `lastSyncedAt`
|
||
- 便于调试和排错
|
||
- `orphan`
|
||
- 表示当前扫描未再找到对应来源
|
||
|
||
## 为什么建议先不做自动删除
|
||
|
||
虽然同步系统中“删除失效卡片”看起来合理,但第一版不建议直接做自动删除。
|
||
|
||
### 原因
|
||
|
||
- 标题改名会被误判为删除旧卡、创建新卡
|
||
- 文件移动可能导致 key 变化
|
||
- 扫描范围变窄时可能导致误判 orphan
|
||
- 用户切换设置后,候选卡集合可能临时变化
|
||
- 删除是破坏性动作,风险高
|
||
|
||
### 第一版建议
|
||
|
||
- 支持新增
|
||
- 支持更新
|
||
- 支持 orphan 检测
|
||
- 支持手动清理 orphan 的未来扩展点
|
||
- 默认不自动删 Anki 卡
|
||
|
||
## 建议的领域服务
|
||
|
||
以下领域服务是比较关键的。
|
||
|
||
## 1. CardExtractionService
|
||
|
||
### 输入
|
||
|
||
- `SourceFile`
|
||
- `HeadingPolicy`
|
||
|
||
### 输出
|
||
|
||
- `CardDraft[]`
|
||
|
||
### 负责
|
||
|
||
- 找到有效标题
|
||
- 切分正文块
|
||
- 判断 `basic` / `cloze`
|
||
|
||
## 2. DeckResolutionService
|
||
|
||
### 输入
|
||
|
||
- 文件级 `TARGET DECK`
|
||
- 插件默认 deck
|
||
- 卡片配置
|
||
|
||
### 输出
|
||
|
||
- `DeckName`
|
||
|
||
### 负责
|
||
|
||
- 决定卡片最终牌组
|
||
|
||
## 3. CardIdentityService
|
||
|
||
### 输入
|
||
|
||
- `SourceLocation`
|
||
- 标题信息
|
||
- 卡片类型
|
||
|
||
### 输出
|
||
|
||
- `CardKey`
|
||
|
||
### 负责
|
||
|
||
- 生成稳定 identity
|
||
|
||
## 4. CardRenderingService
|
||
|
||
### 输入
|
||
|
||
- `CardDraft`
|
||
|
||
### 输出
|
||
|
||
- `Card`
|
||
|
||
### 负责
|
||
|
||
- 生成最终字段
|
||
- 处理 Markdown/媒体/链接/公式
|
||
|
||
## 5. SyncPlanningService
|
||
|
||
### 输入
|
||
|
||
- 当前扫描得到的 `Card[]`
|
||
- 历史 `SyncRecord[]`
|
||
|
||
### 输出
|
||
|
||
- `SyncPlan`
|
||
|
||
### 负责
|
||
|
||
- 判断 add
|
||
- 判断 update
|
||
- 判断 orphan
|
||
|
||
这个服务是整个同步系统的关键,必须单独存在,不应散落在命令或 UI 层中。
|
||
|
||
## Sync Plan 建议
|
||
|
||
推荐把同步规划显式建模出来。
|
||
|
||
```ts
|
||
type SyncPlan = {
|
||
toCreateDecks: string[];
|
||
toAdd: Card[];
|
||
toUpdate: Array<{ card: Card; noteId: number }>;
|
||
toMarkOrphan: SyncRecord[];
|
||
};
|
||
```
|
||
|
||
### 第一版动作集合建议
|
||
|
||
第一版先只做:
|
||
|
||
- `createDeck`
|
||
- `add`
|
||
- `update`
|
||
- `markOrphan`
|
||
|
||
不做:
|
||
|
||
- `delete`
|
||
|
||
## Application Service / Use Case 建议
|
||
|
||
DDD 中应用服务负责“编排用例”,不承担核心业务规则。
|
||
|
||
## 1. ScanAndPlanSyncUseCase
|
||
|
||
### 流程
|
||
|
||
1. 读取扫描范围
|
||
2. 收集候选文件
|
||
3. 从文件解析 `CardDraft`
|
||
4. 渲染为 `Card`
|
||
5. 读取历史 `SyncRecord`
|
||
6. 生成 `SyncPlan`
|
||
|
||
## 2. ExecuteSyncPlanUseCase
|
||
|
||
### 流程
|
||
|
||
1. 创建缺失 deck
|
||
2. 上传媒体
|
||
3. 新增 cards
|
||
4. 更新 cards
|
||
5. 更新同步状态
|
||
6. 标记 orphan
|
||
|
||
## 3. SyncCurrentFileUseCase
|
||
|
||
### 流程
|
||
|
||
只对当前文件执行完整扫描与同步流程。
|
||
|
||
## 4. SyncVaultUseCase
|
||
|
||
### 流程
|
||
|
||
对扫描范围内的文件执行完整扫描与同步流程。
|
||
|
||
## 5. PreviewSyncUseCase(未来可选)
|
||
|
||
### 流程
|
||
|
||
只生成同步计划,不真正执行。
|
||
|
||
### 价值
|
||
|
||
- 调试
|
||
- 风险确认
|
||
- 后续做 UI 预览
|
||
|
||
## Repository 建议
|
||
|
||
Repository 主要是持久化边界,不是业务逻辑本身。
|
||
|
||
## 1. SyncRegistryRepository
|
||
|
||
### 负责
|
||
|
||
- 读取同步记录
|
||
- 保存同步记录
|
||
- upsert 卡片同步状态
|
||
- 标记 orphan
|
||
|
||
### 可能的存储位置
|
||
|
||
- 插件 `data.json`
|
||
|
||
## 2. PluginConfigRepository
|
||
|
||
### 负责
|
||
|
||
- 读取插件配置
|
||
- 输出面向领域的配置对象
|
||
|
||
### 注意
|
||
|
||
不要让领域层直接消费 Obsidian 原始 Setting UI 数据结构。
|
||
|
||
## 3. ScanScopeRepository(可选)
|
||
|
||
如果未来扫描配置较复杂,可以单独抽象。
|
||
|
||
## Anti-Corruption Layer(防腐层)建议
|
||
|
||
这个项目非常需要防腐层,否则外部 API 会把领域污染掉。
|
||
|
||
## 对 Obsidian 的防腐层
|
||
|
||
不要让领域层直接依赖:
|
||
|
||
- `App`
|
||
- `TFile`
|
||
- `CachedMetadata`
|
||
|
||
建议由基础设施层封装为:
|
||
|
||
- `ObsidianVaultGateway`
|
||
- `ObsidianMetadataGateway`
|
||
- `ObsidianWorkspaceGateway`
|
||
|
||
然后向应用层提供干净 DTO。
|
||
|
||
## 对 AnkiConnect 的防腐层
|
||
|
||
不要让领域层直接拼 action JSON。
|
||
|
||
建议封装为:
|
||
|
||
- `AnkiGateway`
|
||
|
||
对外提供的方法应是业务语义,而不是裸 `invoke("addNote")`:
|
||
|
||
- `ensureDeckExists`
|
||
- `addNote`
|
||
- `updateNote`
|
||
- `deleteNotes`
|
||
- `storeMedia`
|
||
|
||
## 领域不变量建议
|
||
|
||
这些不变量建议尽早确立。
|
||
|
||
### 卡片识别相关
|
||
|
||
- 一个标题块最多生成一张卡
|
||
- 一张卡必须属于且只属于一种 `CardType`
|
||
- 同一次扫描内 `cardKey` 不可重复
|
||
|
||
### 配置相关
|
||
|
||
- 问答题级别和填空题级别不能相同
|
||
- 每张卡必须解析出 deck
|
||
- 每张卡必须解析出 note model
|
||
|
||
### Basic / Cloze 相关
|
||
|
||
- `basic` 必须有 front/back 字段映射
|
||
- `cloze` 必须映射到支持 cloze 的 note model
|
||
|
||
### 同步相关
|
||
|
||
- 一个 `cardKey` 只能对应一个活动同步记录
|
||
- 一个 `noteId` 不应对应多个不同 `cardKey`
|
||
- orphan 默认不能自动删除
|
||
|
||
## 建议的目录结构
|
||
|
||
如果未来真的开工,建议从一开始就按边界建目录,而不是继续堆在 `main.ts` 和工具函数里。
|
||
|
||
```text
|
||
src/
|
||
domain/
|
||
card/
|
||
entities/
|
||
value-objects/
|
||
services/
|
||
policies/
|
||
sync/
|
||
entities/
|
||
value-objects/
|
||
services/
|
||
shared/
|
||
value-objects/
|
||
application/
|
||
use-cases/
|
||
dto/
|
||
infrastructure/
|
||
obsidian/
|
||
anki/
|
||
persistence/
|
||
presentation/
|
||
commands/
|
||
settings/
|
||
notices/
|
||
```
|
||
|
||
可以进一步细化为:
|
||
|
||
```text
|
||
src/domain/card/entities/Card.ts
|
||
src/domain/card/entities/SourceFile.ts
|
||
src/domain/card/value-objects/CardKey.ts
|
||
src/domain/card/value-objects/DeckName.ts
|
||
src/domain/card/value-objects/SourceLocation.ts
|
||
src/domain/card/services/CardExtractionService.ts
|
||
src/domain/card/services/CardRenderingService.ts
|
||
src/domain/card/policies/CardIdentityPolicy.ts
|
||
|
||
src/domain/sync/entities/SyncRecord.ts
|
||
src/domain/sync/entities/SyncRegistry.ts
|
||
src/domain/sync/services/SyncPlanningService.ts
|
||
|
||
src/application/use-cases/SyncCurrentFileUseCase.ts
|
||
src/application/use-cases/SyncVaultUseCase.ts
|
||
src/application/use-cases/ScanAndPlanSyncUseCase.ts
|
||
src/application/use-cases/ExecuteSyncPlanUseCase.ts
|
||
|
||
src/infrastructure/obsidian/ObsidianVaultGateway.ts
|
||
src/infrastructure/obsidian/ObsidianMetadataGateway.ts
|
||
src/infrastructure/anki/AnkiConnectGateway.ts
|
||
src/infrastructure/persistence/DataJsonSyncRegistryRepository.ts
|
||
|
||
src/presentation/commands/registerCommands.ts
|
||
src/presentation/settings/PluginSettingTab.ts
|
||
src/presentation/notices/NoticeService.ts
|
||
```
|
||
|
||
## 需要保留和重写的部分
|
||
|
||
这是从两个旧仓库中提炼出的结论。
|
||
|
||
## 可以借鉴的方向
|
||
|
||
### 来自 `AnkiHelperForCoding`
|
||
|
||
- 聚焦标题级工作流
|
||
- 批处理与增量处理思路
|
||
- 与 Obsidian 工作流贴近的命令设计
|
||
|
||
### 来自 `obsidian-to-anki-plugin-forCoding`
|
||
|
||
- AnkiConnect 通信层思路
|
||
- Markdown/链接/媒体/公式转 Anki 的处理经验
|
||
- 媒体上传与 deck 创建
|
||
- 同步状态管理经验
|
||
|
||
## 必须重写的部分
|
||
|
||
- 领域模型
|
||
- 卡片解析流程
|
||
- 同步计划生成方式
|
||
- 配置模型
|
||
- 扫描范围模型
|
||
- 旧仓库里与通用语法平台强耦合的部分
|
||
|
||
## 第一版建议做什么
|
||
|
||
虽然当前阶段不是立即开发,但为了将来回头时不迷失,仍建议记录“第一版的可交付边界”。
|
||
|
||
### 第一版建议包含
|
||
|
||
- 标题段落式卡片解析
|
||
- `H4` 问答题、`H5` 填空题默认配置
|
||
- 标题级别可配置
|
||
- `Basic` / `Cloze` 两类卡片
|
||
- `qaNoteType` / `clozeNoteType` 可配置
|
||
- 默认 deck
|
||
- 文件级 `TARGET DECK`
|
||
- 全局扫描
|
||
- include/exclude 文件夹
|
||
- 增量同步
|
||
- deck 创建
|
||
- note 新增
|
||
- note 更新
|
||
- 媒体上传
|
||
- markdown/html 转换
|
||
- Obsidian 回链
|
||
- orphan 检测(不自动删)
|
||
|
||
## 第一版明确不做什么
|
||
|
||
- 自定义正则系统
|
||
- inline note
|
||
- begin/end note 语法体系
|
||
- folder -> deck 映射
|
||
- folder -> tag 映射
|
||
- 双向同步
|
||
- Anki -> Obsidian 回写
|
||
- Python CLI
|
||
- 自动删除 orphan 对应 Anki 卡片
|
||
- 复杂 glob 系统
|
||
|
||
## 风险点与尚未完全拍板的问题
|
||
|
||
虽然方向已经清楚,但仍有一些点需要在未来真正开工前再确认。
|
||
|
||
## 1. `cardKey` 稳定性
|
||
|
||
当前推荐方案足以起步,但并不完美。
|
||
|
||
需要后续根据实际使用情况决定:
|
||
|
||
- 是否接受标题改名导致新卡
|
||
- 是否接受文件移动导致重建
|
||
- 是否需要更稳定的内部 source id
|
||
|
||
## 2. Cloze 标题是否写入字段
|
||
|
||
当前倾向是:
|
||
|
||
- Cloze 主字段只放正文
|
||
- 标题作为上下文信息另放
|
||
|
||
但这一点未来仍可在使用体验上再验证。
|
||
|
||
## 3. orphan 的后续处理策略
|
||
|
||
当前明确是:
|
||
|
||
- 先检测
|
||
- 先标记
|
||
- 不自动删
|
||
|
||
未来是否要加“手动清理 orphan”,可以再评估。
|
||
|
||
## 4. 媒体处理边界
|
||
|
||
第一版建议保留图片和音频支持,但需要注意:
|
||
|
||
- 不同格式是否都支持
|
||
- 文件定位是否稳定
|
||
- 上传后是否做重复检测
|
||
|
||
## 5. 渲染保真度边界
|
||
|
||
需要明确接受一个现实:
|
||
|
||
- 不可能一开始就 100% 复刻 Obsidian 显示效果
|
||
|
||
更现实的目标是:
|
||
|
||
- 保证常用内容结构可读、可复习
|
||
- 保证不轻易破坏数学、代码和媒体
|
||
|
||
## 未来扩展方向
|
||
|
||
以下方向可以保留想法,但不应现在就进第一版边界。
|
||
|
||
### 1. 预览同步计划
|
||
|
||
在真正同步前,让用户看到:
|
||
|
||
- 将新增几张
|
||
- 将更新几张
|
||
- 哪些是 orphan
|
||
|
||
### 2. 当前标题同步
|
||
|
||
相较于当前文件同步,进一步细化到只同步当前标题块。
|
||
|
||
### 3. 手动 orphan 清理
|
||
|
||
用户明确确认后,删除失效 Anki 卡片。
|
||
|
||
### 4. 更稳定的 card identity
|
||
|
||
引入块级 anchor 或内部 source id。
|
||
|
||
### 5. 更多上下文字段
|
||
|
||
例如:
|
||
|
||
- 文件名
|
||
- 标题路径
|
||
- Obsidian 链接
|
||
- 上级标题
|
||
|
||
## 结论
|
||
|
||
当前讨论已经形成一个较稳定的产品方向:
|
||
|
||
- 这是一个新的、聚焦“标题段落式做卡”的 Obsidian -> Anki 插件
|
||
- 它借鉴旧项目经验,但不继承旧项目的通用框架定位
|
||
- 它明确以 Obsidian 为唯一真源
|
||
- 它只做单向同步
|
||
- 它保留强同步能力、保留较高内容保真度、保留全局扫描
|
||
- 它主动砍掉通用语法平台、folder 映射、Python 旁路、双向同步与自动删除等高复杂度设计
|
||
|
||
从 DDD 视角看,真正应被认真设计的核心只有三件事:
|
||
|
||
1. 如何把标题块稳定识别为领域卡片
|
||
2. 如何生成尽量稳定的卡片身份
|
||
3. 如何在不失控的前提下生成和执行同步计划
|
||
|
||
只要这三点做对,未来无论是否真的开工,这个插件的方向都不会再轻易漂移。
|
||
|
||
## 建议的下一步(未来恢复此 idea 时)
|
||
|
||
当未来决定真正开始做这个插件时,建议按下面顺序继续:
|
||
|
||
1. 先把 `Card`、`CardDraft`、`SyncRecord` 的精确定义拍板
|
||
2. 再拍板 `cardKey` 策略
|
||
3. 再定义 `Basic` / `Cloze` 的字段映射策略
|
||
4. 再实现 `CardExtractionService`
|
||
5. 再实现 `CardRenderingService`
|
||
6. 再实现 `SyncPlanningService`
|
||
7. 最后接 Obsidian UI 与 AnkiConnect 集成
|
||
|
||
在真正动手写代码之前,都不要跳过前面三个建模步骤。
|