From 960b87103d5a89e89d27e7c354771410399ce319 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 20:14:29 +0800 Subject: [PATCH] feat(deck): add configurable deck resolution modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文: 新增 deck 模式选择、文件级 deck 解析、文件夹映射、模板插入与 warning 汇总 English: Add deck mode selection, file-level deck parsing, folder mapping, template insertion, and warning aggregation --- docs/2026-04-18PLAN5.md | 298 +++++++++ docs/2026-04-18,deck-resolution-plan.md | 597 ++++++++++++++++++ docs/deck-resolution-decisions.md | 62 ++ docs/deck-resolution-gap-report.md | 61 ++ docs/module-5-decisions.md | 77 +++ docs/module-5-gap-report.md | 114 ++++ src/application/config/PluginSettings.test.ts | 24 + src/application/config/PluginSettings.ts | 32 + .../services/AnkiBatchExecutor.test.ts | 42 +- src/application/services/AnkiBatchExecutor.ts | 26 +- .../DeckTemplateInsertionService.test.ts | 89 +++ .../services/DeckTemplateInsertionService.ts | 163 +++++ .../services/FileIndexerService.test.ts | 1 + .../services/FileIndexerService.ts | 6 + .../services/ManualSyncService.test.ts | 100 +++ src/application/services/ManualSyncService.ts | 4 + .../services/RenderConfigService.ts | 39 +- src/application/use-cases/manualSyncTypes.ts | 3 + .../manual-sync/entities/IndexedCard.ts | 3 + .../manual-sync/entities/PluginState.ts | 3 + .../services/CardIndexingService.test.ts | 100 +++ .../services/CardIndexingService.ts | 42 +- .../services/DeckExtractionService.test.ts | 136 ++++ .../services/DeckExtractionService.ts | 199 ++++++ .../services/DeckNormalizationService.test.ts | 19 + .../services/DeckNormalizationService.ts | 15 + .../services/DeckResolutionService.test.ts | 103 +++ .../services/DeckResolutionService.ts | 52 ++ .../services/DiffPlannerService.test.ts | 68 ++ .../services/DiffPlannerService.ts | 10 +- .../services/FolderDeckMappingService.test.ts | 42 ++ .../services/FolderDeckMappingService.ts | 52 ++ .../value-objects/DeckResolution.ts | 35 + .../value-objects/ManualSyncPlan.ts | 2 + .../DataJsonPluginConfigRepository.test.ts | 5 + src/presentation/AnkiHeadingSyncPlugin.ts | 42 ++ src/presentation/notices/NoticeService.ts | 12 +- .../settings/PluginSettingTab.test.ts | 246 +++++++- src/presentation/settings/PluginSettingTab.ts | 140 +++- src/test-support/manualSyncFakes.ts | 5 + 40 files changed, 2981 insertions(+), 88 deletions(-) create mode 100644 docs/2026-04-18PLAN5.md create mode 100644 docs/2026-04-18,deck-resolution-plan.md create mode 100644 docs/deck-resolution-decisions.md create mode 100644 docs/deck-resolution-gap-report.md create mode 100644 docs/module-5-decisions.md create mode 100644 docs/module-5-gap-report.md create mode 100644 src/application/services/DeckTemplateInsertionService.test.ts create mode 100644 src/application/services/DeckTemplateInsertionService.ts create mode 100644 src/domain/manual-sync/services/DeckExtractionService.test.ts create mode 100644 src/domain/manual-sync/services/DeckExtractionService.ts create mode 100644 src/domain/manual-sync/services/DeckNormalizationService.test.ts create mode 100644 src/domain/manual-sync/services/DeckNormalizationService.ts create mode 100644 src/domain/manual-sync/services/DeckResolutionService.test.ts create mode 100644 src/domain/manual-sync/services/DeckResolutionService.ts create mode 100644 src/domain/manual-sync/services/FolderDeckMappingService.test.ts create mode 100644 src/domain/manual-sync/services/FolderDeckMappingService.ts create mode 100644 src/domain/manual-sync/value-objects/DeckResolution.ts diff --git a/docs/2026-04-18PLAN5.md b/docs/2026-04-18PLAN5.md new file mode 100644 index 0000000..8449474 --- /dev/null +++ b/docs/2026-04-18PLAN5.md @@ -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 中写入 `: <展开后的模板值>` + - 若没有 frontmatter,则创建 frontmatter +- 当插入位置是 `body` + - 在正文靠前位置插入单行声明 + - 格式:`: <展开后的模板值>` + +本轮不支持更多模板 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 模式能写入 `: ` + - 正文模式能写入 `: ` + - `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 覆盖。 diff --git a/docs/2026-04-18,deck-resolution-plan.md b/docs/2026-04-18,deck-resolution-plan.md new file mode 100644 index 0000000..2911b0b --- /dev/null +++ b/docs/2026-04-18,deck-resolution-plan.md @@ -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 通过 diff --git a/docs/deck-resolution-decisions.md b/docs/deck-resolution-decisions.md new file mode 100644 index 0000000..0428fad --- /dev/null +++ b/docs/deck-resolution-decisions.md @@ -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. 不新增复杂配置项 \ No newline at end of file diff --git a/docs/deck-resolution-gap-report.md b/docs/deck-resolution-gap-report.md new file mode 100644 index 0000000..b02c88e --- /dev/null +++ b/docs/deck-resolution-gap-report.md @@ -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 \ No newline at end of file diff --git a/docs/module-5-decisions.md b/docs/module-5-decisions.md new file mode 100644 index 0000000..ef19e80 --- /dev/null +++ b/docs/module-5-decisions.md @@ -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 组件。 +语义仍然是互斥单选,不扩展复杂交互。 \ No newline at end of file diff --git a/docs/module-5-gap-report.md b/docs/module-5-gap-report.md new file mode 100644 index 0000000..b74fc22 --- /dev/null +++ b/docs/module-5-gap-report.md @@ -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 映射表 \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 5574b01..b4c4571 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -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."); + }); }); \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 6a8b579..9c48816 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -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; 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."); } diff --git a/src/application/services/AnkiBatchExecutor.test.ts b/src/application/services/AnkiBatchExecutor.test.ts index 67f3668..0861530 100644 --- a/src/application/services/AnkiBatchExecutor.test.ts +++ b/src/application/services/AnkiBatchExecutor.test.ts @@ -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([ + [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", }; diff --git a/src/application/services/AnkiBatchExecutor.ts b/src/application/services/AnkiBatchExecutor.ts index 7b45c12..a3e29c3 100644 --- a/src/application/services/AnkiBatchExecutor.ts +++ b/src/application/services/AnkiBatchExecutor.ts @@ -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(); - 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, diff --git a/src/application/services/DeckTemplateInsertionService.test.ts b/src/application/services/DeckTemplateInsertionService.test.ts new file mode 100644 index 0000000..dd41697 --- /dev/null +++ b/src/application/services/DeckTemplateInsertionService.test.ts @@ -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")); + }); +}); \ No newline at end of file diff --git a/src/application/services/DeckTemplateInsertionService.ts b/src/application/services/DeckTemplateInsertionService.ts new file mode 100644 index 0000000..aa5f5a5 --- /dev/null +++ b/src/application/services/DeckTemplateInsertionService.ts @@ -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, "\\$&"); +} \ No newline at end of file diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index ce7405b..20c9e49 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -58,6 +58,7 @@ describe("FileIndexerService", () => { rawBlockHash: "hash-card", renderConfigHash: "render-hash", deck: "Obsidian", + deckWarnings: [], tagsHint: [], lastSyncedAt: 1, orphan: false, diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index 878be95..afd46e6 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -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", }; diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index 05dafda..6a81839 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -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(""].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 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, }; } } \ No newline at end of file diff --git a/src/application/use-cases/manualSyncTypes.ts b/src/application/use-cases/manualSyncTypes.ts index ead8081..bafc9d0 100644 --- a/src/application/use-cases/manualSyncTypes.ts +++ b/src/application/use-cases/manualSyncTypes.ts @@ -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[]; } \ No newline at end of file diff --git a/src/domain/manual-sync/entities/IndexedCard.ts b/src/domain/manual-sync/entities/IndexedCard.ts index 1f9e3f2..5e054cf 100644 --- a/src/domain/manual-sync/entities/IndexedCard.ts +++ b/src/domain/manual-sync/entities/IndexedCard.ts @@ -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; + deckWarnings: DeckResolutionWarning[]; tagsHint: string[]; markerState: MarkerState; sourceContent?: string; diff --git a/src/domain/manual-sync/entities/PluginState.ts b/src/domain/manual-sync/entities/PluginState.ts index bacbeef..e9f0a32 100644 --- a/src/domain/manual-sync/entities/PluginState.ts +++ b/src/domain/manual-sync/entities/PluginState.ts @@ -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; + deckWarnings: DeckResolutionWarning[]; tagsHint: string[]; lastSyncedAt: number; orphan: boolean; diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index f01f468..bef436f 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -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 { @@ -176,6 +274,8 @@ function createKnownCardState(overrides: Partial = {}): 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, diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index b9cd773..962b9d9 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -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"; diff --git a/src/domain/manual-sync/services/DeckExtractionService.test.ts b/src/domain/manual-sync/services/DeckExtractionService.test.ts new file mode 100644 index 0000000..3e29849 --- /dev/null +++ b/src/domain/manual-sync/services/DeckExtractionService.test.ts @@ -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"), + }; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckExtractionService.ts b/src/domain/manual-sync/services/DeckExtractionService.ts new file mode 100644 index 0000000..d9b95f6 --- /dev/null +++ b/src/domain/manual-sync/services/DeckExtractionService.ts @@ -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, "\\$&"); +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckNormalizationService.test.ts b/src/domain/manual-sync/services/DeckNormalizationService.test.ts new file mode 100644 index 0000000..f5964e9 --- /dev/null +++ b/src/domain/manual-sync/services/DeckNormalizationService.test.ts @@ -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 不能为空"); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckNormalizationService.ts b/src/domain/manual-sync/services/DeckNormalizationService.ts new file mode 100644 index 0000000..dc7c96d --- /dev/null +++ b/src/domain/manual-sync/services/DeckNormalizationService.ts @@ -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("::"); + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckResolutionService.test.ts b/src/domain/manual-sync/services/DeckResolutionService.test.ts new file mode 100644 index 0000000..f4c1f1d --- /dev/null +++ b/src/domain/manual-sync/services/DeckResolutionService.test.ts @@ -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 { + 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, + }; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DeckResolutionService.ts b/src/domain/manual-sync/services/DeckResolutionService.ts new file mode 100644 index 0000000..e10149d --- /dev/null +++ b/src/domain/manual-sync/services/DeckResolutionService.ts @@ -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。", + }, + ], + }; + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts index c3baffb..c37defe 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.test.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/src/domain/manual-sync/services/DiffPlannerService.ts b/src/domain/manual-sync/services/DiffPlannerService.ts index 80dc6b9..6124be3 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.ts @@ -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(); 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()), }; } } \ No newline at end of file diff --git a/src/domain/manual-sync/services/FolderDeckMappingService.test.ts b/src/domain/manual-sync/services/FolderDeckMappingService.test.ts new file mode 100644 index 0000000..5ed9c52 --- /dev/null +++ b/src/domain/manual-sync/services/FolderDeckMappingService.test.ts @@ -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", + }), + ], + }); + }); +}); \ No newline at end of file diff --git a/src/domain/manual-sync/services/FolderDeckMappingService.ts b/src/domain/manual-sync/services/FolderDeckMappingService.ts new file mode 100644 index 0000000..4752706 --- /dev/null +++ b/src/domain/manual-sync/services/FolderDeckMappingService.ts @@ -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: [], + }; + } +} \ No newline at end of file diff --git a/src/domain/manual-sync/value-objects/DeckResolution.ts b/src/domain/manual-sync/value-objects/DeckResolution.ts new file mode 100644 index 0000000..962e157 --- /dev/null +++ b/src/domain/manual-sync/value-objects/DeckResolution.ts @@ -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; + warnings: DeckResolutionWarning[]; +} + +export interface DeckResolutionResult { + resolvedDeck: ResolvedDeck; + warnings: DeckResolutionWarning[]; +} + +export function getDeckResolutionWarningKey(warning: DeckResolutionWarning): string { + return `${warning.filePath}\u0000${warning.code}\u0000${warning.message}`; +} \ No newline at end of file diff --git a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts index 818da87..841bd71 100644 --- a/src/domain/manual-sync/value-objects/ManualSyncPlan.ts +++ b/src/domain/manual-sync/value-objects/ManualSyncPlan.ts @@ -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[]; } \ No newline at end of file diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts index 6df1d68..de9260c 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -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 () => { diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index bed71e1..366a42b 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -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 { + 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."); + } + } } \ No newline at end of file diff --git a/src/presentation/notices/NoticeService.ts b/src/presentation/notices/NoticeService.ts index f866761..4cc0d51 100644 --- a/src/presentation/notices/NoticeService.ts +++ b/src/presentation/notices/NoticeService.ts @@ -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); + } } } \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index f8a6745..a8a9a02 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -124,6 +124,7 @@ const { class HoistedFakeTextComponent { public value = ""; + private onChangeHandler?: (value: string) => void | Promise; setPlaceholder(value: string): this { void value; @@ -136,13 +137,19 @@ const { } onChange(callback: (value: string) => void | Promise): this { - void callback; + this.onChangeHandler = callback; return this; } + + async triggerChange(value: string): Promise { + this.value = value; + await this.onChangeHandler?.(value); + } } class HoistedFakeToggleComponent { public value = false; + private onChangeHandler?: (value: boolean) => void | Promise; setValue(value: boolean): this { this.value = value; @@ -150,9 +157,14 @@ const { } onChange(callback: (value: boolean) => void | Promise): this { - void callback; + this.onChangeHandler = callback; return this; } + + async triggerChange(value: boolean): Promise { + 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; type FakeButtonComponentInstance = InstanceType; type FakeDropdownComponentInstance = InstanceType; type FakeElementInstance = InstanceType; +type FakeTextComponentInstance = { + value: string; + triggerChange(value: string): Promise; +}; +type FakeToggleComponentInstance = { + value: boolean; + triggerChange(value: boolean): Promise; +}; 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 { + 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) && "value" in (control as Record) && !(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) && "value" in (control as Record) && !(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); + }); }); \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index 8cd7374..23c5751 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -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 | 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(); 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(); }); diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index b8db066..0ebf08d 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -208,6 +208,11 @@ export class FakeManualSyncAnkiGateway implements AnkiGateway { export function createModule3Settings(overrides: Partial = {}): PluginSettings { return { ...DEFAULT_SETTINGS, + fileDeckEnabled: true, + fileDeckMarker: "TARGET DECK", + fileDeckTemplate: "obsidian::filename", + fileDeckInsertLocation: "body", + folderDeckMode: "folder", noteFieldMappings: { "basic:Basic": { cardType: "basic",