feat(deck): add configurable deck resolution modes

中文: 新增 deck 模式选择、文件级 deck 解析、文件夹映射、模板插入与 warning 汇总

English: Add deck mode selection, file-level deck parsing, folder mapping, template insertion, and warning aggregation
This commit is contained in:
Dusk 2026-04-18 20:14:29 +08:00
parent f0b8063270
commit 960b87103d
40 changed files with 2981 additions and 88 deletions

298
docs/2026-04-18PLAN5.md Normal file
View file

@ -0,0 +1,298 @@
# 模块 5Deck 模式选择与文件级 / 文件夹级 Deck 解析
## Summary
本模块的目标是把 deck 相关功能做成“用户可选择、可理解、可插入模板”的产品形态,而不是隐式启用多套 deck 策略。
用户看到的 deck 体系固定为三层:
1. 始终存在 `defaultDeck`,作为兜底方案
2. 可选开启“文件级自定义牌组”
3. 可选开启一种高级文件夹映射模式
最终优先级固定为:
`文件级显式 deck > 文件夹映射 deck > defaultDeck`
本轮按最新确认的决策执行:
- 旧卡 deck 行为:执行命令时按最新代码规则执行
- YAML key与用户输入的识别名一致默认值为 `TARGET DECK`
- 默认模板:`obsidian::filename`
- 模板变量:只支持裸变量 `filename`
## 需求描述
### 1. 默认模式:只使用 defaultDeck
- 插件始终有 `defaultDeck`
- 在其它 deck 功能都未开启时,所有卡都进入 `defaultDeck`
- 这是最简单的兜底方案
### 2. 基本选项:开启文件级自定义牌组
开启后,设置页提供以下配置:
- `牌组识别名`
- 默认值:`TARGET DECK`
- 同时作用于 YAML key 和正文标记
- `默认牌组模板`
- 默认值:`obsidian::filename`
- `插入位置`
- 单选:`YAML` 或 `正文`
开启后插件具备两种能力:
1. 解析已有文件级 deck 声明
2. 提供“向当前文件插入 deck 模板”的操作
### 3. 高级选项:开启文件夹映射
高级选项是互斥单选,固定三态:
- `关闭`
- `文件夹级映射`
- `文件夹及文件名级映射`
语义固定为:
- `关闭`
- 不启用文件夹映射
- `文件夹级映射`
- 例:`数学/第一章/第一节.md` -> `数学::第一章`
- `文件夹及文件名级映射`
- 例:`数学/第一章/第一节.md` -> `数学::第一章::第一节`
设置页必须显示这两种模式的说明和示例。
## Key Changes
### 配置与设置页
新增或调整 `PluginSettings`
- `defaultDeck: string`
- `fileDeckEnabled: boolean`
- `fileDeckMarker: string`
- 默认 `TARGET DECK`
- `fileDeckTemplate: string`
- 默认 `obsidian::filename`
- `fileDeckInsertLocation: "yaml" | "body"`
- `folderDeckMode: "off" | "folder" | "folder-and-file"`
`defaultDeck` 继续必填。
设置页固定展示四块内容:
1. `Default Deck`
- 保留现有输入框
- 继续作为必填兜底 deck
2. `文件级自定义牌组`
- 开关
- 识别名输入框
- 默认牌组模板输入框
- 插入位置单选
- “向当前文件插入 deck 模板”的操作入口
3. `高级:文件夹映射`
- 单选:关闭 / 文件夹级映射 / 文件夹及文件名级映射
- 带说明文案和示例
4. `最终优先级说明`
- 明文显示:文件级 > 文件夹映射 > 默认 deck
### 文件级 deck 解析规则
文件级显式 deck 支持两类来源:
- YAML
- 正文
两类来源统一使用用户设置的 `fileDeckMarker` 作为识别名。默认情况下:
- YAML key`TARGET DECK`
- 正文标记:`TARGET DECK`
正文支持两种语法:
```text
TARGET DECK
数学::第一章
```
```text
TARGET DECK: 数学::第一章
```
YAML 在默认情况下写作:
```yaml
TARGET DECK: 数学::第一章
```
规则固定为:
- YAML key 与正文 marker 使用同一个用户配置值
- 正文只取第一个合法声明
- fenced code block 内的正文 deck 声明必须忽略
- YAML 与正文同时存在且不同:
- 继续同步
- 以 YAML 为准
- 产出结构化 warning
### 模板插入规则
新增“向当前文件插入 deck 模板”的操作。
模板规则固定:
- 默认模板:`obsidian::filename`
- 第一版只支持一个变量:`filename`
- `filename` 展开为当前文件名,不含 `.md`
插入行为固定:
- 当插入位置是 `yaml`
- 在 frontmatter 中写入 `<fileDeckMarker>: <展开后的模板值>`
- 若没有 frontmatter则创建 frontmatter
- 当插入位置是 `body`
- 在正文靠前位置插入单行声明
- 格式:`<fileDeckMarker>: <展开后的模板值>`
本轮不支持更多模板 DSL不支持 `[[filename]]`
### 文件夹映射规则
文件夹映射只在 `folderDeckMode !== "off"` 时参与解析。
- `folder`
- 仅使用父文件夹路径
- `folder-and-file`
- 使用父文件夹路径 + 当前文件名
规范化规则固定:
- `/` 转成 `::`
- trim 首尾空格
- 去掉空层级
- 结果为空则视为无效
- 文件夹名或文件名中若包含 `::`,产出 warning并放弃该映射结果
- 放弃后回退到 `defaultDeck`
### 最终解析规则
最终顺序固定:
1. 文件级显式 deck
2. 文件夹映射 deck
3. `defaultDeck`
附加规则:
- 文件级 deck 关闭时:
- 不解析 YAML
- 不解析正文
- 只走文件夹映射或默认 deck
- 文件夹映射关闭时:
- 只走文件级或默认 deck
### 同步语义
本轮按最新决策执行:旧卡按现有代码规则执行。
这意味着:
- `toCreate`
- 使用最终 `resolvedDeck`
- 若 deck 不存在,先 `ensureDecks`
- 再 `addNotes`
- `toUpdate`
- 保持当前实现行为
- deck 是否触发 update / 是否触发 `changeDeck`,按现有代码规则保留
- 本模块不额外改变旧卡 deck 迁移逻辑
- 本模块的重点是新增 deck 配置、解析与模板插入能力
### 当前主链路接入点
本轮必须基于当前 manual-sync 主链路,不落到旧的 legacy 链路:
1. `CardIndexingService`
- 提取文件级 deck 原始线索
2. `RenderConfigService` 或新增的 `DeckResolutionService`
- 解析最终 `resolvedDeck`
3. `DiffPlannerService`
- 保持当前旧卡 deck 行为,不在本模块内重写
4. `ManualSyncService`
- 汇总 warnings
5. `AnkiBatchExecutor`
- 新卡使用 `resolvedDeck`
### Warning 结构
新增结构化 warning
- `deck_conflict_yaml_body`
- `deck_multiple_body_declarations`
- `deck_invalid_folder_segment`
- `deck_fallback_default`
结果对象中必须包含:
- `warnings: DeckResolutionWarning[]`
UI 层可以额外展示 notice但 warning 不能只存在于 notice。
## Test Plan
### 设置与模板
- 默认加载时:
- `fileDeckEnabled = false`
- `fileDeckMarker = "TARGET DECK"`
- `fileDeckTemplate = "obsidian::filename"`
- `fileDeckInsertLocation = "body"`
- `folderDeckMode = "off"`
- 设置页能正确保存和回显上述字段
- 模板插入时:
- YAML 模式能写入 `<marker>: <deck>`
- 正文模式能写入 `<marker>: <deck>`
- `filename` 正确展开
### 文件级 deck 解析
- 只存在 YAML 时正确解析
- 只存在正文单行语法时正确解析
- 只存在正文双行语法时正确解析
- marker 改名后YAML 和正文都按新 marker 识别
- fenced code block 内正文声明被忽略
- 多个正文声明时只取第一个并 warning
- YAML 与正文冲突时取 YAML 并 warning
### 文件夹映射
- `folder` 模式下:
- `数学/第一章/第一节.md` -> `数学::第一章`
- `folder-and-file` 模式下:
- `数学/第一章/第一节.md` -> `数学::第一章::第一节`
- 根目录文件回退到 `defaultDeck`
- 路径片段包含 `::` 时 warning并回退到 default
### 最终解析优先级
- 文件级 deck 覆盖文件夹映射和 default
- 无文件级时,文件夹映射覆盖 default
- 文件级关闭时,只走文件夹映射或 default
- 文件夹映射关闭时,只走文件级或 default
### 同步链路
- 新卡创建使用 `resolvedDeck`
- deck 不存在时先 `ensureDecks`
- warnings 能沿链路出现在最终 sync result
- 旧卡行为不被本模块额外改变,保持当前实现回归通过
## Assumptions
- 本轮是 deck 配置与解析能力增强,不重构旧卡 deck 更新语义。
- YAML key 与正文 marker 使用同一个用户配置值。
- 模板语法只支持裸变量 `filename`
- 文件夹映射模式互斥单选。
- 不实现 folder->deck 手工映射表,不实现多模板 DSL不实现标题级 deck 覆盖。

View file

