mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
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:
parent
f0b8063270
commit
960b87103d
40 changed files with 2981 additions and 88 deletions
298
docs/2026-04-18PLAN5.md
Normal file
298
docs/2026-04-18PLAN5.md
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# 模块 5:Deck 模式选择与文件级 / 文件夹级 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 覆盖。
|
||||
597
docs/2026-04-18,deck-resolution-plan.md
Normal file
597
docs/2026-04-18,deck-resolution-plan.md
Normal 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 通过
|
||||
62
docs/deck-resolution-decisions.md
Normal file
62
docs/deck-resolution-decisions.md
Normal 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 deck,update 路径不做 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. 不新增复杂配置项
|
||||
61
docs/deck-resolution-gap-report.md
Normal file
61
docs/deck-resolution-gap-report.md
Normal 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 deck,update 禁止自动 changeDeck
|
||||
8. 对应单元测试与 manual-sync 回归测试
|
||||
|
||||
本轮不扩展到:
|
||||
|
||||
1. 旧链路回归改造
|
||||
2. 标题级 deck override
|
||||
3. deck 迁移命令
|
||||
4. 自定义优先级与模板 DSL
|
||||
77
docs/module-5-decisions.md
Normal file
77
docs/module-5-decisions.md
Normal 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
114
docs/module-5-gap-report.md
Normal 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 映射表
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
163
src/application/services/DeckTemplateInsertionService.ts
Normal file
163
src/application/services/DeckTemplateInsertionService.ts
Normal 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, "\\$&");
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ describe("FileIndexerService", () => {
|
|||
rawBlockHash: "hash-card",
|
||||
renderConfigHash: "render-hash",
|
||||
deck: "Obsidian",
|
||||
deckWarnings: [],
|
||||
tagsHint: [],
|
||||
lastSyncedAt: 1,
|
||||
orphan: false,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
136
src/domain/manual-sync/services/DeckExtractionService.test.ts
Normal file
136
src/domain/manual-sync/services/DeckExtractionService.test.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
199
src/domain/manual-sync/services/DeckExtractionService.ts
Normal file
199
src/domain/manual-sync/services/DeckExtractionService.ts
Normal 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, "\\$&");
|
||||
}
|
||||
|
|
@ -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 不能为空");
|
||||
});
|
||||
});
|
||||
15
src/domain/manual-sync/services/DeckNormalizationService.ts
Normal file
15
src/domain/manual-sync/services/DeckNormalizationService.ts
Normal 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("::");
|
||||
}
|
||||
}
|
||||
103
src/domain/manual-sync/services/DeckResolutionService.test.ts
Normal file
103
src/domain/manual-sync/services/DeckResolutionService.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
src/domain/manual-sync/services/DeckResolutionService.ts
Normal file
52
src/domain/manual-sync/services/DeckResolutionService.ts
Normal 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。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/domain/manual-sync/services/FolderDeckMappingService.ts
Normal file
52
src/domain/manual-sync/services/FolderDeckMappingService.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/domain/manual-sync/value-objects/DeckResolution.ts
Normal file
35
src/domain/manual-sync/value-objects/DeckResolution.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue