mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
1287 lines
26 KiB
Markdown
1287 lines
26 KiB
Markdown
# Obsidian Anki 新插件设计备忘(DDD 讨论稿)
|
||
|
||
## 文档目的
|
||
|
||
这不是一个立即开工的实现文档,而是一份面向未来的设计备忘。
|
||
|
||
目标是把当前已经讨论清楚的方向、边界、约束、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 集成
|
||
|
||
在真正动手写代码之前,都不要跳过前面三个建模步骤。
|