@ -0,0 +1,597 @@
# Deck 解析与同步落地计划
## 文档目的
本文档用于指导 GitHub Agent 直接实现新插件中的 deck 解析、优先级决策、同步行为与测试。
本计划只覆盖 deck 相关能力,不扩展到 note type、字段映射、模板编辑、身份迁移等其他模块。
## 实现约束
本计划必须基于当前新插件的 manual-sync 主链路实现,不允许回到旧的 legacy 同步链路。
本轮实现时,接入点固定为:
1. 文件级 deck 提取:`CardIndexingService`
2. deck 优先级解析:`RenderConfigService` 或新增的 `DeckResolutionService`
3. diff 规划:`DiffPlannerService`
4. 同步执行:`ManualSyncService` 与 `AnkiBatchExecutor`
5. 结果与 warning 汇总manual-sync 结果对象
本轮不要修改或重新启用以下旧链路作为主实现入口:
1. `CardDraft / Card` 旧实体链路
2. `ScanAndPlanSyncUseCase`
3. `ExecuteSyncPlanUseCase`
如果实现时发现旧链路中也有 deck 逻辑,只允许最小限度保持兼容,不允许把本轮 deck 需求主要落在旧链路里。
## 一、范围
### 本轮要实现
1. 全局默认 deck作为兜底值
2. 文件夹路径映射为 Anki 多级父子牌组
3. 文件级显式 deck支持 YAML 与正文 `TARGET DECK`
4. deck 解析优先级与冲突处理
5. deck 规范化与合法性校验
6. deck 结果进入同步链路
7. deck 创建能力
8. 相关单元测试与集成测试
### 本轮不实现
1. folder -> tag 映射
2. 同文件多 deck 分配
3. 标题级 deck 覆盖
4. deck 模板 DSL
5. deck 自动迁移 UI
6. deck 历史兼容迁移工具
## 二、最终规则
### 2.1 deck 优先级
最终解析优先级固定为:
1. 文件级显式 deck
2. 文件夹映射 deck
3. 全局默认 deck
只要上层解析成功,下层不再参与。
### 2.2 文件级显式 deck 来源
支持两种来源。
#### YAML
建议字段名固定为:
```yaml
targetDeck: 数学::第一章
```
#### 正文
支持两种语法:
```text
TARGET DECK
数学::第一章
```
```text
TARGET DECK: 数学::第一章
```
### 2.3 文件级冲突处理
如果 YAML 与正文同时存在 deck 配置:
1. 两者相同,正常通过
2. 两者不同,视为冲突
冲突时的规则固定为:
1. 报出明确错误或 notice
2. 同步该文件时,先按 YAML 的值建立 deck 并继续作为最终 deck
3. 输出冲突告警,提示用户清理正文或 YAML 中的重复配置
也就是说:
- 结果上YAML 优先
- 体验上,要明确提示冲突
### 2.4 文件夹映射规则
#### 路径到 deck 的映射
Obsidian 文件路径示例:
`课程/数学/第一章/导数.md`
对应的 Anki deck
`课程::数学::第一章`
规则是:
1. 取文件所在文件夹路径
2. 用文件夹层级生成 deck
3. 路径分隔符统一转换成 `::`
4. 文件名本身不进入 deck
#### 根目录文件
如果文件直接位于 vault 根目录,没有任何父文件夹层级,则文件夹映射结果为空。
此时直接回退到全局默认 deck。
### 2.5 文件夹名中的 `::`
第一版规则固定为:
- 文件夹映射模式下,源文件夹名里若包含 `::`,直接报错
- 不自动转义
- 不自动替换
- 不静默继续
原因是 `::` 在 Anki 中就是子牌组语义,混入原始文件夹名会产生歧义。
### 2.6 deck 规范化
无论 deck 来自 YAML、正文还是文件夹映射都必须先做规范化。
规范化规则固定为:
1. trim 首尾空格
2. 连续的空层级去掉
3. 清理首尾 `:` 与空片段
4. 文件夹路径分隔符统一转成 `::`
5. 结果为空时视为无效 deck
6. 最终统一输出标准 deck 字符串
示例:
`数学/第一章/ `
规范化后:
`数学::第一章`
## 三、同步语义
### 3.1 deck 是否参与 update
本轮明确规则:
- deck 会参与新增卡片时的创建
- deck 不参与旧卡的自动移动
也就是:
1. 新卡 add 时写入当前解析出的 deck
2. 已存在 note 的 update 不因为 deck 变化而自动 changeDeck
3. 文件移动、文件夹调整、默认 deck 改变、文件级 deck 改变,都不会主动把旧卡迁移到新 deck
实现级硬约束:
1. deck 变化不能单独触发已有 note 进入 `toUpdate`
2. 旧卡 update 路径中禁止因为 resolved deck 变化而执行 `changeDeck`
3. `changeDeck` 只允许用于未来单独的 deck 迁移模块,本轮主链路中不启用
4. 新卡 `toCreate` 时,必须使用本轮解析出的最终 `resolvedDeck`
### 3.2 原因
本轮按你的确认执行:
- 文件级 deck 最高
- 旧卡不做自动移动
- 先把 deck 解析逻辑与创建行为做稳
- 避免 deck 规则调整后批量移动旧卡带来不可预期后果
### 3.3 后续可扩展方向
后续如果需要,可单独新增:
- `syncDeckOnUpdate`
- `moveExistingNotesToResolvedDeck`
- 显式的 deck 迁移命令
但本轮不做。
## 四、领域与应用层设计
### 4.1 新增值对象
建议新增:
```ts
type DeckResolutionSource = "frontmatter" | "body" | "folder" | "default";
type ResolvedDeck = {
value: string;
source: DeckResolutionSource;
};
type DeckResolutionWarningCode =
| "deck_conflict_yaml_body"
| "deck_multiple_body_declarations"
| "deck_invalid_folder_segment"
| "deck_fallback_default";
type DeckResolutionWarning = {
filePath: string;
code: DeckResolutionWarningCode;
message: string;
};
```
### 4.2 新增服务
建议新增或完善:
#### DeckNormalizationService
职责:
1. 规范化 deck 字符串
2. 校验 deck 是否为空
3. 校验文件夹映射下是否含非法 `::`
#### DeckExtractionService
职责:
1. 从 YAML 读取 `targetDeck`
2. 从正文读取 `TARGET DECK`
3. 输出显式 deck 结果与冲突信息
4. 不直接决定最终优先级,只负责“显式 deck 来源提取”
#### FolderDeckMappingService
职责:
1. 根据文件所在文件夹生成 deck
2. 仅使用文件夹层级,不含文件名
3. 把 `/` 映射为 `::`
#### DeckResolutionService
职责:
1. 聚合 YAML、正文、文件夹映射、全局默认 deck
2. 按优先级输出唯一 `ResolvedDeck`
3. 在 YAML 与正文冲突时:
- 记录冲突
- 以 YAML 为准输出最终结果
4. 统一输出结构化 warnings
### 4.3 输入输出建议
```ts
type DeckResolutionInput = {
filePath: string;
vaultRelativeFolderPath: string | null;
frontmatterDeck?: string;
bodyDeck?: string;
defaultDeck: string;
};
type DeckResolutionResult = {
resolvedDeck: ResolvedDeck;
warnings: DeckResolutionWarning[];
};
```
## 五、配置模型
### 5.1 保留配置
```ts
type PluginSettings = {
defaultDeck: string;
includeFolders: string[];
excludeFolders: string[];
// 其他已有字段省略
};
```
### 5.2 默认 deck 继续必填
第一版继续要求 `defaultDeck` 为必填配置,不引入“允许为空”的新语义。
原因:
1. 根目录文件的文件夹映射可能为空
2. 文件级 deck 可能缺失
3. 保持当前设置校验与 UI 语义稳定
也就是说:
- 本轮不改变 `defaultDeck is required` 的现有约束
- deck 解析最终必须总能回落到一个非空默认 deck
### 5.3 不新增复杂配置
本轮不增加:
1. folder -> deck 映射表
2. deck 模板表达式
3. deck 来源优先级自定义
4. per-card deck override 规则
## 六、解析细节
### 6.1 YAML 解析
固定读取:
```yaml
targetDeck: 数学::第一章
```
第一版不做多个 YAML 别名字段。
### 6.2 正文解析
固定支持:
```text
TARGET DECK
数学::第一章
```
```text
TARGET DECK: 数学::第一章
```
正文只取第一个合法声明。
如果出现多个正文 deck 声明,建议直接报 warning并只使用第一个。
正文解析补充约束:
1. 正文中的 `TARGET DECK` 只允许按文件级声明理解,不支持按标题块局部覆盖
2. fenced code block 内的 `TARGET DECK` 必须忽略
3. 多个正文声明不报错中断同步,但必须输出结构化 warning
### 6.3 文件夹路径提取
对于文件:
`课程/数学/第一章/导数.md`
文件夹映射输入应为:
`课程/数学/第一章`
输出:
`课程::数学::第一章`
## 七、同步执行层改动
### 7.1 新卡创建
`toAdd` 中的卡片,必须使用 `ResolvedDeck.value` 作为最终 deck。
如果 deck 不存在:
- 调用 `ensureDeckExists`
- 再执行 `addNote`
### 7.2 旧卡更新
`toUpdate` 中的卡片:
- 只更新字段
- 不自动移动 deck
补充实现要求:
1. 当前 manual-sync 链路下,旧卡 deck 变化不能仅因为 `deck` 改变而进入 `toUpdate`
2. `renderConfigHash` 不得因为 `deck` 变化而导致旧卡进入 update
3. `AnkiBatchExecutor` 的 update 路径中,不得执行基于 resolved deck 的 `changeDeck`
4. 如果当前代码中仍保留 update -> `changeDeck` 逻辑,本轮必须移除或显式短路掉
### 7.3 冲突告警
如果某文件存在 YAML 与正文 deck 冲突:
- 同步继续
- 最终使用 YAML deck
- 输出 warning 或 notice
- 结果中要能看到该告警
### 7.4 warning 传递链路
本轮不要只在底层 `console` 或临时 `Notice` 中输出 warning必须保留结构化结果便于上层汇总展示和测试。
建议传递方式固定为:
1. 文件索引阶段输出文件级 warnings
2. `ManualSyncService` 汇总本次同步范围内的所有 warnings
3. 最终同步结果对象中包含 `warnings: DeckResolutionWarning[]`
4. UI 层可再决定是否额外转成 notice
也就是说:
- warning 先是结构化结果
- notice 只是展示形式,不是唯一载体
### 7.5 deck 数据模型命名
为避免“提取结果”和“最终结果”混淆,本轮建议明确区分两类字段:
1. `explicitDeckHint`
表示文件显式 deck仅来自 YAML 或正文
2. `resolvedDeck`
表示最终用于同步的 deck已经过优先级决策
如果实现时不想大改现有类型命名,至少也要在代码注释和测试命名中保持这个语义区分,避免把 folder deck/default deck 也混进 `deckHint` 的含义里。
## 八、UI 与用户提示
### 8.1 设置页
至少要有:
1. `defaultDeck` 输入框
2. 说明文案,明确 deck 优先级:
- 文件级 deck
- 文件夹映射 deck
- 全局默认 deck
### 8.2 用户提示文案建议
#### 文件级冲突
`检测到同一文件同时在 YAML 和正文中声明了不同的 TARGET DECK本次已按 YAML 值同步,请清理冲突配置。`
#### 非法文件夹名
`检测到文件夹名包含 ::,无法安全映射为 Anki 子牌组,请修改文件夹名。`
#### fallback 到默认 deck
`该文件没有显式 deck且所在位置无法生成文件夹牌组已回退到默认 deck。`
## 九、测试计划
### 9.1 DeckNormalizationService
覆盖:
1. trim 首尾空格
2. `/``::`
3. 清理空层级
4. 空 deck 报错
5. 文件夹映射模式下 `::` 非法报错
### 9.2 DeckExtractionService
覆盖:
1. 只存在 YAML
2. 只存在正文单行语法
3. 只存在正文双行语法
4. YAML 与正文相同
5. YAML 与正文不同
6. 正文多个 TARGET DECK只取第一个并 warning
### 9.3 FolderDeckMappingService
覆盖:
1. 多级文件夹映射
2. 单级文件夹映射
3. 根目录文件返回空
4. 文件夹名带 `::` 报错
### 9.4 DeckResolutionService
覆盖:
1. 文件级 deck 覆盖文件夹 deck
2. 文件夹 deck 覆盖默认 deck
3. 根目录文件回退默认 deck
4. YAML 与正文冲突时:
- 结果取 YAML
- warning 正确输出
### 9.5 manual-sync 链路回归测试
覆盖:
1. 新卡使用 `resolvedDeck` 创建
2. 旧卡仅 deck 变化时,不进入 `toUpdate`
3. 旧卡仅 deck 变化时,不触发 `changeDeck`
4. 旧卡正文变化且 deck 不变时,正常 update
5. YAML 与正文冲突时warning 能沿链路出现在最终 sync result 中
6. 根目录文件无显式 deck 时,回退到必填的 `defaultDeck`
### 9.6 同步用例
覆盖:
1. 新卡使用解析出的 deck 创建
2. 缺失 deck 时使用默认 deck
3. deck 不存在时先创建 deck 再 add
4. 旧卡 update 不自动 changeDeck
5. 文件移动后,旧卡仍不自动迁移 deck
6. 文件级 deck 改变后,旧卡仍不自动迁移 deck
## 十、GitHub Agent 实施顺序
### 阶段 1审查当前实现
检查:
1. 当前默认 deck 的保存位置
2. 当前 `TARGET DECK` 的解析逻辑
3. 当前同步 add / update 是否已经区分 deck 行为
4. 当前是否存在 folder deck 映射雏形
输出:
`docs/deck-resolution-gap-report.md`
### 阶段 2锁定决策
写入:
`docs/deck-resolution-decisions.md`
至少写清:
1. 优先级
2. YAML 冲突处理
3. 文件夹映射规则
4. 根目录 fallback
5. update 不自动移动 deck
### 阶段 3实现服务层
新增或修改:
1. `DeckNormalizationService`
2. `DeckExtractionService`
3. `FolderDeckMappingService`
4. `DeckResolutionService`
### 阶段 4接入同步链路
修改:
1. `CardIndexingService`
2. `RenderConfigService` 或新增 `DeckResolutionService`
3. `DiffPlannerService`
4. `ManualSyncService`
5. `AnkiBatchExecutor`
6. `AnkiGateway.ensureDecks(...)` / `ensureDeckExists(...)`
不要把主实现落到旧的 `ScanAndPlanSyncUseCase / ExecuteSyncPlanUseCase` 链路里。
### 阶段 5补测试与验证
执行:
```bash
npm test
npm run build
npm run lint
```
## 十一、最终成功标准
满足以下条件即视为完成:
1. 文件级显式 deck 生效
2. 文件夹可自动映射为 Anki 多级父子牌组
3. 根目录文件正确回退到默认 deck
4. YAML 与正文冲突时YAML 生效且有告警
5. 旧卡 update 不自动移动 deck
6. 所有关键路径都有测试覆盖
7. build、test、lint 通过

View file

@ -0,0 +1,62 @@
# Deck Resolution Decisions
## 1. 实现入口
本轮继续以 manual-sync 主链路为实现入口:
1. `CardIndexingService` 负责文件级显式 deck 提取
2. `RenderConfigService` 通过新的 deck resolution 服务解析最终 deck
3. `DiffPlannerService` 聚合结构化 warnings并保持 update 判定不受 deck 变化影响
4. `AnkiBatchExecutor` 只在 add 路径使用 resolved deckupdate 路径不做 changeDeck
5. `ManualSyncService` 将 warnings 放入最终 `ManualSyncResult`
## 2. 数据语义
1. manual-sync 里的 `deckHint` 继续表示“文件级显式 deck hint”不混入 folder / default 结果
2. 最终用于同步的 deck 只存在于 `RenderPlan.deck` / `PlannedCard.deck` / `RenderedSyncCard.deck`
3. 文件级 deck warnings 暂挂在 indexed card / state 上,最终在计划阶段去重汇总
## 3. 服务拆分
新增 4 个 deck 相关服务:
1. `DeckNormalizationService`
2. `DeckExtractionService`
3. `FolderDeckMappingService`
4. `DeckResolutionService`
其中:
1. `DeckExtractionService` 负责 YAML / 正文显式 deck 的提取与冲突 warning
2. `DeckResolutionService` 负责优先级决策:显式 deck > folder deck > default deck
## 4. 同步语义
1. 新卡 add 必须使用 resolved deck
2. 旧卡 update 只更新字段,不 changeDeck
3. `renderConfigHash` 改为不包含 deck
4. 为避免旧状态产生无意义 update保留一个仅用于兼容比较的 legacy hash 分支
## 5. 校验与错误
1. deck normalization 输出空字符串时直接报错
2. folder mapping 命中包含 `::` 的文件夹名时按计划走硬失败,不静默回退
3. YAML / 正文冲突不阻断同步,按 YAML 为准并产生 warning
4. 多个正文声明不阻断同步,只取第一个并产生 warning
## 6. 结果透传
`ManualSyncResult` 新增结构化 `warnings`,用于:
1. deck 冲突
2. multiple body declarations
3. fallback 到 default deck
UI 层可以再把这些 warning 转成 notice但 notice 不是唯一载体。
## 7. UI 最小改动
设置页只做最小文案修正:
1. 默认 deck 说明补充优先级语义
2. 不新增复杂配置项

View file

@ -0,0 +1,61 @@
# Deck Resolution Gap Report
## 审查范围
本报告仅审查 manual-sync 主链路中的 deck 相关实现:
1. `CardIndexingService`
2. `RenderConfigService`
3. `DiffPlannerService`
4. `ManualSyncService`
5. `AnkiBatchExecutor`
不把旧的 legacy 同步链路作为本轮主实现入口。
## 当前实现结论
### 已有能力
1. 设置中已有 `defaultDeck`
2. manual-sync 链路里已有 `deckHint -> RenderConfigService -> plannedCard.deck`
3. add 路径已会在批量执行前调用 `ensureDecks(...)`
### 与计划不一致的核心缺口
1. `CardIndexingService` 只支持正文单行 `TARGET DECK: ...`
2. 当前不支持 YAML `targetDeck`
3. 当前不支持正文双行 `TARGET DECK` 声明
4. 当前不处理 YAML / 正文冲突与 multiple body declarations
5. 当前没有 folder -> deck 映射
6. 当前没有独立的 deck 规范化与合法性校验
7. 当前 `RenderConfigService` 只做 `deckHint || defaultDeck`
8. 当前 `renderConfigHash` 包含 `deck`,会让 deck 变化进入 update 判定
9. 当前 `AnkiBatchExecutor` 在 update 后会调用 `changeDecks(...)`
10. 当前 manual-sync 结果对象没有结构化 deck warnings
11. 当前测试没有覆盖计划要求的大部分 deck 规则
## 架构问题
1. deck 解析逻辑被分散成“正文提取 + 默认兜底”,没有独立服务封装优先级与校验
2. warning 没有通过 manual-sync 结果对象向上汇总
3. update 路径把“字段更新”和“迁移 deck”耦合在一起违背计划中的同步语义
## 本轮修复边界
本轮只补以下能力:
1. 显式 deck 提取YAML + 正文两种语法)
2. folder deck 映射
3. default deck fallback
4. deck normalization / validation
5. YAML / 正文冲突 warning
6. warning 沿 manual-sync 结果链路透传
7. add 使用 resolved deckupdate 禁止自动 changeDeck
8. 对应单元测试与 manual-sync 回归测试
本轮不扩展到:
1. 旧链路回归改造
2. 标题级 deck override
3. deck 迁移命令
4. 自定义优先级与模板 DSL

View file

@ -0,0 +1,77 @@
# Module 5 Decisions
## 1. 配置模型
新增并持久化以下字段:
1. `fileDeckEnabled`
2. `fileDeckMarker`
3. `fileDeckTemplate`
4. `fileDeckInsertLocation`
5. `folderDeckMode`
默认值严格按计划:
1. `fileDeckEnabled = false`
2. `fileDeckMarker = "TARGET DECK"`
3. `fileDeckTemplate = "obsidian::filename"`
4. `fileDeckInsertLocation = "body"`
5. `folderDeckMode = "off"`
## 2. 文件级 deck 解析
`DeckExtractionService` 改为接收 `fileDeckMarker` 参数,而不是写死:
1. YAML key 使用同一个 marker
2. 正文单行 / 双行语法也使用同一个 marker
3. 当 `fileDeckEnabled = false` 时,索引阶段完全跳过显式 deck 提取
## 3. 文件夹映射
`FolderDeckMappingService` 改为显式接收 `folderDeckMode`
1. `off` 不参与映射
2. `folder` 使用父文件夹
3. `folder-and-file` 使用父文件夹 + 当前文件名
非法路径片段包含 `::` 时:
1. 不抛错中断
2. 产出 `deck_invalid_folder_segment` warning
3. 丢弃映射结果
4. 继续回退到 `defaultDeck`
## 4. 模板插入能力
新增单独的 deck 模板插入服务:
1. 负责 `filename` 变量展开
2. 负责 YAML / 正文两种插入模式
3. 负责在已有 YAML key 或正文 deck 声明存在时进行就地替换,避免重复声明
该能力通过设置页按钮触发,目标固定为当前活动 Markdown 文件。
## 5. manual-sync 接入
继续沿用当前 manual-sync 主链路:
1. `CardIndexingService` 只提取“文件级显式 deck 线索”
2. `RenderConfigService` 调用 `DeckResolutionService` 解析最终 deck
3. `DiffPlannerService` 保持当前旧卡行为,不额外改写 update / changeDeck 语义
4. `AnkiBatchExecutor` 继续让 `toCreate` 使用 `resolvedDeck`
## 6. warnings
继续使用结构化 warning 结果对象,不把 notice 当作唯一载体。
模块 5 需要继续保证:
1. `deck_conflict_yaml_body`
2. `deck_multiple_body_declarations`
3. `deck_invalid_folder_segment`
4. `deck_fallback_default`
## 7. UI 选择
为保持改动聚焦,本轮设置页的“单选”使用 dropdown 实现,而不是新增自定义 radio 组件。
语义仍然是互斥单选,不扩展复杂交互。

114
docs/module-5-gap-report.md Normal file
View file

@ -0,0 +1,114 @@
# Module 5 Gap Report
## 审查范围
本报告只审查与模块 5 直接相关的实现:
1. deck 配置模型
2. 设置页 deck UI
3. 文件级 deck 解析
4. 文件夹 deck 映射
5. manual-sync 主链路中的最终 deck 解析
6. warning 透传
7. deck 模板插入能力
## 当前实现与模块 5 计划的主要差距
### 1. 配置模型缺字段
当前 `PluginSettings` 只保留了:
1. `defaultDeck`
2. 范围过滤配置
3. 其他同步与字段映射配置
缺少模块 5 需要的:
1. `fileDeckEnabled`
2. `fileDeckMarker`
3. `fileDeckTemplate`
4. `fileDeckInsertLocation`
5. `folderDeckMode`
### 2. 设置页未实现“显式 deck 模式选择”
当前设置页只有 `Default deck` 文案增强,但没有:
1. 文件级自定义牌组开关
2. 识别名输入
3. 默认模板输入
4. 插入位置单选
5. 向当前文件插入 deck 模板入口
6. 文件夹映射三态选择与示例说明
7. 最终优先级说明区块
### 3. 文件级 deck 解析仍然写死
当前 deck 提取实现仍然固定写死:
1. YAML key 固定 `targetDeck`
2. 正文 marker 固定 `TARGET DECK`
这与模块 5 要求的“YAML key 与正文 marker 统一使用用户配置的 `fileDeckMarker`”不一致。
### 4. 文件级 deck 没有按开关启停
当前 deck 解析总是启用。
模块 5 要求:
1. `fileDeckEnabled = false` 时不解析 YAML
2. `fileDeckEnabled = false` 时不解析正文
### 5. 文件夹映射模式不完整
当前只有一种隐式文件夹映射,等价于“父文件夹映射”。
缺少:
1. `off`
2. `folder`
3. `folder-and-file`
同时当前非法路径片段处理是抛错硬失败,而模块 5 要求:
1. 产出 `deck_invalid_folder_segment` warning
2. 放弃映射
3. 回退到 `defaultDeck`
### 6. 没有 deck 模板插入能力
当前仓库不存在“向当前文件插入 deck 模板”的能力,也没有相关命令或设置页操作入口。
### 7. 同步链路已有 warning 透传,但未覆盖模块 5 的新模式行为
当前 warning 结果对象已存在,但:
1. 还没有 fileDeck 开关影响解析的测试
2. 还没有 marker 改名后的 YAML / 正文解析测试
3. 还没有 `folder-and-file` 模式测试
4. 还没有 invalid folder segment 回退 default 的 warning 测试
5. 还没有 deck 模板插入测试
## 兼容与边界判断
模块 5 明确要求:旧卡行为保持当前实现,不在本模块内改写旧卡 deck update 语义。
因此本轮不能回退到更早的 deck 迁移语义,也不能新增新的 old-card deck migration 逻辑。
## 本轮修复边界
本轮只补齐模块 5 明确要求:
1. deck 模式配置
2. deck 解析按配置启停
3. 可配置 marker
4. 文件夹映射三态
5. deck 模板插入
6. warning 继续沿 manual-sync 结果透传
7. 与当前 old-card 行为兼容的回归测试
不扩展到:
1. deck 迁移模块
2. 模板 DSL 扩展
3. 标题级 deck override
4. 手工 folder -> deck 映射表

View file

@ -25,4 +25,28 @@ describe("PluginSettings", () => {
}),
).toThrow("Scope mode must be one of all, include, or exclude.");
});
it("includes module 5 defaults", () => {
expect(DEFAULT_SETTINGS.fileDeckEnabled).toBe(false);
expect(DEFAULT_SETTINGS.fileDeckMarker).toBe("TARGET DECK");
expect(DEFAULT_SETTINGS.fileDeckTemplate).toBe("obsidian::filename");
expect(DEFAULT_SETTINGS.fileDeckInsertLocation).toBe("body");
expect(DEFAULT_SETTINGS.folderDeckMode).toBe("off");
});
it("rejects invalid module 5 enum values", () => {
expect(() =>
validatePluginSettings({
...DEFAULT_SETTINGS,
fileDeckInsertLocation: "middle" as never,
}),
).toThrow("File deck insert location must be yaml or body.");
expect(() =>
validatePluginSettings({
...DEFAULT_SETTINGS,
folderDeckMode: "tree" as never,
}),
).toThrow("Folder deck mode must be off, folder, or folder-and-file.");
});
});

View file

@ -1,6 +1,8 @@
import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
export type ScopeMode = "all" | "include" | "exclude";
export type FileDeckInsertLocation = "yaml" | "body";
export type FolderDeckMode = "off" | "folder" | "folder-and-file";
export interface PluginSettings {
qaHeadingLevel: number;
@ -9,6 +11,11 @@ export interface PluginSettings {
clozeNoteType: string;
noteFieldMappings: Record<string, NoteModelFieldMapping>;
defaultDeck: string;
fileDeckEnabled: boolean;
fileDeckMarker: string;
fileDeckTemplate: string;
fileDeckInsertLocation: FileDeckInsertLocation;
folderDeckMode: FolderDeckMode;
scopeMode: ScopeMode;
includeFolders: string[];
excludeFolders: string[];
@ -24,6 +31,11 @@ export const DEFAULT_SETTINGS: PluginSettings = {
clozeNoteType: "Cloze",
noteFieldMappings: {},
defaultDeck: "Obsidian",
fileDeckEnabled: false,
fileDeckMarker: "TARGET DECK",
fileDeckTemplate: "obsidian::filename",
fileDeckInsertLocation: "body",
folderDeckMode: "off",
scopeMode: "all",
includeFolders: [],
excludeFolders: [],
@ -59,6 +71,26 @@ export function validatePluginSettings(settings: PluginSettings): void {
throw new Error("Default deck is required.");
}
if (typeof settings.fileDeckEnabled !== "boolean") {
throw new Error("File deck enabled must be a boolean.");
}
if (typeof settings.fileDeckMarker !== "string") {
throw new Error("File deck marker must be a string.");
}
if (typeof settings.fileDeckTemplate !== "string") {
throw new Error("File deck template must be a string.");
}
if (settings.fileDeckInsertLocation !== "yaml" && settings.fileDeckInsertLocation !== "body") {
throw new Error("File deck insert location must be yaml or body.");
}
if (settings.folderDeckMode !== "off" && settings.folderDeckMode !== "folder" && settings.folderDeckMode !== "folder-and-file") {
throw new Error("Folder deck mode must be off, folder, or folder-and-file.");
}
if (settings.scopeMode !== "all" && settings.scopeMode !== "include" && settings.scopeMode !== "exclude") {
throw new Error("Scope mode must be one of all, include, or exclude.");
}

View file

@ -41,6 +41,7 @@ describe("AnkiBatchExecutor", () => {
toRewriteMarker: [],
toOrphan: [],
unchangedCards: 0,
warnings: [],
};
const result = await executor.execute(
@ -54,13 +55,49 @@ describe("AnkiBatchExecutor", () => {
expect(result.updated).toBe(1);
expect(ankiGateway.getModelDetailsCalls).toEqual(["Basic"]);
});
it("ensures decks for adds and does not move existing notes to another deck on update", async () => {
const ankiGateway = new CountingAnkiGateway();
ankiGateway.noteSummariesById.set(300, {
noteId: 300,
modelName: "Basic",
cardIds: [700],
});
const executor = new AnkiBatchExecutor(ankiGateway);
const createCard = createPlannedCard("ahs_create", undefined, "Folder::Deck");
const updateCard = createPlannedCard("ahs_update", 300, "Changed::Deck");
const renderedCards = new Map<string, RenderedSyncCard>([
[createCard.card.cardId, createRenderedSyncCard(createCard)],
[updateCard.card.cardId, createRenderedSyncCard(updateCard)],
]);
await executor.execute(
{
toCreate: [createCard],
toUpdate: [updateCard],
toRewriteMarker: [],
toOrphan: [],
unchangedCards: 0,
warnings: [],
},
renderedCards,
async (plannedCard) => createRenderedSyncCard(plannedCard),
createModule3Settings().noteFieldMappings,
);
expect(ankiGateway.ensuredDecks).toEqual([["Folder::Deck"]]);
expect(ankiGateway.changedDecks).toEqual([]);
expect(ankiGateway.addedNotes[0]?.deckName).toBe("Folder::Deck");
expect(ankiGateway.updatedNotes[0]?.deckName).toBe("Changed::Deck");
});
});
function createPlannedCard(cardId: string, noteId?: number): PlannedCard {
function createPlannedCard(cardId: string, noteId?: number, deck = "Obsidian"): PlannedCard {
return {
card: createIndexedCard(cardId, noteId),
noteId,
deck: "Obsidian",
deck,
noteModel: "Basic",
renderConfigHash: "render-config",
};
@ -99,6 +136,7 @@ function createIndexedCard(cardId: string, noteId?: number): IndexedCard {
contentEndLine: 2,
rawBlockText: `#### Heading ${cardId}\nBody ${cardId}`,
rawBlockHash: `hash-${cardId}`,
deckWarnings: [],
tagsHint: [],
markerState: noteId ? "card-and-note" : "card-only",
};

View file

@ -72,7 +72,7 @@ export class AnkiBatchExecutor {
}
await this.batchScheduler.runVoidBatches(
Array.from(new Set([...addQueue, ...updateQueue.map((entry) => entry.renderedCard)].map((card) => card.deck))),
Array.from(new Set(addQueue.map((card) => card.deck))),
50,
1,
(batch) => this.ankiGateway.ensureDecks(batch),
@ -128,30 +128,6 @@ export class AnkiBatchExecutor {
}
}
const deckChanges = new Map<string, number[]>();
for (const { plannedCard } of updateQueue) {
const noteId = plannedCard.noteId;
if (!noteId) {
continue;
}
const summary = noteSummariesById.get(noteId);
if (!summary || summary.cardIds.length === 0) {
continue;
}
const cardIds = deckChanges.get(plannedCard.deck) ?? [];
cardIds.push(...summary.cardIds);
deckChanges.set(plannedCard.deck, cardIds);
}
await this.batchScheduler.runVoidBatches(
Array.from(deckChanges.entries()).map(([deckName, cardIds]) => ({ deckName, cardIds })),
25,
1,
(batch) => this.ankiGateway.changeDecks(batch),
);
return {
created,
updated,

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { DeckTemplateInsertionService } from "./DeckTemplateInsertionService";
describe("DeckTemplateInsertionService", () => {
it("creates YAML frontmatter and expands filename in the template", () => {
const service = new DeckTemplateInsertionService();
const result = service.insert(
{
path: "数学/第一章/第一节.md",
basename: "第一节",
content: ["#### Prompt", "Answer"].join("\n"),
},
"TARGET DECK",
"obsidian::filename",
"yaml",
);
expect(result.insertedDeck).toBe("obsidian::第一节");
expect(result.nextContent).toBe([
"---",
"TARGET DECK: obsidian::第一节",
"---",
"",
"#### Prompt",
"Answer",
].join("\n"));
});
it("replaces an existing YAML marker instead of duplicating it", () => {
const service = new DeckTemplateInsertionService();
const result = service.insert(
{
path: "notes/example.md",
basename: "example",
content: [
"---",
"TARGET DECK: old::deck",
"alias: test",
"---",
"",
"#### Prompt",
"Answer",
].join("\n"),
},
"TARGET DECK",
"vault::filename",
"yaml",
);
expect(result.nextContent).toContain("TARGET DECK: vault::example");
expect(result.nextContent.match(/TARGET DECK:/g)).toHaveLength(1);
});
it("writes the body declaration near the top of the body", () => {
const service = new DeckTemplateInsertionService();
const result = service.insert(
{
path: "notes/example.md",
basename: "example",
content: [
"---",
"tags: [test]",
"---",
"",
"#### Prompt",
"Answer",
].join("\n"),
},
"MY DECK",
"vault::filename",
"body",
);
expect(result.nextContent).toBe([
"---",
"tags: [test]",
"---",
"",
"MY DECK: vault::example",
"",
"#### Prompt",
"Answer",
].join("\n"));
});
});

View file

@ -0,0 +1,163 @@
import type { FileDeckInsertLocation } from "@/application/config/PluginSettings";
import type { SourceFile } from "@/domain/card/entities/SourceFile";
import { DeckNormalizationService } from "@/domain/manual-sync/services/DeckNormalizationService";
export interface DeckTemplateInsertionResult {
expectedContent: string;
nextContent: string;
insertedDeck: string;
}
export class DeckTemplateInsertionService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
insert(sourceFile: SourceFile, marker: string, template: string, location: FileDeckInsertLocation): DeckTemplateInsertionResult {
const insertedDeck = this.expandTemplate(template, sourceFile.basename);
const nextContent = location === "yaml"
? upsertFrontmatterDeck(sourceFile.content, marker, insertedDeck)
: upsertBodyDeck(sourceFile.content, marker, insertedDeck);
return {
expectedContent: sourceFile.content,
nextContent,
insertedDeck,
};
}
private expandTemplate(template: string, basename: string): string {
return this.deckNormalizationService.normalize(template.replace(/\bfilename\b/g, basename));
}
}
function upsertFrontmatterDeck(content: string, marker: string, deck: string): string {
const lines = content.split(/\r?\n/);
const frontmatterRange = findFrontmatterRange(lines);
const yamlLine = `${marker}: ${deck}`;
if (!frontmatterRange) {
return ["---", yamlLine, "---", "", ...lines].join("\n");
}
const nextLines = [...lines];
const markerRegexp = createMarkerKeyRegExp(marker);
const frontmatterStart = frontmatterRange.start + 1;
const frontmatterEnd = frontmatterRange.end;
const existingIndex = nextLines.slice(frontmatterStart, frontmatterEnd).findIndex((line) => markerRegexp.test(line));
if (existingIndex >= 0) {
nextLines[frontmatterStart + existingIndex] = yamlLine;
return nextLines.join("\n");
}
nextLines.splice(frontmatterEnd, 0, yamlLine);
return nextLines.join("\n");
}
function upsertBodyDeck(content: string, marker: string, deck: string): string {
const lines = content.split(/\r?\n/);
const frontmatterRange = findFrontmatterRange(lines);
const bodyStart = frontmatterRange ? frontmatterRange.end + 1 : 0;
const prefixLines = lines.slice(0, bodyStart);
const bodyLines = lines.slice(bodyStart);
const declarationRange = findBodyDeclarationRange(bodyLines, marker);
const inlineLine = `${marker}: ${deck}`;
const nextBodyLines = [...bodyLines];
if (declarationRange) {
nextBodyLines.splice(declarationRange.start, declarationRange.end - declarationRange.start + 1, inlineLine);
return [...prefixLines, ...nextBodyLines].join("\n");
}
const trimmedBodyLines = trimLeadingBlankLines(bodyLines);
const insertedBodyLines = trimmedBodyLines.length > 0 ? [inlineLine, "", ...trimmedBodyLines] : [inlineLine];
if (frontmatterRange) {
return [...prefixLines, "", ...insertedBodyLines].join("\n");
}
return insertedBodyLines.join("\n");
}
function findFrontmatterRange(lines: string[]): { start: number; end: number } | undefined {
if (lines[0]?.trim() !== "---") {
return undefined;
}
for (let index = 1; index < lines.length; index += 1) {
if (lines[index]?.trim() === "---" || lines[index]?.trim() === "...") {
return { start: 0, end: index };
}
}
return undefined;
}
function findBodyDeclarationRange(lines: string[], marker: string): { start: number; end: number } | undefined {
const inlineRegexp = new RegExp(`^\\s*${escapeRegExp(marker)}\\s*:\\s*(.+?)\\s*$`);
const blockRegexp = new RegExp(`^\\s*${escapeRegExp(marker)}\\s*$`);
let fenceMarker: string | null = null;
for (let index = 0; index < lines.length; index += 1) {
const trimmed = lines[index]?.trim() ?? "";
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
fenceMarker = fenceMarker ? null : trimmed.slice(0, 3);
continue;
}
if (fenceMarker) {
continue;
}
if (inlineRegexp.test(lines[index] ?? "")) {
return { start: index, end: index };
}
if (!blockRegexp.test(lines[index] ?? "")) {
continue;
}
const nextValueIndex = findNextBodyValueIndex(lines, index + 1);
return {
start: index,
end: nextValueIndex ?? index,
};
}
return undefined;
}
function findNextBodyValueIndex(lines: string[], startIndex: number): number | undefined {
for (let index = startIndex; index < lines.length; index += 1) {
const trimmed = lines[index]?.trim() ?? "";
if (!trimmed) {
continue;
}
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
return undefined;
}
return index;
}
return undefined;
}
function trimLeadingBlankLines(lines: string[]): string[] {
let startIndex = 0;
while (startIndex < lines.length && !(lines[startIndex] ?? "").trim()) {
startIndex += 1;
}
return lines.slice(startIndex);
}
function createMarkerKeyRegExp(marker: string): RegExp {
return new RegExp(`^\\s*${escapeRegExp(marker)}\\s*:`);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -58,6 +58,7 @@ describe("FileIndexerService", () => {
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "Obsidian",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,

View file

@ -54,6 +54,8 @@ export class FileIndexerService {
fileStamp,
knownCards: stateIndex.cardsByFilePath.get(filePath) ?? [],
pendingWriteBack: stateIndex.pendingByFilePath.get(filePath) ?? [],
fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker,
});
return {
@ -121,6 +123,8 @@ export class FileIndexerService {
fileStamp,
knownCards,
pendingWriteBack,
fileDeckEnabled: settings.fileDeckEnabled,
fileDeckMarker: settings.fileDeckMarker,
});
indexedFiles.push(indexedFile);
@ -188,6 +192,8 @@ function restoreIndexedCard(card: CardState): IndexedCard {
rawBlockText: card.rawBlockText,
rawBlockHash: card.rawBlockHash,
deckHint: card.deckHint,
deckHintSource: card.deckHintSource,
deckWarnings: [...card.deckWarnings],
tagsHint: card.tagsHint,
markerState: card.noteId ? "card-and-note" : "card-only",
};

View file

@ -21,6 +21,9 @@ describe("ManualSyncService", () => {
expect(result.created).toBe(1);
expect(result.updated).toBe(0);
expect(result.warnings).toEqual([]);
expect(ankiGateway.addedNotes[0]?.deckName).toBe("notes");
expect(ankiGateway.ensuredDecks).toEqual([["notes"]]);
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001");
expect(stateRepository.savedState?.pendingWriteBack).toEqual([]);
@ -118,6 +121,99 @@ describe("ManualSyncService", () => {
).toBe(true);
});
it("uses YAML deck, emits conflict warning, and continues syncing when YAML and body deck declarations differ", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": [
"---",
"TARGET DECK: YAML/Deck",
"---",
"",
"TARGET DECK: Body::Deck",
"",
"#### 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.warnings.map((warning) => warning.code)).toEqual(["deck_conflict_yaml_body"]);
expect(ankiGateway.addedNotes[0]?.deckName).toBe("YAML::Deck");
});
it("maps folder hierarchy to nested Anki decks for new notes", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"课程/数学/第一章/导数.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);
await service.syncFile("课程/数学/第一章/导数.md", createModule3Settings());
expect(ankiGateway.addedNotes[0]?.deckName).toBe("课程::数学::第一章");
expect(ankiGateway.ensuredDecks).toEqual([["课程::数学::第一章"]]);
});
it("does not update or move an existing note when only the resolved deck changes", async () => {
const settings = createModule3Settings({ defaultDeck: "New::Deck" });
const vaultGateway = new FakeManualSyncVaultGateway({
"example.md": ["#### Prompt", "Answer", "<!-- AHS:card=ahs_known note=42 -->"].join("\n"),
});
const indexedCard = {
cardId: "ahs_known",
noteId: 42,
markerNoteId: 42,
filePath: "example.md",
cardType: "basic" as const,
heading: "Prompt",
headingLevel: 4,
bodyMarkdown: "Answer",
blockStartOffset: 0,
blockEndOffset: 16,
blockStartLine: 1,
bodyStartLine: 2,
blockEndLine: 3,
contentEndLine: 2,
markerLine: 3,
rawBlockText: ["#### Prompt", "Answer"].join("\n"),
rawBlockHash: hashString(["#### Prompt", "Answer"].join("\n")),
deckWarnings: [],
tagsHint: [],
markerState: "card-and-note" as const,
};
const renderPlan = new RenderConfigService().resolve(indexedCard, settings, ["Old::Deck"]);
const legacyRenderConfigHash = renderPlan.compatibleRenderConfigHashes.find((hash) => hash !== renderPlan.renderConfigHash) ?? renderPlan.renderConfigHash;
const stateRepository = new InMemoryPluginStateRepository({
files: {},
cards: {
ahs_known: createStoredSyncedCard(settings, {
filePath: "example.md",
deck: "Old::Deck",
renderConfigHash: legacyRenderConfigHash,
}),
},
pendingWriteBack: [],
});
const ankiGateway = new FakeManualSyncAnkiGateway();
ankiGateway.noteSummariesById.set(42, {
noteId: 42,
modelName: "Basic",
cardIds: [7001],
});
const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234);
const result = await service.syncFile("example.md", settings);
expect(result.updated).toBe(0);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(ankiGateway.changedDecks).toEqual([]);
});
it("skips current file sync when the file is outside the configured scope", async () => {
const vaultGateway = new FakeManualSyncVaultGateway({
"outside/example.md": ["#### Prompt", "Answer"].join("\n"),
@ -165,6 +261,8 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
rawBlockText,
rawBlockHash,
deckHint: overrides.deckHint,
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
markerState: "card-and-note" as const,
};
@ -190,6 +288,8 @@ function createStoredSyncedCard(settings: PluginSettings, overrides: Partial<Car
renderConfigHash: overrides.renderConfigHash ?? renderPlan.renderConfigHash,
deck: overrides.deck ?? renderPlan.deck,
deckHint: indexedCard.deckHint,
deckHintSource: indexedCard.deckHintSource,
deckWarnings: [...indexedCard.deckWarnings],
tagsHint: indexedCard.tagsHint,
lastSyncedAt: overrides.lastSyncedAt ?? 1,
orphan: overrides.orphan ?? false,

View file

@ -98,6 +98,7 @@ export class ManualSyncService {
skippedUnchangedCards: indexResult.skippedUnchangedCards,
rewrittenMarkers: writeBackResult.writtenCardIds.length,
markerWriteConflictFiles: writeBackResult.conflictFiles,
warnings: plan.warnings,
};
}
@ -161,6 +162,7 @@ export class ManualSyncService {
skippedUnchangedCards: indexResult.skippedUnchangedCards,
rewrittenMarkers: writeBackResult.writtenCardIds.length,
markerWriteConflictFiles: writeBackResult.conflictFiles,
warnings: plan.warnings,
};
}
@ -219,6 +221,8 @@ export class ManualSyncService {
renderConfigHash: renderPlan.renderConfigHash,
deck: renderPlan.deck,
deckHint: card.deckHint,
deckHintSource: card.deckHintSource,
deckWarnings: [...card.deckWarnings],
tagsHint: card.tagsHint,
lastSyncedAt: touchedCardIds.has(card.cardId) ? now : existingState?.lastSyncedAt ?? 0,
orphan: false,

View file

@ -1,33 +1,48 @@
import type { PluginSettings } from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey } from "@/application/config/NoteModelFieldMapping";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { DeckResolutionService } from "@/domain/manual-sync/services/DeckResolutionService";
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
import { hashString } from "@/domain/shared/hash";
export interface RenderPlan {
deck: string;
noteModel: string;
renderConfigHash: string;
compatibleRenderConfigHashes: string[];
warnings: DeckResolutionWarning[];
}
export class RenderConfigService {
resolve(card: IndexedCard, settings: PluginSettings): RenderPlan {
constructor(private readonly deckResolutionService = new DeckResolutionService()) {}
resolve(card: IndexedCard, settings: PluginSettings, compatibilityDecks: string[] = []): RenderPlan {
const noteModel = card.cardType === "basic" ? settings.qaNoteType : settings.clozeNoteType;
const deck = card.deckHint?.trim() || settings.defaultDeck;
const deckResolution = this.deckResolutionService.resolve(card, settings.defaultDeck, settings.folderDeckMode);
const deck = deckResolution.resolvedDeck.value;
const mapping = settings.noteFieldMappings[createNoteFieldMappingKey(card.cardType, noteModel)] ?? null;
const renderConfigPayload = {
cardType: card.cardType,
noteModel,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
};
const renderConfigHash = hashString(JSON.stringify(renderConfigPayload));
const compatibleRenderConfigHashes = Array.from(new Set([
renderConfigHash,
...compatibilityDecks.map((compatibilityDeck) => hashString(JSON.stringify({
...renderConfigPayload,
deck: compatibilityDeck,
}))),
]));
return {
deck,
noteModel,
renderConfigHash: hashString(
JSON.stringify({
cardType: card.cardType,
noteModel,
deck,
mapping,
addObsidianBacklink: settings.addObsidianBacklink,
convertHighlightsToCloze: settings.convertHighlightsToCloze,
}),
),
renderConfigHash,
compatibleRenderConfigHashes,
warnings: deckResolution.warnings,
};
}
}

View file

@ -1,3 +1,5 @@
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
export interface ManualSyncResult {
scannedFiles: number;
scannedCards: number;
@ -8,4 +10,5 @@ export interface ManualSyncResult {
skippedUnchangedCards: number;
rewrittenMarkers: number;
markerWriteConflictFiles: string[];
warnings: DeckResolutionWarning[];
}

View file

@ -1,5 +1,6 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
import type { MarkerState } from "@/domain/manual-sync/entities/AhsMarker";
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
export interface IndexedCard {
cardId: string;
@ -20,6 +21,8 @@ export interface IndexedCard {
rawBlockText: string;
rawBlockHash: string;
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
tagsHint: string[];
markerState: MarkerState;
sourceContent?: string;

View file

@ -1,4 +1,5 @@
import type { CardType } from "@/domain/card/entities/RenderedFields";
import type { DeckResolutionWarning, DeckResolutionSource } from "@/domain/manual-sync/value-objects/DeckResolution";
export interface FileState {
filePath: string;
@ -28,6 +29,8 @@ export interface CardState {
renderConfigHash: string;
deck: string;
deckHint?: string;
deckHintSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
deckWarnings: DeckResolutionWarning[];
tagsHint: string[];
lastSyncedAt: number;
orphan: boolean;

View file

@ -151,6 +151,104 @@ describe("CardIndexingService", () => {
),
).toThrow("Multiple AHS markers");
});
it("extracts YAML deck, prefers it over conflicting body deck, and stores a warning on indexed cards", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"---",
"TARGET DECK: YAML/Deck",
"---",
"",
"TARGET DECK: Body::Deck",
"",
"#### Prompt",
"Answer",
].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
fileDeckEnabled: true,
fileDeckMarker: "TARGET DECK",
},
);
expect(indexedFile.cards[0]).toMatchObject({
deckHint: "YAML::Deck",
deckHintSource: "frontmatter",
});
expect(indexedFile.cards[0]?.deckWarnings.map((warning) => warning.code)).toEqual(["deck_conflict_yaml_body"]);
});
it("extracts multiline body TARGET DECK while ignoring fenced code blocks", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"```md",
"TARGET DECK: Fake::Deck",
"```",
"",
"TARGET DECK",
"Real/Deck",
"",
"#### Prompt",
"Answer",
].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
fileDeckEnabled: true,
fileDeckMarker: "TARGET DECK",
},
);
expect(indexedFile.cards[0]).toMatchObject({
deckHint: "Real::Deck",
deckHintSource: "body",
});
});
it("skips explicit deck extraction when file-level deck mode is disabled", () => {
const service = new CardIndexingService();
const indexedFile = service.index(
{
path: "notes/example.md",
basename: "example",
content: [
"TARGET DECK: Scoped/Deck",
"",
"#### Prompt",
"Answer",
].join("\n"),
},
{
qaHeadingLevel: 4,
clozeHeadingLevel: 5,
fileStamp: "1:1",
knownCards: [],
pendingWriteBack: [],
fileDeckEnabled: false,
fileDeckMarker: "TARGET DECK",
},
);
expect(indexedFile.cards[0]?.deckHint).toBeUndefined();
expect(indexedFile.cards[0]?.deckWarnings).toEqual([]);
});
});
function createKnownCardState(overrides: Partial<CardState> = {}): CardState {
@ -176,6 +274,8 @@ function createKnownCardState(overrides: Partial<CardState> = {}): CardState {
renderConfigHash: overrides.renderConfigHash ?? "render-hash",
deck: overrides.deck ?? "Obsidian",
deckHint: overrides.deckHint,
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
lastSyncedAt: overrides.lastSyncedAt ?? 1,
orphan: overrides.orphan ?? false,

View file

@ -5,6 +5,7 @@ import type { CardState, PendingWriteBackState } from "@/domain/manual-sync/enti
import { hashString } from "@/domain/shared/hash";
import { CardMarkerError, CardMarkerService } from "./CardMarkerService";
import { DeckExtractionService } from "./DeckExtractionService";
interface HeadingMatch {
level: number;
@ -27,13 +28,16 @@ export interface CardIndexingContext {
fileStamp: string;
knownCards: CardState[];
pendingWriteBack: PendingWriteBackState[];
fileDeckEnabled?: boolean;
fileDeckMarker?: string;
}
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()) {}
constructor(
private readonly markerService = new CardMarkerService(),
private readonly deckExtractionService = new DeckExtractionService(),
) {}
index(sourceFile: SourceFile, context: CardIndexingContext): IndexedFile {
validateHeadingPolicy(context.qaHeadingLevel, context.clozeHeadingLevel);
@ -41,7 +45,9 @@ export class CardIndexingService {
const lines = sourceFile.content.split(/\r?\n/);
const lineStartOffsets = computeLineStartOffsets(sourceFile.content);
const headings = collectHeadings(lines);
const targetDeck = extractTargetDeck(lines);
const extractedDeck = context.fileDeckEnabled
? this.deckExtractionService.extract(sourceFile, context.fileDeckMarker ?? "TARGET DECK")
: { warnings: [] };
const cards: IndexedCard[] = [];
const knownCardsById = new Map(context.knownCards.map((card) => [card.cardId, card]));
const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards);
@ -93,7 +99,9 @@ export class CardIndexingService {
markerLine: marker.markerLine,
rawBlockText,
rawBlockHash,
deckHint: targetDeck,
deckHint: extractedDeck.explicitDeckHint,
deckHintSource: extractedDeck.explicitDeckSource,
deckWarnings: [...extractedDeck.warnings],
tagsHint: [],
markerState: marker.markerState,
sourceContent: sourceFile.content,
@ -214,30 +222,6 @@ function collectHeadings(lines: string[]): HeadingMatch[] {
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";

View file

@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { DeckExtractionService } from "./DeckExtractionService";
describe("DeckExtractionService", () => {
it("extracts deck from YAML frontmatter using the shared marker", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"---",
"TARGET DECK: 数学/第一章",
"---",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("数学::第一章");
expect(result.explicitDeckSource).toBe("frontmatter");
expect(result.warnings).toEqual([]);
});
it("extracts deck from body inline declaration", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"TARGET DECK: Coding/Deck",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("Coding::Deck");
expect(result.explicitDeckSource).toBe("body");
});
it("extracts deck from body multiline declaration and ignores fenced code blocks", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"```md",
"TARGET DECK: Fake::Deck",
"```",
"",
"TARGET DECK",
"Real/Deck",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("Real::Deck");
expect(result.explicitDeckSource).toBe("body");
});
it("accepts matching YAML and body declarations without warning", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"---",
"TARGET DECK: Coding/Deck",
"---",
"",
"TARGET DECK: Coding::Deck",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("Coding::Deck");
expect(result.explicitDeckSource).toBe("frontmatter");
expect(result.warnings).toEqual([]);
});
it("uses YAML deck and emits a warning when YAML and body declarations conflict", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"---",
"TARGET DECK: YAML/Deck",
"---",
"",
"TARGET DECK: Body::Deck",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("YAML::Deck");
expect(result.explicitDeckSource).toBe("frontmatter");
expect(result.warnings.map((warning) => warning.code)).toEqual(["deck_conflict_yaml_body"]);
});
it("uses the first body declaration and emits a warning when multiple body declarations exist", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"TARGET DECK: Deck/One",
"TARGET DECK",
"Deck Two",
"",
"#### Prompt",
"Answer",
]));
expect(result.explicitDeckHint).toBe("Deck::One");
expect(result.warnings.map((warning) => warning.code)).toEqual(["deck_multiple_body_declarations"]);
});
it("respects a renamed marker for both YAML and body", () => {
const service = new DeckExtractionService();
const result = service.extract(createSourceFile([
"---",
"MY DECK: YAML/Deck",
"---",
"",
"MY DECK: YAML::Deck",
"",
"#### Prompt",
"Answer",
]), "MY DECK");
expect(result.explicitDeckHint).toBe("YAML::Deck");
expect(result.explicitDeckSource).toBe("frontmatter");
expect(result.warnings).toEqual([]);
});
});
function createSourceFile(lines: string[]) {
return {
path: "notes/example.md",
basename: "example",
content: lines.join("\n"),
};
}

View file

@ -0,0 +1,199 @@
import type { SourceFile } from "@/domain/card/entities/SourceFile";
import type { ExplicitDeckExtractionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
import { DeckNormalizationService } from "./DeckNormalizationService";
export class DeckExtractionService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
extract(sourceFile: SourceFile, marker = "TARGET DECK"): ExplicitDeckExtractionResult {
const lines = sourceFile.content.split(/\r?\n/);
const warnings = [] as ExplicitDeckExtractionResult["warnings"];
const frontmatterRange = findFrontmatterRange(lines);
const frontmatterDeck = frontmatterRange
? this.extractFrontmatterDeck(lines.slice(frontmatterRange.start + 1, frontmatterRange.end), marker)
: undefined;
const bodyResult = this.extractBodyDeck(
lines,
frontmatterRange?.end !== undefined ? frontmatterRange.end + 1 : 0,
sourceFile.path,
marker,
);
warnings.push(...bodyResult.warnings);
if (frontmatterDeck && bodyResult.bodyDeck) {
if (frontmatterDeck === bodyResult.bodyDeck) {
return {
frontmatterDeck,
bodyDeck: bodyResult.bodyDeck,
explicitDeckHint: frontmatterDeck,
explicitDeckSource: "frontmatter",
warnings,
};
}
warnings.push({
filePath: sourceFile.path,
code: "deck_conflict_yaml_body",
message: "检测到同一文件同时在 YAML 和正文中声明了不同的 TARGET DECK本次已按 YAML 值同步,请清理冲突配置。",
});
return {
frontmatterDeck,
bodyDeck: bodyResult.bodyDeck,
explicitDeckHint: frontmatterDeck,
explicitDeckSource: "frontmatter",
warnings,
};
}
if (frontmatterDeck) {
return {
frontmatterDeck,
explicitDeckHint: frontmatterDeck,
explicitDeckSource: "frontmatter",
warnings,
};
}
if (bodyResult.bodyDeck) {
return {
bodyDeck: bodyResult.bodyDeck,
explicitDeckHint: bodyResult.bodyDeck,
explicitDeckSource: "body",
warnings,
};
}
return {
warnings,
};
}
private extractFrontmatterDeck(frontmatterLines: string[], marker: string): string | undefined {
const frontmatterTargetDeckRegexp = createFrontmatterMarkerRegExp(marker);
for (const line of frontmatterLines) {
const match = line.match(frontmatterTargetDeckRegexp);
if (!match) {
continue;
}
return this.deckNormalizationService.normalize(unwrapQuotedScalar(match[1] ?? ""));
}
return undefined;
}
private extractBodyDeck(
lines: string[],
startLineIndex: number,
filePath: string,
marker: string,
): { bodyDeck?: string; warnings: ExplicitDeckExtractionResult["warnings"] } {
const warnings = [] as ExplicitDeckExtractionResult["warnings"];
const declarations: string[] = [];
let fenceMarker: string | null = null;
const bodyTargetDeckInlineRegexp = createBodyInlineMarkerRegExp(marker);
const bodyTargetDeckBlockRegexp = createBodyBlockMarkerRegExp(marker);
for (let lineIndex = startLineIndex; 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 inlineMatch = line.match(bodyTargetDeckInlineRegexp);
if (inlineMatch) {
declarations.push(this.deckNormalizationService.normalize(inlineMatch[1] ?? ""));
continue;
}
if (!bodyTargetDeckBlockRegexp.test(line)) {
continue;
}
const nextValue = findNextBodyDeckValue(lines, lineIndex + 1);
if (nextValue) {
declarations.push(this.deckNormalizationService.normalize(nextValue));
}
}
if (declarations.length > 1) {
warnings.push({
filePath,
code: "deck_multiple_body_declarations",
message: "检测到同一文件存在多个 TARGET DECK 声明,本次只使用第一个正文 deck 声明。",
});
}
return {
bodyDeck: declarations[0],
warnings,
};
}
}
function findFrontmatterRange(lines: string[]): { start: number; end: number } | undefined {
if (lines[0]?.trim() !== "---") {
return undefined;
}
for (let index = 1; index < lines.length; index += 1) {
if (lines[index]?.trim() === "---" || lines[index]?.trim() === "...") {
return { start: 0, end: index };
}
}
return undefined;
}
function findNextBodyDeckValue(lines: string[], startLineIndex: number): string | undefined {
for (let index = startLineIndex; index < lines.length; index += 1) {
const trimmed = lines[index]?.trim() ?? "";
if (!trimmed) {
continue;
}
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
return undefined;
}
return trimmed;
}
return undefined;
}
function unwrapQuotedScalar(value: string): string {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function createFrontmatterMarkerRegExp(marker: string): RegExp {
return new RegExp(`^\\s*${escapeRegExp(marker)}\\s*:\\s*(.*?)\\s*$`);
}
function createBodyInlineMarkerRegExp(marker: string): RegExp {
return new RegExp(`^\\s*${escapeRegExp(marker)}\\s*:\\s*(.+?)\\s*$`);
}
function createBodyBlockMarkerRegExp(marker: string): RegExp {
return new RegExp(`^\\s*${escapeRegExp(marker)}\\s*$`);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { DeckNormalizationService } from "./DeckNormalizationService";
describe("DeckNormalizationService", () => {
it("trims whitespace, removes empty segments, and normalizes folder separators", () => {
const service = new DeckNormalizationService();
expect(service.normalize(" 数学/第一章/ ")).toBe("数学::第一章");
expect(service.normalize("数学::::第一章")).toBe("数学::第一章");
expect(service.normalize(" 数学:: 第一章 :: ")).toBe("数学::第一章");
});
it("rejects empty final deck values", () => {
const service = new DeckNormalizationService();
expect(() => service.normalize(" / :: ")).toThrow("Deck 不能为空");
});
});

View file

@ -0,0 +1,15 @@
export class DeckNormalizationService {
normalize(deckName: string): string {
const normalizedSeparators = deckName.trim().replace(/\\/g, "/").replace(/\/+?/g, "::");
const segments = normalizedSeparators
.split(/::+/)
.map((segment) => segment.trim().replace(/^:+|:+$/g, ""))
.filter(Boolean);
if (segments.length === 0) {
throw new Error("Deck 不能为空,请检查 TARGET DECK 或默认 deck 配置。");
}
return segments.join("::");
}
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import { DeckResolutionService } from "./DeckResolutionService";
describe("DeckResolutionService", () => {
it("prefers explicit deck over folder and default decks", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({
filePath: "课程/数学/第一章/导数.md",
deckHint: "显式/Deck",
deckHintSource: "frontmatter",
}), "Default", "folder");
expect(result.resolvedDeck).toEqual({
value: "显式::Deck",
source: "frontmatter",
});
expect(result.warnings).toEqual([]);
});
it("uses folder mapping when no explicit deck exists", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder");
expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章",
source: "folder",
});
});
it("supports folder-and-file mapping mode", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程/数学/第一章/导数.md" }), "Default", "folder-and-file");
expect(result.resolvedDeck).toEqual({
value: "课程::数学::第一章::导数",
source: "folder",
});
});
it("falls back to default deck for root-level files and emits a warning", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "导数.md" }), " Default/Deck ", "folder-and-file");
expect(result.resolvedDeck).toEqual({
value: "Default::Deck",
source: "default",
});
expect(result.warnings.map((warning) => warning.code)).toEqual(["deck_fallback_default"]);
});
it("warns and falls back to default deck when folder mapping is invalid", () => {
const service = new DeckResolutionService();
const result = service.resolve(createIndexedCard({ filePath: "课程::非法/导数.md" }), "Default", "folder");
expect(result.resolvedDeck.value).toBe("Default");
expect(result.warnings.map((warning) => warning.code)).toEqual([
"deck_invalid_folder_segment",
"deck_fallback_default",
]);
expect(service.resolve(createIndexedCard({
filePath: "课程::非法/导数.md",
deckHint: "显式::Deck",
deckHintSource: "body",
}), "Default", "folder").resolvedDeck.value).toBe("显式::Deck");
});
});
function createIndexedCard(overrides: Partial<IndexedCard> = {}): IndexedCard {
return {
cardId: overrides.cardId ?? "ahs_1",
noteId: overrides.noteId,
markerNoteId: overrides.markerNoteId,
filePath: overrides.filePath ?? "notes/example.md",
cardType: overrides.cardType ?? "basic",
heading: overrides.heading ?? "Prompt",
headingLevel: overrides.headingLevel ?? 4,
bodyMarkdown: overrides.bodyMarkdown ?? "Answer",
blockStartOffset: overrides.blockStartOffset ?? 0,
blockEndOffset: overrides.blockEndOffset ?? 10,
blockStartLine: overrides.blockStartLine ?? 1,
bodyStartLine: overrides.bodyStartLine ?? 2,
blockEndLine: overrides.blockEndLine ?? 2,
contentEndLine: overrides.contentEndLine ?? 2,
markerLine: overrides.markerLine,
rawBlockText: overrides.rawBlockText ?? "#### Prompt\nAnswer",
rawBlockHash: overrides.rawBlockHash ?? "hash-1",
deckHint: overrides.deckHint,
deckHintSource: overrides.deckHintSource,
deckWarnings: overrides.deckWarnings ?? [],
tagsHint: overrides.tagsHint ?? [],
markerState: overrides.markerState ?? "missing",
sourceContent: overrides.sourceContent,
};
}

View file

@ -0,0 +1,52 @@
import type { FolderDeckMode } from "@/application/config/PluginSettings";
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { DeckResolutionResult } from "@/domain/manual-sync/value-objects/DeckResolution";
import { DeckNormalizationService } from "./DeckNormalizationService";
import { FolderDeckMappingService } from "./FolderDeckMappingService";
export class DeckResolutionService {
constructor(
private readonly folderDeckMappingService = new FolderDeckMappingService(),
private readonly deckNormalizationService = new DeckNormalizationService(),
) {}
resolve(card: IndexedCard, defaultDeck: string, folderDeckMode: FolderDeckMode): DeckResolutionResult {
if (card.deckHint) {
return {
resolvedDeck: {
value: this.deckNormalizationService.normalize(card.deckHint),
source: card.deckHintSource ?? "body",
},
warnings: [...card.deckWarnings],
};
}
const folderMapping = this.folderDeckMappingService.mapFilePathToDeck(card.filePath, folderDeckMode);
if (folderMapping.deck) {
return {
resolvedDeck: {
value: folderMapping.deck,
source: "folder",
},
warnings: [...card.deckWarnings, ...folderMapping.warnings],
};
}
return {
resolvedDeck: {
value: this.deckNormalizationService.normalize(defaultDeck),
source: "default",
},
warnings: [
...card.deckWarnings,
...folderMapping.warnings,
{
filePath: card.filePath,
code: "deck_fallback_default",
message: "该文件没有显式 deck且所在位置无法生成文件夹牌组已回退到默认 deck。",
},
],
};
}
}

View file

@ -26,6 +26,7 @@ describe("DiffPlannerService", () => {
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "hash-card",
deckWarnings: [],
tagsHint: [],
markerState: "card-only",
},
@ -57,6 +58,7 @@ describe("DiffPlannerService", () => {
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "hash-card",
deckWarnings: [],
tagsHint: [],
markerState: "missing" as const,
};
@ -82,6 +84,7 @@ describe("DiffPlannerService", () => {
rawBlockHash: "hash-card",
renderConfigHash: renderPlan.renderConfigHash,
deck: renderPlan.deck,
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
@ -121,6 +124,7 @@ describe("DiffPlannerService", () => {
rawBlockHash: "old-hash",
renderConfigHash: "old-render",
deck: "Old",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
@ -157,6 +161,7 @@ describe("DiffPlannerService", () => {
contentEndLine: 2,
rawBlockText: ["#### Prompt", "Body"].join("\n"),
rawBlockHash: "hash-card",
deckWarnings: [],
tagsHint: [],
markerState: "card-and-note",
},
@ -193,6 +198,7 @@ describe("DiffPlannerService", () => {
rawBlockHash: "hash-card",
renderConfigHash: "render-hash",
deck: "Obsidian",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
@ -205,4 +211,66 @@ describe("DiffPlannerService", () => {
expect(plan.toOrphan.map((card) => card.cardId)).toEqual(["ahs_missing"]);
});
it("does not schedule update when only the resolved deck changed", () => {
const service = new DiffPlannerService();
const settings = createModule3Settings({ defaultDeck: "New::Deck" });
const card = {
cardId: "ahs_1",
noteId: 42,
markerNoteId: 42,
filePath: "example.md",
cardType: "basic" as const,
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",
deckWarnings: [],
tagsHint: [],
markerState: "card-and-note" as const,
};
const legacyRenderPlan = new RenderConfigService().resolve(card, settings, ["Old::Deck"]);
const legacyRenderConfigHash = legacyRenderPlan.compatibleRenderConfigHashes.find((hash) => hash !== legacyRenderPlan.renderConfigHash) ?? legacyRenderPlan.renderConfigHash;
const state = {
files: {},
cards: {
ahs_1: {
cardId: "ahs_1",
noteId: 42,
filePath: "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: legacyRenderConfigHash,
deck: "Old::Deck",
deckWarnings: [],
tagsHint: [],
lastSyncedAt: 1,
orphan: false,
},
},
pendingWriteBack: [],
};
const plan = service.plan([card], state, ["example.md"], settings);
expect(plan.toUpdate).toHaveLength(0);
expect(plan.unchangedCards).toBe(1);
});
});

View file

@ -2,6 +2,7 @@ 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 { getDeckResolutionWarningKey, type DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
import type { ManualSyncPlan, PlannedCard } from "@/domain/manual-sync/value-objects/ManualSyncPlan";
export class DiffPlannerService {
@ -13,6 +14,7 @@ export class DiffPlannerService {
const toCreate: PlannedCard[] = [];
const toUpdate: PlannedCard[] = [];
const toRewriteMarker: PlannedCard[] = [];
const warningMap = new Map<string, DeckResolutionWarning>();
let unchangedCards = 0;
for (const card of cards) {
@ -22,7 +24,10 @@ export class DiffPlannerService {
cardsById.set(card.cardId, card);
const existingState = state.cards[card.cardId];
const renderPlan = this.renderConfigService.resolve(card, settings);
const renderPlan = this.renderConfigService.resolve(card, settings, existingState?.deck ? [existingState.deck] : []);
for (const warning of renderPlan.warnings) {
warningMap.set(getDeckResolutionWarningKey(warning), warning);
}
const resolvedNoteId = existingState?.noteId ?? card.noteId;
const plannedCard: PlannedCard = {
card: {
@ -56,7 +61,7 @@ export class DiffPlannerService {
if (
existingState.rawBlockHash !== card.rawBlockHash ||
existingState.renderConfigHash !== renderPlan.renderConfigHash ||
!renderPlan.compatibleRenderConfigHashes.includes(existingState.renderConfigHash) ||
existingState.orphan ||
pendingByCardId.has(card.cardId)
) {
@ -76,6 +81,7 @@ export class DiffPlannerService {
toRewriteMarker,
toOrphan,
unchangedCards,
warnings: Array.from(warningMap.values()),
};
}
}

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { FolderDeckMappingService } from "./FolderDeckMappingService";
describe("FolderDeckMappingService", () => {
it("maps nested folders to nested Anki decks", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("课程/数学/第一章/导数.md", "folder")).toEqual({
deck: "课程::数学::第一章",
warnings: [],
});
expect(service.mapFilePathToDeck("课程/数学/第一章/导数.md", "folder-and-file")).toEqual({
deck: "课程::数学::第一章::导数",
warnings: [],
});
expect(service.mapFilePathToDeck("课程/导数.md", "folder")).toEqual({
deck: "课程",
warnings: [],
});
});
it("returns empty for root-level files", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("导数.md", "folder")).toEqual({ warnings: [] });
expect(service.mapFilePathToDeck("导数.md", "folder-and-file")).toEqual({ warnings: [] });
});
it("emits a warning instead of throwing when a path segment contains deck separators", () => {
const service = new FolderDeckMappingService();
expect(service.mapFilePathToDeck("课程::非法/导数.md", "folder")).toEqual({
warnings: [
expect.objectContaining({
filePath: "课程::非法/导数.md",
code: "deck_invalid_folder_segment",
}),
],
});
});
});

View file

@ -0,0 +1,52 @@
import type { FolderDeckMode } from "@/application/config/PluginSettings";
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
import { DeckNormalizationService } from "./DeckNormalizationService";
export interface FolderDeckMappingResult {
deck?: string;
warnings: DeckResolutionWarning[];
}
export class FolderDeckMappingService {
constructor(private readonly deckNormalizationService = new DeckNormalizationService()) {}
mapFilePathToDeck(filePath: string, mode: FolderDeckMode): FolderDeckMappingResult {
if (mode === "off") {
return { warnings: [] };
}
const lastSlash = filePath.lastIndexOf("/");
if (lastSlash < 0) {
return { warnings: [] };
}
const folderPath = filePath.slice(0, lastSlash);
if (!folderPath.trim()) {
return { warnings: [] };
}
const segments = folderPath.split("/").filter(Boolean);
if (mode === "folder-and-file") {
const fileName = filePath.slice(lastSlash + 1).replace(/\.[^.]+$/, "").trim();
if (fileName) {
segments.push(fileName);
}
}
if (segments.some((segment) => segment.includes("::"))) {
return {
warnings: [{
filePath,
code: "deck_invalid_folder_segment",
message: "检测到文件夹名或文件名包含 ::,本次已放弃文件夹映射并回退到默认 deck。",
}],
};
}
return {
deck: this.deckNormalizationService.normalize(segments.join("/")),
warnings: [],
};
}
}

View file

@ -0,0 +1,35 @@
export type DeckResolutionSource = "frontmatter" | "body" | "folder" | "default";
export type DeckResolutionWarningCode =
| "deck_conflict_yaml_body"
| "deck_multiple_body_declarations"
| "deck_invalid_folder_segment"
| "deck_fallback_default";
export interface ResolvedDeck {
value: string;
source: DeckResolutionSource;
}
export interface DeckResolutionWarning {
filePath: string;
code: DeckResolutionWarningCode;
message: string;
}
export interface ExplicitDeckExtractionResult {
frontmatterDeck?: string;
bodyDeck?: string;
explicitDeckHint?: string;
explicitDeckSource?: Extract<DeckResolutionSource, "frontmatter" | "body">;
warnings: DeckResolutionWarning[];
}
export interface DeckResolutionResult {
resolvedDeck: ResolvedDeck;
warnings: DeckResolutionWarning[];
}
export function getDeckResolutionWarningKey(warning: DeckResolutionWarning): string {
return `${warning.filePath}\u0000${warning.code}\u0000${warning.message}`;
}

View file

@ -1,5 +1,6 @@
import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard";
import type { CardState } from "@/domain/manual-sync/entities/PluginState";
import type { DeckResolutionWarning } from "@/domain/manual-sync/value-objects/DeckResolution";
export interface PlannedCard {
card: IndexedCard;
@ -15,4 +16,5 @@ export interface ManualSyncPlan {
toRewriteMarker: PlannedCard[];
toOrphan: CardState[];
unchangedCards: number;
warnings: DeckResolutionWarning[];
}

View file

@ -36,6 +36,11 @@ describe("DataJsonPluginConfigRepository", () => {
expect(settings.noteFieldMappings).toEqual({});
expect(settings.scopeMode).toBe("all");
expect(settings.includeFolders).toEqual(["cards"]);
expect(settings.fileDeckEnabled).toBe(false);
expect(settings.fileDeckMarker).toBe("TARGET DECK");
expect(settings.fileDeckTemplate).toBe("obsidian::filename");
expect(settings.fileDeckInsertLocation).toBe("body");
expect(settings.folderDeckMode).toBe("off");
});
it("persists note field mappings across save and reload", async () => {

View file

@ -2,6 +2,7 @@ import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, type PluginSettings } from "@/application/config/PluginSettings";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
import { DeckTemplateInsertionService } from "@/application/services/DeckTemplateInsertionService";
import { ManualSyncCurrentFileUseCase } from "@/application/use-cases/ManualSyncCurrentFileUseCase";
import { RebuildCardIndexUseCase } from "@/application/use-cases/RebuildCardIndexUseCase";
import { ManualSyncVaultUseCase } from "@/application/use-cases/ManualSyncVaultUseCase";
@ -22,6 +23,7 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
private readonly noticeService = new NoticeService();
private readonly ankiGateway = new AnkiConnectGateway(() => this.settings.ankiConnectUrl);
private readonly deckTemplateInsertionService = new DeckTemplateInsertionService();
private syncCurrentFileUseCase?: ManualSyncCurrentFileUseCase;
private syncVaultUseCase?: ManualSyncVaultUseCase;
@ -143,4 +145,44 @@ export default class AnkiHeadingSyncPlugin extends Plugin {
this.noticeService.error(error instanceof Error ? error.message : "Card index rebuild failed.");
}
}
async insertDeckTemplateToCurrentFile(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || activeFile.extension.toLowerCase() !== "md") {
this.noticeService.error("No active Markdown file is available for deck template insertion.");
return;
}
if (!this.vaultGateway) {
this.noticeService.error("Vault gateway is not initialized.");
return;
}
try {
const sourceFile = await this.vaultGateway.readMarkdownFile(activeFile.path);
if (!sourceFile) {
this.noticeService.error(`Markdown file not found: ${activeFile.path}`);
return;
}
const insertionResult = this.deckTemplateInsertionService.insert(
sourceFile,
this.settings.fileDeckMarker,
this.settings.fileDeckTemplate,
this.settings.fileDeckInsertLocation,
);
if (insertionResult.nextContent === insertionResult.expectedContent) {
this.noticeService.info("当前文件中的 deck 模板已是最新,无需重复写入。");
return;
}
await this.vaultGateway.replaceMarkdownFile(activeFile.path, insertionResult.expectedContent, insertionResult.nextContent);
this.noticeService.info(`已向当前文件写入 deck 模板:${insertionResult.insertedDeck}`);
} catch (error) {
console.error("Deck template insertion failed.", error);
this.noticeService.error(error instanceof Error ? error.message : "Deck template insertion failed.");
}
}
}

View file

@ -16,8 +16,12 @@ export class NoticeService {
const conflicts = result.markerWriteConflictFiles.length > 0
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
: "";
const warnings = result.warnings.length > 0 ? ` Warnings: ${result.warnings.length}.` : "";
this.info(`${summary}${conflicts}`);
this.info(`${summary}${conflicts}${warnings}`);
for (const warning of result.warnings.slice(0, 3)) {
this.info(warning.message);
}
}
showRebuildSummary(prefix: string, result: ManualSyncResult): void {
@ -25,7 +29,11 @@ export class NoticeService {
const conflicts = result.markerWriteConflictFiles.length > 0
? ` Marker write conflicts: ${result.markerWriteConflictFiles.join(", ")}.`
: "";
const warnings = result.warnings.length > 0 ? ` Warnings: ${result.warnings.length}.` : "";
this.info(`${summary}${conflicts}`);
this.info(`${summary}${conflicts}${warnings}`);
for (const warning of result.warnings.slice(0, 3)) {
this.info(warning.message);
}
}
}

View file

@ -124,6 +124,7 @@ const {
class HoistedFakeTextComponent {
public value = "";
private onChangeHandler?: (value: string) => void | Promise<void>;
setPlaceholder(value: string): this {
void value;
@ -136,13 +137,19 @@ const {
}
onChange(callback: (value: string) => void | Promise<void>): this {
void callback;
this.onChangeHandler = callback;
return this;
}
async triggerChange(value: string): Promise<void> {
this.value = value;
await this.onChangeHandler?.(value);
}
}
class HoistedFakeToggleComponent {
public value = false;
private onChangeHandler?: (value: boolean) => void | Promise<void>;
setValue(value: boolean): this {
this.value = value;
@ -150,9 +157,14 @@ const {
}
onChange(callback: (value: boolean) => void | Promise<void>): this {
void callback;
this.onChangeHandler = callback;
return this;
}
async triggerChange(value: boolean): Promise<void> {
this.value = value;
await this.onChangeHandler?.(value);
}
}
class HoistedFakeSetting {
@ -219,6 +231,10 @@ const {
public readonly app: unknown,
public readonly plugin: unknown,
) {}
hide(): void {
return;
}
}
return {
@ -242,9 +258,19 @@ type FakeSettingInstance = InstanceType<typeof FakeSetting>;
type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
type FakeDropdownComponentInstance = InstanceType<typeof FakeDropdownComponent>;
type FakeElementInstance = InstanceType<typeof FakeElement>;
type FakeTextComponentInstance = {
value: string;
triggerChange(value: string): Promise<void>;
};
type FakeToggleComponentInstance = {
value: boolean;
triggerChange(value: boolean): Promise<void>;
};
class FakePlugin {
public readonly app = {};
public listFolderTreeCalls = 0;
public insertDeckTemplateCalls = 0;
public settings = {
...DEFAULT_SETTINGS,
qaNoteType: "Custom Basic",
@ -300,8 +326,13 @@ class FakePlugin {
}
async listFolderTree() {
this.listFolderTreeCalls += 1;
return this.folderTree;
}
async insertDeckTemplateToCurrentFile(): Promise<void> {
this.insertDeckTemplateCalls += 1;
}
}
function findSetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance {
@ -338,6 +369,26 @@ function getDropdown(setting: FakeSettingInstance): FakeDropdownComponentInstanc
return dropdown;
}
function getText(setting: FakeSettingInstance): FakeTextComponentInstance {
const text = setting.controls.find((control: unknown) => typeof control === "object" && control !== null && "triggerChange" in (control as Record<string, unknown>) && "value" in (control as Record<string, unknown>) && !(control instanceof FakeDropdownComponent) && !(control instanceof FakeButtonComponent));
if (!text) {
throw new Error(`Text control not found for setting: ${setting.name}`);
}
return text as FakeTextComponentInstance;
}
function getToggle(setting: FakeSettingInstance): FakeToggleComponentInstance {
const toggle = setting.controls.find((control: unknown) => typeof control === "object" && control !== null && "triggerChange" in (control as Record<string, unknown>) && "value" in (control as Record<string, unknown>) && !(control instanceof FakeDropdownComponent) && !(control instanceof FakeButtonComponent) && typeof (control as { value?: unknown }).value === "boolean");
if (!toggle) {
throw new Error(`Toggle not found for setting: ${setting.name}`);
}
return toggle as FakeToggleComponentInstance;
}
function queryCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance | undefined {
return findElement(containerEl, (element) => element.tag === "input" && element.dataset.folderPath === folderPath);
}
@ -476,6 +527,22 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
});
it("loads the current folder tree when the settings page first opens", async () => {
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
scopeMode: "include",
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
tab.display();
await flushAsync();
tab.display();
expect(plugin.listFolderTreeCalls).toBe(1);
expect(getCheckboxByPath(tab.containerEl as unknown as FakeContainerElInstance, "notes")).toBeDefined();
});
it("shows the folder tree as a collapsed hierarchy and reveals children after expanding a parent", async () => {
const plugin = new FakePlugin();
plugin.settings = {
@ -510,6 +577,127 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(container.textNodes).toContain("empty");
});
it("reloads folder tree after the settings tab is reopened and shows newly created folders", async () => {
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
scopeMode: "include",
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await flushAsync();
tab.display();
expect(plugin.listFolderTreeCalls).toBe(1);
expect(queryCheckboxByPath(container, "notes/new-folder")).toBeUndefined();
plugin.folderTree = [
{
path: "notes",
name: "notes",
children: [
{
path: "notes/sub",
name: "sub",
children: [],
},
{
path: "notes/other",
name: "other",
children: [],
},
{
path: "notes/new-folder",
name: "new-folder",
children: [],
},
],
},
{
path: "empty",
name: "empty",
children: [],
},
];
tab.hide();
tab.display();
await flushAsync();
tab.display();
expect(plugin.listFolderTreeCalls).toBe(2);
await getFolderToggle(container, "notes").trigger("click");
const newFolderCheckbox = getCheckboxByPath(container, "notes/new-folder");
const newFolderRow = getFolderRow(container, "notes/new-folder");
expect(newFolderCheckbox.checked).toBe(false);
expect(newFolderRow.dataset.folderDepth).toBe("1");
expect(newFolderRow.style.paddingLeft).toBe("18px");
});
it("keeps existing folder selections after reloading the folder tree on reopen", async () => {
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
scopeMode: "include",
includeFolders: ["notes/sub"],
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await flushAsync();
tab.display();
await getFolderToggle(container, "notes").trigger("click");
expect(getCheckboxByPath(container, "notes/sub").checked).toBe(true);
expect(getCheckboxByPath(container, "notes").indeterminate).toBe(true);
plugin.folderTree = [
{
path: "notes",
name: "notes",
children: [
{
path: "notes/sub",
name: "sub",
children: [],
},
{
path: "notes/other",
name: "other",
children: [],
},
{
path: "notes/new-folder",
name: "new-folder",
children: [],
},
],
},
{
path: "empty",
name: "empty",
children: [],
},
];
tab.hide();
tab.display();
await flushAsync();
tab.display();
await getFolderToggle(container, "notes").trigger("click");
expect(plugin.settings.includeFolders).toEqual(["notes/sub"]);
expect(getCheckboxByPath(container, "notes/sub").checked).toBe(true);
expect(getCheckboxByPath(container, "notes").indeterminate).toBe(true);
expect(getCheckboxByPath(container, "notes/new-folder").checked).toBe(false);
});
it("switches scope mode and saves compressed folder selections from the tree", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
@ -532,4 +720,58 @@ describe("AnkiHeadingSyncSettingTab", () => {
expect(queryCheckboxByPath(container, "notes")).toBeUndefined();
});
it("saves and rehydrates module 5 deck settings", async () => {
const plugin = new FakePlugin();
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
expect(container.textNodes).toContain("默认牌组");
expect(container.textNodes).toContain("文件级自定义牌组");
expect(container.textNodes).toContain("高级:文件夹映射");
expect(container.textNodes).toContain("最终优先级说明");
await getToggle(findSetting(container, "开启文件级自定义牌组")).triggerChange(true);
await flushAsync();
await getText(findSetting(container, "牌组识别名")).triggerChange("MY DECK");
await getText(findSetting(container, "默认牌组模板")).triggerChange("vault::filename");
await getDropdown(findSetting(container, "模板插入位置")).triggerChange("yaml");
await getDropdown(findSetting(container, "文件夹映射模式")).triggerChange("folder-and-file");
await getText(findSetting(container, "默认牌组")).triggerChange("Deck::Default");
await flushAsync();
expect(plugin.settings.fileDeckEnabled).toBe(true);
expect(plugin.settings.fileDeckMarker).toBe("MY DECK");
expect(plugin.settings.fileDeckTemplate).toBe("vault::filename");
expect(plugin.settings.fileDeckInsertLocation).toBe("yaml");
expect(plugin.settings.folderDeckMode).toBe("folder-and-file");
expect(plugin.settings.defaultDeck).toBe("Deck::Default");
tab.display();
expect(getToggle(findSetting(container, "开启文件级自定义牌组")).value).toBe(true);
expect(getText(findSetting(container, "牌组识别名")).value).toBe("MY DECK");
expect(getText(findSetting(container, "默认牌组模板")).value).toBe("vault::filename");
expect(getDropdown(findSetting(container, "模板插入位置")).value).toBe("yaml");
expect(getDropdown(findSetting(container, "文件夹映射模式")).value).toBe("folder-and-file");
expect(getText(findSetting(container, "默认牌组")).value).toBe("Deck::Default");
});
it("exposes the deck template insertion action when file deck mode is enabled", async () => {
const plugin = new FakePlugin();
plugin.settings = {
...plugin.settings,
fileDeckEnabled: true,
};
const tab = new AnkiHeadingSyncSettingTab(plugin as never);
const container = tab.containerEl as unknown as FakeContainerElInstance;
tab.display();
await getButton(findSetting(container, "向当前文件插入 deck 模板")).click();
expect(plugin.insertDeckTemplateCalls).toBe(1);
});
});

View file

@ -1,6 +1,6 @@
import { PluginSettingTab, Setting } from "obsidian";
import type { ScopeMode } from "@/application/config/PluginSettings";
import type { FileDeckInsertLocation, FolderDeckMode, ScopeMode } from "@/application/config/PluginSettings";
import { createNoteFieldMappingKey, type NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping";
import type { FolderTreeNode } from "@/application/dto/FolderTreeNode";
import type { NoteModelDetails } from "@/application/dto/NoteModelDetails";
@ -35,8 +35,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
private folderTree: FolderTreeNode[] = [];
private folderTreeStatus = FOLDER_TREE_STATUS_LOADING;
private folderTreeLoadPromise: Promise<void> | null = null;
// removed cached 'hasLoadedFolderTree' so the settings UI can refresh
// the folder list when displayed (ensures newly-created Obsidian folders appear).
private hasLoadedFolderTree = false;
private readonly expandedFolderPaths = new Set<string>();
constructor(plugin: AnkiHeadingSyncPlugin) {
@ -46,6 +45,12 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
declare plugin: AnkiHeadingSyncPlugin;
hide(): void {
super.hide();
this.hasLoadedFolderTree = false;
this.expandedFolderPaths.clear();
}
display(): void {
const { containerEl } = this;
const settings = this.plugin.settings;
@ -62,14 +67,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
.setName("Default deck")
.setDesc("Used when a file does not define TARGET DECK")
.addText((text) => {
text.setValue(settings.defaultDeck).onChange((value) => {
void this.plugin.updateSettings({ defaultDeck: value });
});
});
this.renderDeckSection(containerEl, settings);
new Setting(containerEl)
.setName("QA heading level")
@ -384,6 +382,121 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
return cardType === "basic" ? this.plugin.settings.qaNoteType : this.plugin.settings.clozeNoteType;
}
private renderDeckSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
containerEl.createEl("h3", { text: "默认牌组" });
new Setting(containerEl)
.setName("默认牌组")
.setDesc("优先级最低:当文件级 deck 与文件夹映射都未命中时使用。")
.addText((text) => {
text.setValue(settings.defaultDeck).onChange((value) => {
void this.plugin.updateSettings({ defaultDeck: value });
});
});
containerEl.createEl("h3", { text: "文件级自定义牌组" });
new Setting(containerEl)
.setName("开启文件级自定义牌组")
.setDesc("开启后,允许用统一 marker 在 YAML 或正文中为整篇笔记指定 deck。")
.addToggle((toggle) => {
toggle.setValue(settings.fileDeckEnabled).onChange(async (value) => {
await this.plugin.updateSettings({ fileDeckEnabled: value });
this.display();
});
});
if (settings.fileDeckEnabled) {
new Setting(containerEl)
.setName("牌组识别名")
.setDesc("YAML key 与正文 marker 共用同一个识别名。默认是 TARGET DECK。")
.addText((text) => {
text.setValue(settings.fileDeckMarker).onChange((value) => {
const nextValue = value.trim();
if (!nextValue) {
return;
}
void this.plugin.updateSettings({ fileDeckMarker: nextValue });
});
});
new Setting(containerEl)
.setName("默认牌组模板")
.setDesc("只支持 filename 变量,插入时会展开为当前文件名(不含 .md。")
.addText((text) => {
text.setValue(settings.fileDeckTemplate).onChange((value) => {
const nextValue = value.trim();
if (!nextValue) {
return;
}
void this.plugin.updateSettings({ fileDeckTemplate: nextValue });
});
});
new Setting(containerEl)
.setName("模板插入位置")
.setDesc("选择将 deck 模板写入 YAML frontmatter 还是正文前部。")
.addDropdown((dropdown) => {
this.populateFileDeckInsertLocationDropdown(dropdown, settings.fileDeckInsertLocation);
dropdown.onChange((value) => {
if (value !== "yaml" && value !== "body") {
return;
}
void this.plugin.updateSettings({ fileDeckInsertLocation: value });
});
});
new Setting(containerEl)
.setName("向当前文件插入 deck 模板")
.setDesc("按当前 marker、模板和插入位置把 deck 声明写入当前活动 Markdown 文件。")
.addButton((button) => {
button.setButtonText("插入 deck 模板").onClick(() => {
void this.plugin.insertDeckTemplateToCurrentFile();
});
});
}
containerEl.createEl("h3", { text: "高级:文件夹映射" });
new Setting(containerEl)
.setName("文件夹映射模式")
.setDesc("关闭 / 仅父文件夹 / 父文件夹加当前文件名。")
.addDropdown((dropdown) => {
this.populateFolderDeckModeDropdown(dropdown, settings.folderDeckMode);
dropdown.onChange((value) => {
if (value !== "off" && value !== "folder" && value !== "folder-and-file") {
return;
}
void this.plugin.updateSettings({ folderDeckMode: value });
});
});
containerEl.createEl("p", { text: "文件夹级映射示例:数学/第一章/第一节.md -> 数学::第一章" });
containerEl.createEl("p", { text: "文件夹及文件名级映射示例:数学/第一章/第一节.md -> 数学::第一章::第一节" });
containerEl.createEl("h3", { text: "最终优先级说明" });
containerEl.createEl("p", {
text: "最终 deck 优先级:文件级自定义牌组 > 文件夹映射 deck > 默认牌组。旧卡 deck 行为保持当前实现。",
});
}
private populateFileDeckInsertLocationDropdown(dropdown: SimpleDropdown, selectedValue: FileDeckInsertLocation): void {
dropdown.addOption("body", "正文前部");
dropdown.addOption("yaml", "YAML frontmatter");
dropdown.setValue(selectedValue);
}
private populateFolderDeckModeDropdown(dropdown: SimpleDropdown, selectedValue: FolderDeckMode): void {
dropdown.addOption("off", "关闭");
dropdown.addOption("folder", "文件夹级映射");
dropdown.addOption("folder-and-file", "文件夹及文件名级映射");
dropdown.setValue(selectedValue);
}
private renderScopeSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void {
new Setting(containerEl)
.setName("运行范围")
@ -498,13 +611,11 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
}
private ensureFolderTreeLoaded(): void {
if (this.folderTreeLoadPromise) {
if (this.hasLoadedFolderTree || this.folderTreeLoadPromise) {
return;
}
this.folderTreeStatus = FOLDER_TREE_STATUS_LOADING;
// Always re-fetch the folder tree when requested (no permanent cache).
// Avoid concurrent loads using folderTreeLoadPromise.
this.folderTreeLoadPromise = this.plugin
.listFolderTree()
.then((folderTree) => {
@ -516,6 +627,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab {
this.folderTreeStatus = error instanceof Error ? error.message : "读取 vault 文件夹失败。";
})
.finally(() => {
this.hasLoadedFolderTree = true;
this.folderTreeLoadPromise = null;
this.display();
});

View file

@ -208,6 +208,11 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway {
export function createModule3Settings(overrides: Partial<PluginSettings> = {}): PluginSettings {
return {
...DEFAULT_SETTINGS,
fileDeckEnabled: true,
fileDeckMarker: "TARGET DECK",
fileDeckTemplate: "obsidian::filename",
fileDeckInsertLocation: "body",
folderDeckMode: "folder",
noteFieldMappings: {
"basic:Basic": {
cardType: "basic",