From cb968b1d178205ec067ef1700d8c1d76d5ebdf61 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 13:42:01 +0800 Subject: [PATCH] feat: redesign folder scope settings --- docs/implementation-decisions.md | 108 ++++----- docs/implementation-gap-report.md | 136 ++++-------- src/application/config/PluginSettings.test.ts | 9 + src/application/config/PluginSettings.ts | 23 ++ src/application/dto/FolderTreeNode.ts | 5 + .../ports/ManualSyncVaultGateway.ts | 2 + .../services/FileIndexerService.test.ts | 1 + .../services/FileIndexerService.ts | 7 +- .../services/ManualSyncService.test.ts | 144 +++++++++++- src/application/services/ManualSyncService.ts | 14 ++ .../services/ScanScopeService.test.ts | 24 +- src/application/services/ScanScopeService.ts | 35 ++- .../use-cases/ScanAndPlanSyncUseCase.ts | 2 +- .../use-cases/SyncVaultUseCase.test.ts | 4 +- .../services/CardIndexingService.test.ts | 132 +++++++---- .../services/CardIndexingService.ts | 42 ++++ .../services/DiffPlannerService.test.ts | 61 +++++ .../obsidian/ObsidianVaultGateway.test.ts | 40 ++++ .../obsidian/ObsidianVaultGateway.ts | 18 +- .../DataJsonPluginConfigRepository.test.ts | 5 +- src/presentation/AnkiHeadingSyncPlugin.ts | 19 +- .../settings/FolderScopeTree.test.ts | 62 ++++++ src/presentation/settings/FolderScopeTree.ts | 207 +++++++++++++++++ .../settings/PluginSettingTab.test.ts | 208 ++++++++++++++++-- src/presentation/settings/PluginSettingTab.ts | 157 +++++++++++-- src/test-support/manualSyncFakes.ts | 64 ++++++ src/test-support/obsidianStub.ts | 12 + 27 files changed, 1292 insertions(+), 249 deletions(-) create mode 100644 src/application/dto/FolderTreeNode.ts create mode 100644 src/infrastructure/obsidian/ObsidianVaultGateway.test.ts create mode 100644 src/presentation/settings/FolderScopeTree.test.ts create mode 100644 src/presentation/settings/FolderScopeTree.ts diff --git a/docs/implementation-decisions.md b/docs/implementation-decisions.md index 7eaa829..0d0133d 100644 --- a/docs/implementation-decisions.md +++ b/docs/implementation-decisions.md @@ -1,80 +1,58 @@ -# 模块 3 实现决策 +# 模块 4 实现决策 -本文锁定模块 3 的具体实现决策,范围以 `docs/2026-04-18PLAN4.md` 为准。 +本文锁定本轮“文件夹范围设置重做”为三模式 + 文件夹树勾选的具体实现决策。 -## 1. 主链路替换策略 +## 1. 设置模型 -- 模块 3 采用新的索引/状态/规划/执行主链路。 -- 现有模块 1/2 代码保留在仓库中,但插件命令入口改接模块 3 use case。 -- 不为旧 `legacy cardKey` / `embeddedNoteId` 做兼容适配。 +- `PluginSettings` 新增 `scopeMode: "all" | "include" | "exclude"` +- 默认值固定为 `"all"` +- `includeFolders / excludeFolders` 继续保留为持久化数组 +- 旧数据加载时不迁移、不清空旧数组,只是默认按 `scopeMode = "all"` 生效 -## 2. marker 与身份 +## 2. 范围语义 -- 统一 marker 格式:`` -- 允许仅写 `cardId`:`` -- `cardId` 采用 `ahs_` 前缀加随机唯一片段生成 -- 缺失 marker 的块在用户执行命令时生成 `cardId`,平时不自动回写 +- `all`:忽略 `includeFolders / excludeFolders`,扫描全部 Markdown 文件 +- `include`:只扫描 `includeFolders` 中的文件夹及其子文件夹 +- `exclude`:扫描全部 Markdown 文件,但排除 `excludeFolders` 中的文件夹及其子文件夹 +- 不再保留“include 为空即全库”的隐式规则 -## 3. 文件级跳过实现 +## 3. 文件夹树接口 -- 为满足“先过滤路径,再读文件内容”,新增文件元数据扫描接口 -- 文件状态除计划要求的 `fileHash` 外,额外保存内部 `fileStamp` -- `fileStamp` 采用 `mtime:size`,只用于决定是否需要重新读取文件 -- `fileHash` 在真正读取文件内容后计算并持久化 +- 在 `ManualSyncVaultGateway` 上新增只读 `listFolderTree()` +- `FolderTreeNode` 只包含: + - `path` + - `name` + - `children` +- `ObsidianVaultGateway` 直接从 vault 根目录递归收集真实文件夹 +- 返回根目录的子文件夹列表,根目录本身不作为可勾选节点 -## 4. pending write-back 恢复策略 +## 4. 设置页交互 -- 模块 3 的正式身份只认 marker 中的 `cardId` -- 若 marker 丢失,即视为新卡片块,重新生成新的 `cardId` -- pending write-back 仅在 marker 仍保留 `cardId` 时恢复对应 `noteId` -- 不按 `rawBlockHash` 对无 marker 块做长期身份复绑 +- 范围设置使用一个固定模式下拉: + - `全部文件` + - `仅在指定文件夹` + - `排除指定文件夹` +- `scopeMode = all` 时隐藏文件夹树 +- `scopeMode = include / exclude` 时展示树状复选框 +- 目录树按 Windows 风格三态规则运行:全选、半选、未选 -## 5. 渲染策略 +## 5. 选择状态与保存格式 -- 扫描阶段只建立 `IndexedFile/IndexedCard` -- 仅 `toCreate / toUpdate` 进入 renderer -- `renderConfigHash` 独立计算,不与 `rawBlockHash` 混用 -- 现有 markdown 渲染逻辑保留并抽到新的模块 3 渲染服务里复用 +- 三态勾选与最小集合压缩放到独立纯函数中处理,而不是散落在 UI 事件回调里 +- 保存时始终压缩为最小集合: + - 父目录完整选中则只保存父目录 + - 只选部分子目录则只保存子目录 +- 不支持在 UI 中手工输入任意路径 -## 6. Anki 批处理策略 +## 6. 当前文件同步门禁 -- 新增 `BatchScheduler` -- `notesInfo / addNotes / updateNoteFields / changeDeck / storeMedia` 按批执行 -- 默认使用保守常量: - - `notesInfo` batch size 100 - - `addNotes / updateNoteFields` batch size 50 - - `storeMedia` batch size 10,并发 3 -- 不为当前模块额外开放用户设置项,先保持范围收敛 +- 当前文件同步仍保留命令入口 +- 范围检查放到 `ManualSyncService.syncFile()` 中,避免只有 UI 层知道范围规则 +- 若当前文件不在范围内,抛出专用错误,由插件入口转成 info notice:`当前文件不在插件作用范围内` -## 7. 本地状态结构 +## 7. 范围控制 -- 新状态统一存入 `pluginState` -- 结构包含: - - `files` - - `cards` - - `pendingWriteBack` -- 当前实现不做旧 `syncRegistry` 自动迁移;旧数据保留但模块 3 不读取 - -## 8. rebuild index - -- rebuild index 不调用 Anki 字段更新 -- 它会: - - 全量重建文件状态与卡片状态 - - 为缺失 `cardId` 的块补齐 marker - - 仅对仍保留 `cardId` 的块恢复本地已知 `noteId` - -## 9. 命令与提示 - -- 命令名称按计划使用中文: - - `同步当前文件到 Anki` - - `同步全库到 Anki` - - `重建卡片索引` -- Notice 展示:扫描文件数、扫描卡片数、新增、更新、orphan、media、跳过未变化卡数 -- 若有 markdown 回写冲突,明确列出失败文件 - -## 10. 范围控制 - -- 不实现旧插件迁移 -- 不实现双向同步 -- 不实现实时监听自动同步 -- 不为 tags 引入额外语法;当前阶段 `tagsHint` 为空数组,但批处理执行层保留接口位置 \ No newline at end of file +- 本模块只改“文件夹范围设置”相关能力 +- 不改 deck、note type、字段映射设置 +- 不引入单文件路径白名单/黑名单 +- 不改 marker 写回与手动同步主流程 \ No newline at end of file diff --git a/docs/implementation-gap-report.md b/docs/implementation-gap-report.md index 6b8d2d2..ee7c670 100644 --- a/docs/implementation-gap-report.md +++ b/docs/implementation-gap-report.md @@ -1,144 +1,100 @@ -# 模块 3 实现差距报告 +# 模块 4 实现差距报告 -本文以 `docs/2026-04-18PLAN4.md` 为唯一实现基准,对当前仓库实现做差距审计。 +本文以本轮“文件夹范围设置重做为三模式 + 文件夹树勾选”的计划为唯一实现基准,对当前仓库做差距审计。 ## 结论 -当前仓库主体仍停留在模块 1/2 的架构: +当前仓库已经有基础的文件夹过滤能力,但仍停留在“高级用户手填路径”的旧交互: -- 身份模型仍以 `cardKey` 和 `embeddedNoteId` 为核心,而不是 `cardId + noteId` -- 扫描阶段会对全部卡片立即 render,而不是先索引、后规划、再延迟渲染 -- 本地状态仍是 `syncRegistry` 单层结构,缺失文件层状态和独立 pending write-back 层 -- Anki 执行除了 `notesInfo` 外基本仍是逐卡顺序调用 -- 命令层缺少 `重建卡片索引` +- `PluginSettings` 只有 `includeFolders / excludeFolders`,没有显式 `scopeMode` +- 设置页仍是两个逗号分隔文本框 +- `ScanScopeService` 仍依赖“include 为空即全库”的隐式规则 +- 仓库里没有“列出 vault 文件夹树”的接口 +- 当前文件同步命令没有范围门禁,不会在超出范围时直接跳过 -模块 3 不能在当前 `legacy-card-key` 体系上继续增量补丁,必须引入新的索引、状态、规划和执行主链路。 +这意味着当前实现只覆盖了“底层路径过滤”的一部分,尚未完成计划要求的三模式语义、面向小白的树状勾选交互,以及当前文件同步的范围收口。 ## 主要差距 -### 1. 身份与 marker +### 1. 设置模型不完整 当前实现: -- `CardIdentityPolicy` 生成 `cardKey` -- `HeadingSyncMarkerService` 只写 `` -- `CardExtractionService` 只解析旧 marker 里的 `noteId` +- `PluginSettings` 只保存 `includeFolders / excludeFolders` +- `DataJsonPluginConfigRepository` 也只合并这两个数组 与计划冲突: -- 缺失永久 `cardId` -- marker 格式错误,未写入 `cardId` -- 仍保留 `embeddedNoteId` / `identityMode` 这类模块 2 兼容逻辑 +- 缺失显式 `scopeMode: all | include | exclude` +- 旧设置加载时无法明确区分“全库”与“仅 include 为空” -### 2. 文件发现与索引 +### 2. 设置页交互仍是文本输入 当前实现: -- `ScanAndPlanSyncUseCase` 先取所有 markdown 内容,再过滤,再提取,再 render +- [src/presentation/settings/PluginSettingTab.ts](src/presentation/settings/PluginSettingTab.ts) 仍渲染 `Include folders` / `Exclude folders` 文本框 与计划冲突: -- 没有路径先过滤再读文件的流程 -- 没有文件级跳过 -- 没有文件层 `fileHash/lastIndexedAt/cardIds[]` -- 没有卡片级 `rawBlockHash` +- 不符合“三模式 + 文件夹树勾选”的交互 +- 对新手用户不友好 +- 不支持三态父子联动与最小集合保存 -### 3. 本地状态 +### 3. 缺少 vault 文件夹树数据源 当前实现: -- 仅有 `SyncRegistry` -- 仓库存储只有 `syncRegistry.records` +- [src/infrastructure/obsidian/ObsidianVaultGateway.ts](src/infrastructure/obsidian/ObsidianVaultGateway.ts) 只支持 Markdown 文件读取和写回 与计划冲突: -- 缺失 `files` -- 缺失新的 `cards` -- 缺失独立 `pendingWriteBack` -- 缺失 `renderConfigHash` +- 缺失只读 `listFolderTree()` 接口 +- 设置页无法展示真实 vault 文件夹层级 +- 无法验证“空文件夹也显示、根目录不勾选”的计划要求 -### 4. Planner +### 4. 范围判定语义仍是隐式的 当前实现: -- `SyncPlanningService` 只产出 `toAdd / toUpdate / toMarkOrphan` +- [src/application/services/ScanScopeService.ts](src/application/services/ScanScopeService.ts) 以“include 为空”推断全库 与计划冲突: -- 缺失 `toRewriteMarker` -- `toUpdate` 依据的是旧 `contentHash`,不是 `rawBlockHash + renderConfigHash` -- 仍包含 `embeddedNoteId` 的特殊逻辑 +- `all / include / exclude` 不是显式行为 +- `includeFolders` 和 `excludeFolders` 同时存在时语义不够收敛 -### 5. Renderer +### 5. 当前文件同步未做范围收口 当前实现: -- 所有卡片都在扫描阶段立即 render +- 当前文件命令直接进入 `ManualSyncService.syncFile()` 与计划冲突: -- 未实现“只 render `toCreate / toUpdate`” -- 缺失 `renderConfigHash` -- 渲染输出与原始块变化耦合在一个 `contentHash` 中 +- 当前文件不在范围内时,计划要求直接提示并跳过 +- 现在不会在命令层给出明确的“当前文件不在插件作用范围内”反馈 -### 6. Anki 执行层 +### 6. 测试覆盖不足 当前实现: -- `notesInfo` 已批量化 -- `addNote / updateNote / storeMedia` 仍主要是逐卡/逐资源调用 +- 只有旧的 `ScanScopeService` 行为测试 +- 设置页测试只覆盖 note type / field mapping 与计划冲突: -- 缺少批处理调度器 -- 缺少按操作类型分批执行 -- 缺少 deck 分组 `changeDeck` -- 缺少明确的媒体并发控制 - -### 7. Markdown 回写 - -当前实现: - -- 已有“同文件一次写回、自底向上写回”的局部能力 - -与计划冲突: - -- marker 仍是旧格式 -- pending write-back 仍挂在旧 `identityMode` -- 结果汇总里缺少回写冲突文件明细 - -### 8. 命令与 UX - -当前实现: - -- 只有“同步当前文件”和“同步全库”命令 -- Notice 仅展示少量计数 - -与计划冲突: - -- 缺少“重建卡片索引” -- 结果 notice 缺少扫描文件数、扫描卡片数、跳过数、回写冲突文件明细 - -### 9. 测试 - -当前实现: - -- 模块 1/2 的 extraction/planning/write-back 测试较多 - -与计划冲突: - -- 缺少 `cardId` 生成与 marker 新格式测试 -- 缺少文件级跳过测试 -- 缺少 `renderConfigHash` 驱动 update 测试 -- 缺少 rebuild index 测试 -- 缺少批处理调度与结果汇总测试 +- 缺少 `scopeMode` 兼容加载测试 +- 缺少文件夹树读取测试 +- 缺少三态树选择与最小集合保存测试 +- 缺少 UI 回归测试 +- 缺少当前文件超范围跳过测试 ## 修复方向 -模块 3 将按以下方向落地: +模块 4 将按以下方向落地: -1. 新建 `cardId + noteId` 为核心的 marker / index / state / planner / executor 主链路 -2. 保留现有 markdown 渲染逻辑中可复用的部分,但改为延迟渲染 -3. 新增文件层状态与 pending write-back 层 -4. 新增 rebuild index 命令与 use case -5. 用新的同步 use case 接管插件命令入口 \ No newline at end of file +1. 为 `PluginSettings` 增加显式 `scopeMode`,默认值为 `all` +2. 为 Obsidian vault gateway 增加只读文件夹树接口 +3. 将设置页的两个文本框改为“模式选择 + 树状勾选” +4. 用纯函数收口三态树与最小集合保存逻辑,避免把选择算法揉进 UI 代码 +5. 将范围门禁加到当前文件同步链路,并在命令层显示明确 notice \ No newline at end of file diff --git a/src/application/config/PluginSettings.test.ts b/src/application/config/PluginSettings.test.ts index 05dac37..5574b01 100644 --- a/src/application/config/PluginSettings.test.ts +++ b/src/application/config/PluginSettings.test.ts @@ -16,4 +16,13 @@ describe("PluginSettings", () => { }), ).toThrow("QA and Cloze heading levels must be different."); }); + + it("rejects invalid scope modes", () => { + expect(() => + validatePluginSettings({ + ...DEFAULT_SETTINGS, + scopeMode: "invalid" as never, + }), + ).toThrow("Scope mode must be one of all, include, or exclude."); + }); }); \ No newline at end of file diff --git a/src/application/config/PluginSettings.ts b/src/application/config/PluginSettings.ts index 5c52991..6a8b579 100644 --- a/src/application/config/PluginSettings.ts +++ b/src/application/config/PluginSettings.ts @@ -1,5 +1,7 @@ import type { NoteModelFieldMapping } from "@/application/config/NoteModelFieldMapping"; +export type ScopeMode = "all" | "include" | "exclude"; + export interface PluginSettings { qaHeadingLevel: number; clozeHeadingLevel: number; @@ -7,6 +9,7 @@ export interface PluginSettings { clozeNoteType: string; noteFieldMappings: Record; defaultDeck: string; + scopeMode: ScopeMode; includeFolders: string[]; excludeFolders: string[]; addObsidianBacklink: boolean; @@ -21,6 +24,7 @@ export const DEFAULT_SETTINGS: PluginSettings = { clozeNoteType: "Cloze", noteFieldMappings: {}, defaultDeck: "Obsidian", + scopeMode: "all", includeFolders: [], excludeFolders: [], addObsidianBacklink: true, @@ -55,11 +59,30 @@ export function validatePluginSettings(settings: PluginSettings): void { throw new Error("Default deck is required."); } + if (settings.scopeMode !== "all" && settings.scopeMode !== "include" && settings.scopeMode !== "exclude") { + throw new Error("Scope mode must be one of all, include, or exclude."); + } + + validateFolderList(settings.includeFolders, "Include folders"); + validateFolderList(settings.excludeFolders, "Exclude folders"); + if (!settings.ankiConnectUrl.trim()) { throw new Error("AnkiConnect URL is required."); } } +function validateFolderList(folderList: string[], label: string): void { + if (!Array.isArray(folderList)) { + throw new Error(`${label} must be an array.`); + } + + for (const folder of folderList) { + if (typeof folder !== "string") { + throw new Error(`${label} must only contain strings.`); + } + } +} + function validateNoteFieldMappings(noteFieldMappings: Record): void { if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) { throw new Error("Note field mappings must be an object."); diff --git a/src/application/dto/FolderTreeNode.ts b/src/application/dto/FolderTreeNode.ts new file mode 100644 index 0000000..be34135 --- /dev/null +++ b/src/application/dto/FolderTreeNode.ts @@ -0,0 +1,5 @@ +export interface FolderTreeNode { + path: string; + name: string; + children: FolderTreeNode[]; +} \ No newline at end of file diff --git a/src/application/ports/ManualSyncVaultGateway.ts b/src/application/ports/ManualSyncVaultGateway.ts index b09b9a5..20d77cd 100644 --- a/src/application/ports/ManualSyncVaultGateway.ts +++ b/src/application/ports/ManualSyncVaultGateway.ts @@ -1,3 +1,4 @@ +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { SourceFile } from "@/domain/card/entities/SourceFile"; import type { RenderResourceResolver } from "@/domain/card/ports/RenderResourceResolver"; @@ -9,6 +10,7 @@ export interface MarkdownFileReference { } export interface ManualSyncVaultGateway extends RenderResourceResolver { + listFolderTree(): Promise; listMarkdownFileRefs(): Promise; readMarkdownFile(path: string): Promise; replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise; diff --git a/src/application/services/FileIndexerService.test.ts b/src/application/services/FileIndexerService.test.ts index 296f353..ce7405b 100644 --- a/src/application/services/FileIndexerService.test.ts +++ b/src/application/services/FileIndexerService.test.ts @@ -15,6 +15,7 @@ describe("FileIndexerService", () => { await service.indexVault( createModule3Settings({ + scopeMode: "include", includeFolders: ["keep"], }), createEmptyPluginState(), diff --git a/src/application/services/FileIndexerService.ts b/src/application/services/FileIndexerService.ts index e7a8e92..878be95 100644 --- a/src/application/services/FileIndexerService.ts +++ b/src/application/services/FileIndexerService.ts @@ -28,7 +28,12 @@ export class FileIndexerService { ) {} async indexVault(settings: PluginSettings, state: PluginState, forceReadAll = false): Promise { - const refs = this.scanScopeService.filter(await this.vaultGateway.listMarkdownFileRefs(), settings.includeFolders, settings.excludeFolders); + const refs = this.scanScopeService.filter( + await this.vaultGateway.listMarkdownFileRefs(), + settings.scopeMode, + settings.includeFolders, + settings.excludeFolders, + ); return this.indexRefs(refs, settings, state, this.buildStateIndex(state), forceReadAll); } diff --git a/src/application/services/ManualSyncService.test.ts b/src/application/services/ManualSyncService.test.ts index e1d50fd..05dafda 100644 --- a/src/application/services/ManualSyncService.test.ts +++ b/src/application/services/ManualSyncService.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { ManualSyncService } from "./ManualSyncService"; +import type { PluginSettings } from "@/application/config/PluginSettings"; +import { RenderConfigService } from "@/application/services/RenderConfigService"; +import type { CardState } from "@/domain/manual-sync/entities/PluginState"; +import { hashString } from "@/domain/shared/hash"; + +import { CurrentFileOutOfScopeError, ManualSyncService } from "./ManualSyncService"; import { createModule3Settings, FakeManualSyncAnkiGateway, FakeManualSyncVaultGateway, InMemoryPluginStateRepository } from "@/test-support/manualSyncFakes"; describe("ManualSyncService", () => { @@ -54,4 +59,139 @@ describe("ManualSyncService", () => { expect(vaultGateway.getFileContent("notes/example.md")).toContain(""); + }); + + it("creates a new Anki note when marker was deleted and the card content changed", async () => { + const settings = createModule3Settings(); + const vaultGateway = new FakeManualSyncVaultGateway({ + "notes/example.md": ["#### Prompt", "Updated Answer"].join("\n"), + }); + const stateRepository = new InMemoryPluginStateRepository({ + files: {}, + cards: { + ahs_known: createStoredSyncedCard(settings), + }, + pendingWriteBack: [], + }); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); + + const result = await service.syncFile("notes/example.md", settings); + + expect(result.created).toBe(1); + expect(ankiGateway.addedNotes).toHaveLength(1); + expect(vaultGateway.getFileContent("notes/example.md")).toContain("note=9001"); + expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("AHS:card=ahs_known note=42"); + expect(stateRepository.savedState?.cards.ahs_known?.orphan).toBe(true); + expect( + Object.values(stateRepository.savedState?.cards ?? {}).some((card) => card.cardId !== "ahs_known" && card.noteId === 9001 && !card.orphan), + ).toBe(true); + }); + + 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"), + }); + const stateRepository = new InMemoryPluginStateRepository(); + const ankiGateway = new FakeManualSyncAnkiGateway(); + const service = new ManualSyncService(vaultGateway, stateRepository, ankiGateway, undefined, undefined, undefined, undefined, undefined, undefined, () => 1234); + + await expect( + service.syncFile( + "outside/example.md", + createModule3Settings({ + scopeMode: "include", + includeFolders: ["notes"], + }), + ), + ).rejects.toBeInstanceOf(CurrentFileOutOfScopeError); + + expect(vaultGateway.readCalls).toEqual([]); + expect(ankiGateway.addedNotes).toHaveLength(0); + }); +}); + +function createStoredSyncedCard(settings: PluginSettings, overrides: Partial = {}): CardState { + const heading = overrides.heading ?? "Prompt"; + const bodyMarkdown = overrides.bodyMarkdown ?? "Answer"; + const rawBlockText = overrides.rawBlockText ?? [`#### ${heading}`, bodyMarkdown].join("\n"); + const rawBlockHash = overrides.rawBlockHash ?? hashString(rawBlockText); + const indexedCard = { + cardId: overrides.cardId ?? "ahs_known", + noteId: overrides.noteId ?? 42, + markerNoteId: overrides.noteId ?? 42, + filePath: overrides.filePath ?? "notes/example.md", + cardType: overrides.cardType ?? "basic", + heading, + headingLevel: overrides.headingLevel ?? 4, + bodyMarkdown, + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? rawBlockText.length, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 2, + contentEndLine: overrides.contentEndLine ?? 2, + markerLine: overrides.markerLine, + rawBlockText, + rawBlockHash, + deckHint: overrides.deckHint, + tagsHint: overrides.tagsHint ?? [], + markerState: "card-and-note" as const, + }; + const renderPlan = new RenderConfigService().resolve(indexedCard, settings); + + return { + cardId: indexedCard.cardId, + noteId: indexedCard.noteId, + filePath: indexedCard.filePath, + heading: indexedCard.heading, + headingLevel: indexedCard.headingLevel, + bodyMarkdown: indexedCard.bodyMarkdown, + cardType: indexedCard.cardType, + blockStartOffset: indexedCard.blockStartOffset, + blockEndOffset: indexedCard.blockEndOffset, + blockStartLine: indexedCard.blockStartLine, + bodyStartLine: indexedCard.bodyStartLine, + blockEndLine: indexedCard.blockEndLine, + contentEndLine: indexedCard.contentEndLine, + markerLine: indexedCard.markerLine, + rawBlockText: indexedCard.rawBlockText, + rawBlockHash: indexedCard.rawBlockHash, + renderConfigHash: overrides.renderConfigHash ?? renderPlan.renderConfigHash, + deck: overrides.deck ?? renderPlan.deck, + deckHint: indexedCard.deckHint, + tagsHint: indexedCard.tagsHint, + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; +} \ No newline at end of file diff --git a/src/application/services/ManualSyncService.ts b/src/application/services/ManualSyncService.ts index 0e92336..4622de0 100644 --- a/src/application/services/ManualSyncService.ts +++ b/src/application/services/ManualSyncService.ts @@ -7,6 +7,7 @@ import { AnkiBatchExecutor } from "@/application/services/AnkiBatchExecutor"; import { FileIndexerService } from "@/application/services/FileIndexerService"; import { MarkdownWriteBackService } from "@/application/services/MarkdownWriteBackService"; import { RenderConfigService } from "@/application/services/RenderConfigService"; +import { ScanScopeService } from "@/application/services/ScanScopeService"; import type { IndexedCard } from "@/domain/manual-sync/entities/IndexedCard"; import type { PluginState } from "@/domain/manual-sync/entities/PluginState"; import type { RenderedSyncCard } from "@/domain/manual-sync/entities/RenderedSyncCard"; @@ -15,7 +16,16 @@ import { ManualCardRenderer, type ManualCardRenderContext } from "@/domain/manua import type { ManualSyncResult } from "@/application/use-cases/manualSyncTypes"; +export class CurrentFileOutOfScopeError extends Error { + constructor(filePath: string) { + super(`当前文件不在插件作用范围内: ${filePath}`); + this.name = "CurrentFileOutOfScopeError"; + } +} + export class ManualSyncService { + private readonly scanScopeService = new ScanScopeService(); + constructor( private readonly vaultGateway: ManualSyncVaultGateway, private readonly pluginStateRepository: PluginStateRepository, @@ -38,6 +48,10 @@ export class ManualSyncService { async syncFile(filePath: string, settings: PluginSettings): Promise { validatePluginSettings(settings); + if (!this.scanScopeService.isPathInScope(filePath, settings.scopeMode, settings.includeFolders, settings.excludeFolders)) { + throw new CurrentFileOutOfScopeError(filePath); + } + const state = await this.pluginStateRepository.load(); const indexResult = await this.fileIndexerService.indexFile(filePath, settings, state); return this.syncIndexedResult(indexResult, state, settings); diff --git a/src/application/services/ScanScopeService.test.ts b/src/application/services/ScanScopeService.test.ts index 0b9ea06..f80f744 100644 --- a/src/application/services/ScanScopeService.test.ts +++ b/src/application/services/ScanScopeService.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { ScanScopeService } from "./ScanScopeService"; describe("ScanScopeService", () => { - it("scans the whole vault when includeFolders is empty", () => { + it("scans the whole vault when scopeMode is all", () => { const service = new ScanScopeService(); const files = service.filter( [ @@ -11,6 +11,7 @@ describe("ScanScopeService", () => { { path: "notes/two.md", basename: "two", content: "" }, { path: "notes/three.txt", basename: "three", content: "" }, ], + "all", [], [], ); @@ -18,7 +19,7 @@ describe("ScanScopeService", () => { expect(files.map((file) => file.path)).toEqual(["notes/one.md", "notes/two.md"]); }); - it("applies includeFolders and excludeFolders together", () => { + it("only scans folders selected by include mode", () => { const service = new ScanScopeService(); const files = service.filter( [ @@ -26,10 +27,27 @@ describe("ScanScopeService", () => { { path: "cards/archive/b.md", basename: "b", content: "" }, { path: "other/c.md", basename: "c", content: "" }, ], + "include", ["cards"], + [], + ); + + expect(files.map((file) => file.path)).toEqual(["cards/a.md", "cards/archive/b.md"]); + }); + + it("skips excluded folders in exclude mode", () => { + const service = new ScanScopeService(); + const files = service.filter( + [ + { path: "cards/a.md", basename: "a", content: "" }, + { path: "cards/archive/b.md", basename: "b", content: "" }, + { path: "other/c.md", basename: "c", content: "" }, + ], + "exclude", + [], ["cards/archive"], ); - expect(files.map((file) => file.path)).toEqual(["cards/a.md"]); + expect(files.map((file) => file.path)).toEqual(["cards/a.md", "other/c.md"]); }); }); \ No newline at end of file diff --git a/src/application/services/ScanScopeService.ts b/src/application/services/ScanScopeService.ts index 0a979a5..0503b51 100644 --- a/src/application/services/ScanScopeService.ts +++ b/src/application/services/ScanScopeService.ts @@ -1,21 +1,38 @@ +import type { ScopeMode } from "@/application/config/PluginSettings"; + export class ScanScopeService { - filter(files: T[], includeFolders: string[], excludeFolders: string[]): T[] { - const normalizedIncludes = includeFolders.map(normalizeFolderPath).filter(Boolean); - const normalizedExcludes = excludeFolders.map(normalizeFolderPath).filter(Boolean); + filter(files: T[], scopeMode: ScopeMode, includeFolders: string[], excludeFolders: string[]): T[] { + const normalizedIncludes = normalizeFolderList(includeFolders); + const normalizedExcludes = normalizeFolderList(excludeFolders); return files.filter((file) => { if (!file.path.toLowerCase().endsWith(".md")) { return false; } - const normalizedPath = normalizeFilePath(file.path); - const included = - normalizedIncludes.length === 0 || normalizedIncludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); - const excluded = normalizedExcludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); - - return included && !excluded; + return this.isPathInScope(file.path, scopeMode, normalizedIncludes, normalizedExcludes); }); } + + isPathInScope(filePath: string, scopeMode: ScopeMode, includeFolders: string[], excludeFolders: string[]): boolean { + const normalizedPath = normalizeFilePath(filePath); + const normalizedIncludes = normalizeFolderList(includeFolders); + const normalizedExcludes = normalizeFolderList(excludeFolders); + + if (scopeMode === "include") { + return normalizedIncludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); + } + + if (scopeMode === "exclude") { + return !normalizedExcludes.some((folder) => isPathInsideFolder(normalizedPath, folder)); + } + + return true; + } +} + +function normalizeFolderList(folderPaths: string[]): string[] { + return folderPaths.map(normalizeFolderPath).filter(Boolean); } function normalizeFolderPath(folderPath: string): string { diff --git a/src/application/use-cases/ScanAndPlanSyncUseCase.ts b/src/application/use-cases/ScanAndPlanSyncUseCase.ts index bdaa3a3..bb7a2ca 100644 --- a/src/application/use-cases/ScanAndPlanSyncUseCase.ts +++ b/src/application/use-cases/ScanAndPlanSyncUseCase.ts @@ -23,7 +23,7 @@ export class ScanAndPlanSyncUseCase { validatePluginSettings(settings); const markdownFiles = await this.vaultGateway.listMarkdownFiles(); - const scopedFiles = this.scanScopeService.filter(markdownFiles, settings.includeFolders, settings.excludeFolders); + const scopedFiles = this.scanScopeService.filter(markdownFiles, settings.scopeMode, settings.includeFolders, settings.excludeFolders); return this.scanFiles(scopedFiles, settings); } diff --git a/src/application/use-cases/SyncVaultUseCase.test.ts b/src/application/use-cases/SyncVaultUseCase.test.ts index eddb865..fb2bbed 100644 --- a/src/application/use-cases/SyncVaultUseCase.test.ts +++ b/src/application/use-cases/SyncVaultUseCase.test.ts @@ -178,8 +178,8 @@ describe("SyncVaultUseCase", () => { const result = await useCase.execute( createSettings({ - includeFolders: ["cards"], - excludeFolders: ["cards/skip"], + scopeMode: "exclude", + excludeFolders: ["cards/skip", "outside"], }), ); const snapshot = store.readSnapshot(); diff --git a/src/domain/manual-sync/services/CardIndexingService.test.ts b/src/domain/manual-sync/services/CardIndexingService.test.ts index 1fd253d..f01f468 100644 --- a/src/domain/manual-sync/services/CardIndexingService.test.ts +++ b/src/domain/manual-sync/services/CardIndexingService.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { CardState } from "@/domain/manual-sync/entities/PluginState"; +import { hashString } from "@/domain/shared/hash"; + import { CardIndexingService } from "./CardIndexingService"; describe("CardIndexingService", () => { @@ -26,29 +29,46 @@ describe("CardIndexingService", () => { expect(indexedFile.cards[0]?.bodyMarkdown).toBe("Answer"); }); - it("treats a missing marker as a new card even if local pending state has the same raw block hash", () => { + it("reuses cardId and noteId when marker is missing but the same file has a unique raw block hash match", () => { const service = new CardIndexingService(); + const rawBlockText = ["#### Prompt", "Answer"].join("\n"); const indexedFile = service.index( { path: "notes/example.md", basename: "example", - content: ["#### Prompt", "Answer"].join("\n"), + content: rawBlockText, }, { qaHeadingLevel: 4, clozeHeadingLevel: 5, fileStamp: "1:1", - knownCards: [], - pendingWriteBack: [ - { - filePath: "notes/example.md", - cardId: "ahs_known", - noteId: 42, - expectedFileHash: "hash", - targetMarker: "", - rawBlockHash: "hash-card", - }, - ], + knownCards: [createKnownCardState({ rawBlockHash: hashString(rawBlockText) })], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards[0]).toMatchObject({ + cardId: "ahs_known", + noteId: 42, + markerState: "missing", + }); + }); + + it("treats a missing marker as a new card when the raw block hash changed", () => { + const service = new CardIndexingService(); + const oldRawBlockText = ["#### Prompt", "Answer"].join("\n"); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: ["#### Prompt", "Updated Answer"].join("\n"), + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + fileStamp: "1:1", + knownCards: [createKnownCardState({ rawBlockHash: hashString(oldRawBlockText) })], + pendingWriteBack: [], }, ); @@ -56,8 +76,37 @@ describe("CardIndexingService", () => { expect(indexedFile.cards[0]?.noteId).toBeUndefined(); }); - it("restores noteId when the marker still carries cardId", () => { + it("does not reuse card identity when multiple same-file candidates share the same raw block hash", () => { const service = new CardIndexingService(); + const rawBlockText = ["#### Prompt", "Answer"].join("\n"); + const rawBlockHash = hashString(rawBlockText); + const indexedFile = service.index( + { + path: "notes/example.md", + basename: "example", + content: rawBlockText, + }, + { + qaHeadingLevel: 4, + clozeHeadingLevel: 5, + fileStamp: "1:1", + knownCards: [ + createKnownCardState({ cardId: "ahs_known", rawBlockHash }), + createKnownCardState({ cardId: "ahs_other", noteId: 99, rawBlockHash }), + ], + pendingWriteBack: [], + }, + ); + + expect(indexedFile.cards[0]?.cardId).not.toBe("ahs_known"); + expect(indexedFile.cards[0]?.cardId).not.toBe("ahs_other"); + expect(indexedFile.cards[0]?.noteId).toBeUndefined(); + }); + + it("restores noteId from the marker cardId before considering hash-based recovery", () => { + const service = new CardIndexingService(); + const rawBlockText = ["#### Prompt", "Answer"].join("\n"); + const rawBlockHash = hashString(rawBlockText); const indexedFile = service.index( { path: "notes/example.md", @@ -69,28 +118,8 @@ describe("CardIndexingService", () => { clozeHeadingLevel: 5, fileStamp: "1:1", knownCards: [ - { - cardId: "ahs_known", - noteId: 42, - filePath: "notes/example.md", - heading: "Prompt", - headingLevel: 4, - bodyMarkdown: "Answer", - cardType: "basic", - blockStartOffset: 0, - blockEndOffset: 13, - blockStartLine: 1, - bodyStartLine: 2, - blockEndLine: 3, - contentEndLine: 2, - rawBlockText: ["#### Prompt", "Answer"].join("\n"), - rawBlockHash: "hash-card", - renderConfigHash: "render-hash", - deck: "Obsidian", - tagsHint: [], - lastSyncedAt: 1, - orphan: false, - }, + createKnownCardState({ cardId: "ahs_known", rawBlockHash }), + createKnownCardState({ cardId: "ahs_hash_match", noteId: 99, rawBlockHash }), ], pendingWriteBack: [], }, @@ -122,4 +151,33 @@ describe("CardIndexingService", () => { ), ).toThrow("Multiple AHS markers"); }); -}); \ No newline at end of file +}); + +function createKnownCardState(overrides: Partial = {}): CardState { + const rawBlockText = overrides.rawBlockText ?? ["#### Prompt", "Answer"].join("\n"); + + return { + cardId: overrides.cardId ?? "ahs_known", + noteId: overrides.noteId ?? 42, + filePath: overrides.filePath ?? "notes/example.md", + heading: overrides.heading ?? "Prompt", + headingLevel: overrides.headingLevel ?? 4, + bodyMarkdown: overrides.bodyMarkdown ?? "Answer", + cardType: overrides.cardType ?? "basic", + blockStartOffset: overrides.blockStartOffset ?? 0, + blockEndOffset: overrides.blockEndOffset ?? rawBlockText.length, + blockStartLine: overrides.blockStartLine ?? 1, + bodyStartLine: overrides.bodyStartLine ?? 2, + blockEndLine: overrides.blockEndLine ?? 2, + contentEndLine: overrides.contentEndLine ?? 2, + markerLine: overrides.markerLine, + rawBlockText, + rawBlockHash: overrides.rawBlockHash ?? hashString(rawBlockText), + renderConfigHash: overrides.renderConfigHash ?? "render-hash", + deck: overrides.deck ?? "Obsidian", + deckHint: overrides.deckHint, + tagsHint: overrides.tagsHint ?? [], + lastSyncedAt: overrides.lastSyncedAt ?? 1, + orphan: overrides.orphan ?? false, + }; +} \ No newline at end of file diff --git a/src/domain/manual-sync/services/CardIndexingService.ts b/src/domain/manual-sync/services/CardIndexingService.ts index 116af47..b9cd773 100644 --- a/src/domain/manual-sync/services/CardIndexingService.ts +++ b/src/domain/manual-sync/services/CardIndexingService.ts @@ -44,6 +44,7 @@ export class CardIndexingService { const targetDeck = extractTargetDeck(lines); const cards: IndexedCard[] = []; const knownCardsById = new Map(context.knownCards.map((card) => [card.cardId, card])); + const knownCardsByBlockKey = groupKnownCardsByBlockKey(context.knownCards); const pendingByCardId = new Map(context.pendingWriteBack.map((pending) => [pending.cardId, pending])); const usedCardIds = new Set(); @@ -62,10 +63,14 @@ export class CardIndexingService { const rawBlockText = [lines[heading.lineIndex], ...trimmedBodyLines].join("\n").trimEnd(); const rawBlockHash = hashString(rawBlockText); const resolvedIdentity = this.resolveIdentity( + sourceFile.path, + rawBlockHash, marker.markerCardId, marker.markerNoteId, knownCardsById, + knownCardsByBlockKey, pendingByCardId, + usedCardIds, ); usedCardIds.add(resolvedIdentity.cardId); @@ -105,10 +110,14 @@ export class CardIndexingService { } private resolveIdentity( + filePath: string, + rawBlockHash: string, markerCardId: string | undefined, markerNoteId: number | undefined, knownCardsById: Map, + knownCardsByBlockKey: Map, pendingByCardId: Map, + usedCardIds: Set, ): { cardId: string; noteId?: number } { if (markerCardId) { const pending = pendingByCardId.get(markerCardId); @@ -120,12 +129,45 @@ export class CardIndexingService { }; } + const knownMatches = knownCardsByBlockKey.get(createKnownCardBlockKey(filePath, rawBlockHash)) ?? []; + if (knownMatches.length === 1 && !usedCardIds.has(knownMatches[0].cardId)) { + return { + cardId: knownMatches[0].cardId, + noteId: knownMatches[0].noteId, + }; + } + return { cardId: this.markerService.generateCardId(), }; } } +function groupKnownCardsByBlockKey(knownCards: CardState[]): Map { + const grouped = new Map(); + + for (const card of knownCards) { + if (card.orphan) { + continue; + } + + const key = createKnownCardBlockKey(card.filePath, card.rawBlockHash); + const entries = grouped.get(key); + if (entries) { + entries.push(card); + continue; + } + + grouped.set(key, [card]); + } + + return grouped; +} + +function createKnownCardBlockKey(filePath: string, rawBlockHash: string): string { + return `${filePath}\u0000${rawBlockHash}`; +} + function validateHeadingPolicy(qaHeadingLevel: number, clozeHeadingLevel: number): void { if (!Number.isInteger(qaHeadingLevel) || qaHeadingLevel < 1 || qaHeadingLevel > 6) { throw new Error("QA heading level must be an integer between 1 and 6."); diff --git a/src/domain/manual-sync/services/DiffPlannerService.test.ts b/src/domain/manual-sync/services/DiffPlannerService.test.ts index 2a336aa..c3baffb 100644 --- a/src/domain/manual-sync/services/DiffPlannerService.test.ts +++ b/src/domain/manual-sync/services/DiffPlannerService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createModule3Settings } from "@/test-support/manualSyncFakes"; import { createEmptyPluginState } from "@/domain/manual-sync/entities/PluginState"; +import { RenderConfigService } from "@/application/services/RenderConfigService"; import { DiffPlannerService } from "./DiffPlannerService"; @@ -37,6 +38,66 @@ describe("DiffPlannerService", () => { expect(plan.toCreate).toHaveLength(1); }); + it("rewrites a missing marker without create or update when the restored card content is unchanged", () => { + const service = new DiffPlannerService(); + const settings = createModule3Settings(); + const card = { + cardId: "ahs_known", + noteId: 42, + filePath: "notes/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", + tagsHint: [], + markerState: "missing" as const, + }; + const renderPlan = new RenderConfigService().resolve(card, settings); + const state = { + files: {}, + cards: { + ahs_known: { + cardId: "ahs_known", + noteId: 42, + filePath: "notes/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: renderPlan.renderConfigHash, + deck: renderPlan.deck, + tagsHint: [], + lastSyncedAt: 1, + orphan: false, + }, + }, + pendingWriteBack: [], + }; + + const plan = service.plan([card], state, ["notes/example.md"], settings); + + expect(plan.toCreate).toHaveLength(0); + expect(plan.toUpdate).toHaveLength(0); + expect(plan.toRewriteMarker.map((plannedCard) => plannedCard.card.cardId)).toEqual(["ahs_known"]); + expect(plan.unchangedCards).toBe(1); + }); + it("uses rawBlockHash, renderConfigHash, and pending entries to schedule updates and rewrites", () => { const service = new DiffPlannerService(); const state = { diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts new file mode 100644 index 0000000..163d99f --- /dev/null +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { TFile, TFolder } from "obsidian"; + +import { ObsidianVaultGateway } from "./ObsidianVaultGateway"; + +describe("ObsidianVaultGateway", () => { + it("lists vault folders as a tree without exposing the root folder", async () => { + const FolderCtor = TFolder as unknown as new (path: string, children?: Array) => TFolder; + const FileCtor = TFile as unknown as new (path: string) => TFile; + const gateway = new ObsidianVaultGateway({ + vault: { + getRoot: () => + new FolderCtor("", [ + new FolderCtor("notes", [new FolderCtor("notes/sub", []), new FileCtor("notes/example.md")]), + new FolderCtor("empty", []), + new FileCtor("top.md"), + ]), + }, + } as never); + + await expect(gateway.listFolderTree()).resolves.toEqual([ + { + path: "notes", + name: "notes", + children: [ + { + path: "notes/sub", + name: "sub", + children: [], + }, + ], + }, + { + path: "empty", + name: "empty", + children: [], + }, + ]); + }); +}); \ No newline at end of file diff --git a/src/infrastructure/obsidian/ObsidianVaultGateway.ts b/src/infrastructure/obsidian/ObsidianVaultGateway.ts index 4df5ae6..f7fb7c8 100644 --- a/src/infrastructure/obsidian/ObsidianVaultGateway.ts +++ b/src/infrastructure/obsidian/ObsidianVaultGateway.ts @@ -1,6 +1,7 @@ -import { TFile } from "obsidian"; +import { TFile, TFolder } from "obsidian"; import type { App } from "obsidian"; +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { MarkdownFileReference, ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import { MarkdownFileNotFoundError, MarkdownWriteConflictError, type VaultGateway } from "@/application/ports/VaultGateway"; import { hashString } from "@/domain/shared/hash"; @@ -12,6 +13,13 @@ const AUDIO_EXTENSIONS = new Set(["wav", "m4a", "flac", "mp3", "wma", "aac", "we export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGateway { constructor(private readonly app: App) {} + async listFolderTree(): Promise { + return this.app.vault + .getRoot() + .children.filter((child): child is TFolder => child instanceof TFolder) + .map((folder) => this.toFolderTreeNode(folder)); + } + async listMarkdownFileRefs(): Promise { return this.app.vault.getMarkdownFiles().map((file) => ({ path: file.path, @@ -112,6 +120,14 @@ export class ObsidianVaultGateway implements VaultGateway, ManualSyncVaultGatewa private createObsidianUrl(target: string): string { return `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(target)}`; } + + private toFolderTreeNode(folder: TFolder): FolderTreeNode { + return { + path: folder.path, + name: folder.name, + children: folder.children.filter((child): child is TFolder => child instanceof TFolder).map((child) => this.toFolderTreeNode(child)), + }; + } } function parseLinkTarget(rawTarget: string): { alias?: string; linkPath: string } { diff --git a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts index 1257e27..6df1d68 100644 --- a/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts +++ b/src/infrastructure/persistence/DataJsonPluginConfigRepository.test.ts @@ -18,12 +18,13 @@ class InMemoryPluginDataStore implements PluginDataStore { } describe("DataJsonPluginConfigRepository", () => { - it("loads legacy settings snapshots without noteFieldMappings", async () => { + it("loads legacy settings snapshots without noteFieldMappings and defaults scopeMode to all", async () => { const repository = new DataJsonPluginConfigRepository( new InMemoryPluginDataStore({ settings: { qaNoteType: "Legacy Basic", clozeNoteType: "Legacy Cloze", + includeFolders: ["cards"], }, }), ); @@ -33,6 +34,8 @@ describe("DataJsonPluginConfigRepository", () => { expect(settings.qaNoteType).toBe("Legacy Basic"); expect(settings.clozeNoteType).toBe("Legacy Cloze"); expect(settings.noteFieldMappings).toEqual({}); + expect(settings.scopeMode).toBe("all"); + expect(settings.includeFolders).toEqual(["cards"]); }); it("persists note field mappings across save and reload", async () => { diff --git a/src/presentation/AnkiHeadingSyncPlugin.ts b/src/presentation/AnkiHeadingSyncPlugin.ts index 2acefc9..bed71e1 100644 --- a/src/presentation/AnkiHeadingSyncPlugin.ts +++ b/src/presentation/AnkiHeadingSyncPlugin.ts @@ -13,7 +13,9 @@ import { DataJsonPluginStateRepository } from "@/infrastructure/persistence/Data import { registerCommands } from "@/presentation/commands/registerCommands"; import { NoticeService } from "@/presentation/notices/NoticeService"; import { AnkiHeadingSyncSettingTab } from "@/presentation/settings/PluginSettingTab"; -import { ManualSyncService } from "@/application/services/ManualSyncService"; +import { CurrentFileOutOfScopeError, ManualSyncService } from "@/application/services/ManualSyncService"; +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; +import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; export default class AnkiHeadingSyncPlugin extends Plugin { settings: PluginSettings = DEFAULT_SETTINGS; @@ -25,12 +27,14 @@ export default class AnkiHeadingSyncPlugin extends Plugin { private syncVaultUseCase?: ManualSyncVaultUseCase; private rebuildCardIndexUseCase?: RebuildCardIndexUseCase; private pluginConfigRepository?: DataJsonPluginConfigRepository; + private vaultGateway?: ManualSyncVaultGateway; async onload(): Promise { const pluginDataStore = new ObsidianPluginDataStore(this); this.pluginConfigRepository = new DataJsonPluginConfigRepository(pluginDataStore); const pluginStateRepository = new DataJsonPluginStateRepository(pluginDataStore); const vaultGateway = new ObsidianVaultGateway(this.app); + this.vaultGateway = vaultGateway; try { this.settings = await this.pluginConfigRepository.load(); @@ -75,6 +79,14 @@ export default class AnkiHeadingSyncPlugin extends Plugin { return this.ankiGateway.getModelDetails(modelName); } + async listFolderTree(): Promise { + if (!this.vaultGateway) { + return []; + } + + return this.vaultGateway.listFolderTree(); + } + async runSyncCurrentFile(): Promise { const activeFile = this.app.workspace.getActiveFile(); @@ -92,6 +104,11 @@ export default class AnkiHeadingSyncPlugin extends Plugin { const result = await this.syncCurrentFileUseCase.execute(activeFile.path, this.settings); this.noticeService.showSyncSummary("当前文件同步完成", result); } catch (error) { + if (error instanceof CurrentFileOutOfScopeError) { + this.noticeService.info("当前文件不在插件作用范围内"); + return; + } + console.error("Current file sync failed.", error); this.noticeService.error(error instanceof Error ? error.message : "Current file sync failed."); } diff --git a/src/presentation/settings/FolderScopeTree.test.ts b/src/presentation/settings/FolderScopeTree.test.ts new file mode 100644 index 0000000..36cda74 --- /dev/null +++ b/src/presentation/settings/FolderScopeTree.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; + +import { buildFolderTreeSelection, compressFolderSelections, toggleFolderTreeSelection } from "./FolderScopeTree"; + +const TREE: FolderTreeNode[] = [ + { + path: "notes", + name: "notes", + children: [ + { + path: "notes/daily", + name: "daily", + children: [], + }, + { + path: "notes/projects", + name: "projects", + children: [], + }, + ], + }, + { + path: "archive", + name: "archive", + children: [], + }, +]; + +describe("FolderScopeTree", () => { + it("selecting a parent folder saves the minimal parent-only selection", () => { + expect(toggleFolderTreeSelection(TREE, [], "notes", true)).toEqual(["notes"]); + }); + + it("clearing a selected parent folder removes all descendants", () => { + expect(toggleFolderTreeSelection(TREE, ["notes"], "notes", false)).toEqual([]); + }); + + it("marks a parent as indeterminate when only part of its children are selected", () => { + const selection = buildFolderTreeSelection(TREE, ["notes/daily"]); + + expect(selection[0]).toMatchObject({ + path: "notes", + checked: false, + indeterminate: true, + }); + expect(selection[0]?.children[0]).toMatchObject({ + path: "notes/daily", + checked: true, + indeterminate: false, + }); + }); + + it("expands a selected parent when one child subtree is unchecked", () => { + expect(toggleFolderTreeSelection(TREE, ["notes"], "notes/daily", false)).toEqual(["notes/projects"]); + }); + + it("compresses fully selected sibling folders back to their parent", () => { + expect(compressFolderSelections(TREE, ["notes/daily", "notes/projects"])).toEqual(["notes"]); + }); +}); \ No newline at end of file diff --git a/src/presentation/settings/FolderScopeTree.ts b/src/presentation/settings/FolderScopeTree.ts new file mode 100644 index 0000000..1bb7044 --- /dev/null +++ b/src/presentation/settings/FolderScopeTree.ts @@ -0,0 +1,207 @@ +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; + +export interface FolderTreeSelectionNode extends FolderTreeNode { + checked: boolean; + indeterminate: boolean; + children: FolderTreeSelectionNode[]; +} + +export function buildFolderTreeSelection(nodes: FolderTreeNode[], selectedPaths: string[]): FolderTreeSelectionNode[] { + const explicitSelections = new Set(normalizeSelections(selectedPaths)); + return nodes.map((node) => buildSelectionNode(node, explicitSelections, false)); +} + +export function toggleFolderTreeSelection( + nodes: FolderTreeNode[], + selectedPaths: string[], + targetPath: string, + checked: boolean, +): string[] { + const index = buildTreeIndex(nodes); + const explicitSelections = new Set(normalizeSelections(selectedPaths).filter((path) => index.nodeByPath.has(path))); + + if (!index.nodeByPath.has(targetPath)) { + return compressFolderSelections(nodes, Array.from(explicitSelections)); + } + + if (checked) { + if (findNearestSelectedAncestor(targetPath, explicitSelections, index.parentByPath)) { + return compressFolderSelections(nodes, Array.from(explicitSelections)); + } + + removeSubtreeSelections(explicitSelections, targetPath); + explicitSelections.add(targetPath); + return compressFolderSelections(nodes, Array.from(explicitSelections)); + } + + const nearestSelectedAncestor = findNearestSelectedAncestor(targetPath, explicitSelections, index.parentByPath); + if (nearestSelectedAncestor) { + explicitSelections.delete(nearestSelectedAncestor); + + if (nearestSelectedAncestor !== targetPath) { + const ancestorNode = index.nodeByPath.get(nearestSelectedAncestor); + if (ancestorNode) { + for (const replacement of selectAllExceptSubtree(ancestorNode, targetPath)) { + explicitSelections.add(replacement); + } + } + } + } + + removeSubtreeSelections(explicitSelections, targetPath); + return compressFolderSelections(nodes, Array.from(explicitSelections)); +} + +export function compressFolderSelections(nodes: FolderTreeNode[], selectedPaths: string[]): string[] { + const explicitSelections = new Set(normalizeSelections(selectedPaths)); + return nodes.flatMap((node) => compressSelectionNode(node, explicitSelections).paths); +} + +function buildSelectionNode( + node: FolderTreeNode, + explicitSelections: Set, + ancestorSelected: boolean, +): FolderTreeSelectionNode { + const selfSelected = ancestorSelected || explicitSelections.has(node.path); + const children = node.children.map((child) => buildSelectionNode(child, explicitSelections, selfSelected)); + + if (selfSelected) { + return { + ...node, + checked: true, + indeterminate: false, + children, + }; + } + + const allChildrenChecked = children.length > 0 && children.every((child) => child.checked && !child.indeterminate); + const someChildrenChecked = children.some((child) => child.checked || child.indeterminate); + + return { + ...node, + checked: allChildrenChecked, + indeterminate: someChildrenChecked && !allChildrenChecked, + children, + }; +} + +function compressSelectionNode( + node: FolderTreeNode, + explicitSelections: Set, +): { fullySelected: boolean; paths: string[] } { + if (explicitSelections.has(node.path)) { + return { + fullySelected: true, + paths: [node.path], + }; + } + + if (node.children.length === 0) { + return { + fullySelected: false, + paths: [], + }; + } + + const childResults = node.children.map((child) => compressSelectionNode(child, explicitSelections)); + const allChildrenSelected = childResults.length > 0 && childResults.every((child) => child.fullySelected); + + if (allChildrenSelected) { + return { + fullySelected: true, + paths: [node.path], + }; + } + + return { + fullySelected: false, + paths: childResults.flatMap((child) => child.paths), + }; +} + +function buildTreeIndex(nodes: FolderTreeNode[]): { + nodeByPath: Map; + parentByPath: Map; +} { + const nodeByPath = new Map(); + const parentByPath = new Map(); + + const visit = (node: FolderTreeNode, parentPath: string | undefined): void => { + nodeByPath.set(node.path, node); + parentByPath.set(node.path, parentPath); + + for (const child of node.children) { + visit(child, node.path); + } + }; + + for (const node of nodes) { + visit(node, undefined); + } + + return { + nodeByPath, + parentByPath, + }; +} + +function findNearestSelectedAncestor( + targetPath: string, + explicitSelections: Set, + parentByPath: Map, +): string | undefined { + let currentPath: string | undefined = targetPath; + + while (currentPath) { + if (explicitSelections.has(currentPath)) { + return currentPath; + } + + currentPath = parentByPath.get(currentPath); + } + + return undefined; +} + +function removeSubtreeSelections(explicitSelections: Set, targetPath: string): void { + for (const path of Array.from(explicitSelections)) { + if (path === targetPath || isPathInsideFolder(path, targetPath)) { + explicitSelections.delete(path); + } + } +} + +function selectAllExceptSubtree(node: FolderTreeNode, targetPath: string): string[] { + if (node.path === targetPath) { + return []; + } + + const targetChild = node.children.find((child) => child.path === targetPath || isPathInsideFolder(targetPath, child.path)); + if (!targetChild) { + return [node.path]; + } + + const selections: string[] = []; + for (const child of node.children) { + if (child.path === targetChild.path) { + selections.push(...selectAllExceptSubtree(child, targetPath)); + continue; + } + + selections.push(child.path); + } + + return selections; +} + +function normalizeSelections(selectedPaths: string[]): string[] { + return Array.from(new Set(selectedPaths.map((path) => normalizePath(path)).filter(Boolean))); +} + +function normalizePath(path: string): string { + return path.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +function isPathInsideFolder(filePath: string, folderPath: string): boolean { + return filePath === folderPath || filePath.startsWith(`${folderPath}/`); +} \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index fa037c4..246e1aa 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -6,24 +6,72 @@ import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; const { FakeButtonComponent, FakeDropdownComponent, + FakeElement, FakePluginSettingTab, FakeSetting, } = vi.hoisted(() => { - class HoistedFakeContainerEl { - settings: HoistedFakeSetting[] = []; - textNodes: string[] = []; + class HoistedFakeElement { + public readonly children: HoistedFakeElement[] = []; + public readonly dataset: Record = {}; + public checked = false; + public indeterminate = false; + public type = ""; + public value = ""; + public text = ""; + + private readonly listeners = new Map void | Promise>>(); + + constructor( + public readonly root: HoistedFakeContainerEl, + public readonly tag: string, + ) {} + + createEl(tag: string, options?: { text?: string }): HoistedFakeElement { + const child = new HoistedFakeElement(this.root, tag); + if (options?.text) { + child.text = options.text; + this.root.textNodes.push(options.text); + } + + this.children.push(child); + return child; + } + + createDiv(options?: { text?: string }): HoistedFakeElement { + return this.createEl("div", options); + } + + addEventListener(eventName: string, callback: () => void | Promise): void { + const callbacks = this.listeners.get(eventName) ?? []; + callbacks.push(callback); + this.listeners.set(eventName, callbacks); + } + + async trigger(eventName: string): Promise { + for (const callback of this.listeners.get(eventName) ?? []) { + await callback(); + } + } + + setAttr(name: string, value: string | number): this { + (this as Record)[name] = value; + return this; + } + } + + class HoistedFakeContainerEl extends HoistedFakeElement { + public settings: HoistedFakeSetting[] = []; + public textNodes: string[] = []; + + constructor() { + super(undefined as never, "root"); + (this as { root: HoistedFakeContainerEl }).root = this; + } empty(): void { this.settings = []; this.textNodes = []; - } - - createEl(_tag: string, options?: { text?: string }): HoistedFakeContainerEl { - if (options?.text) { - this.textNodes.push(options.text); - } - - return this; + this.children.length = 0; } } @@ -107,7 +155,7 @@ const { class HoistedFakeSetting { public name = ""; - public desc = ""; + public desc: unknown = ""; public controls: Array< HoistedFakeButtonComponent | HoistedFakeDropdownComponent | HoistedFakeTextComponent | HoistedFakeToggleComponent > = []; @@ -121,7 +169,7 @@ const { return this; } - setDesc(desc: string): this { + setDesc(desc: unknown): this { this.desc = desc; return this; } @@ -173,8 +221,8 @@ const { return { FakeButtonComponent: HoistedFakeButtonComponent, - FakeContainerEl: HoistedFakeContainerEl, FakeDropdownComponent: HoistedFakeDropdownComponent, + FakeElement: HoistedFakeElement, FakePluginSettingTab: HoistedFakePluginSettingTab, FakeSetting: HoistedFakeSetting, }; @@ -191,6 +239,7 @@ type FakeContainerElInstance = InstanceType["contai type FakeSettingInstance = InstanceType; type FakeButtonComponentInstance = InstanceType; type FakeDropdownComponentInstance = InstanceType; +type FakeElementInstance = InstanceType; class FakePlugin { public readonly app = {}; @@ -199,6 +248,29 @@ class FakePlugin { qaNoteType: "Custom Basic", noteFieldMappings: {}, }; + public folderTree = [ + { + path: "notes", + name: "notes", + children: [ + { + path: "notes/sub", + name: "sub", + children: [], + }, + { + path: "notes/other", + name: "other", + children: [], + }, + ], + }, + { + path: "empty", + name: "empty", + children: [], + }, + ]; async updateSettings(partialSettings: Record): Promise { this.settings = { @@ -224,6 +296,10 @@ class FakePlugin { isCloze: false, }; } + + async listFolderTree() { + return this.folderTree; + } } function findSetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance { @@ -236,6 +312,10 @@ function findSetting(containerEl: FakeContainerElInstance, name: string): FakeSe return setting; } +function querySetting(containerEl: FakeContainerElInstance, name: string): FakeSettingInstance | undefined { + return containerEl.settings.find((candidate: FakeSettingInstance) => candidate.name === name); +} + function getButton(setting: FakeSettingInstance): FakeButtonComponentInstance { const button = setting.controls.find((control: unknown) => control instanceof FakeButtonComponent); @@ -256,6 +336,46 @@ function getDropdown(setting: FakeSettingInstance): FakeDropdownComponentInstanc return dropdown; } +function queryCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance | undefined { + return findElement(containerEl, (element) => element.tag === "input" && element.dataset.folderPath === folderPath); +} + +function getCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { + const checkbox = queryCheckboxByPath(containerEl, folderPath); + if (!checkbox) { + throw new Error(`Checkbox not found for folder path: ${folderPath}`); + } + + return checkbox; +} + +function findElement( + root: FakeElementInstance, + predicate: (element: FakeElementInstance) => boolean, +): FakeElementInstance | undefined { + if (predicate(root)) { + return root; + } + + for (const child of root.children) { + if (!(child instanceof FakeElement)) { + continue; + } + + const match = findElement(child as FakeElementInstance, predicate); + if (match) { + return match; + } + } + + return undefined; +} + +async function flushAsync(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + describe("AnkiHeadingSyncSettingTab", () => { beforeEach(() => { vi.clearAllMocks(); @@ -317,4 +437,64 @@ describe("AnkiHeadingSyncSettingTab", () => { const mainFieldDropdown = getDropdown(findSetting(container, "Cloze main field")); expect(mainFieldDropdown.value).toBe("Text"); }); + + it("removes the old folder textareas and hides the folder tree in all mode", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + await flushAsync(); + + expect(querySetting(container, "Include folders")).toBeUndefined(); + expect(querySetting(container, "Exclude folders")).toBeUndefined(); + expect(findSetting(container, "运行范围")).toBeDefined(); + expect(queryCheckboxByPath(container, "notes")).toBeUndefined(); + }); + + it("shows the folder tree in include mode and rehydrates saved selections after redisplay", 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(); + + const parentCheckbox = getCheckboxByPath(container, "notes"); + const childCheckbox = getCheckboxByPath(container, "notes/sub"); + + expect(parentCheckbox.checked).toBe(false); + expect(parentCheckbox.indeterminate).toBe(true); + expect(childCheckbox.checked).toBe(true); + expect(container.textNodes).toContain("empty"); + }); + + it("switches scope mode and saves compressed folder selections from the tree", async () => { + const plugin = new FakePlugin(); + const tab = new AnkiHeadingSyncSettingTab(plugin as never); + const container = tab.containerEl as unknown as FakeContainerElInstance; + + tab.display(); + await flushAsync(); + await getDropdown(findSetting(container, "运行范围")).triggerChange("include"); + await flushAsync(); + + const parentCheckbox = getCheckboxByPath(container, "notes"); + parentCheckbox.checked = true; + await parentCheckbox.trigger("change"); + await flushAsync(); + + expect(plugin.settings.includeFolders).toEqual(["notes"]); + + await getDropdown(findSetting(container, "运行范围")).triggerChange("all"); + await flushAsync(); + + expect(queryCheckboxByPath(container, "notes")).toBeUndefined(); + }); }); \ No newline at end of file diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index c40cb80..e7dc961 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -1,12 +1,17 @@ import { PluginSettingTab, Setting } from "obsidian"; +import type { 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"; import { NoteFieldMappingService } from "@/application/services/NoteFieldMappingService"; import type { CardType } from "@/domain/card/entities/RenderedFields"; import type AnkiHeadingSyncPlugin from "@/presentation/AnkiHeadingSyncPlugin"; +import { buildFolderTreeSelection, toggleFolderTreeSelection, type FolderTreeSelectionNode } from "./FolderScopeTree"; + const NOTE_TYPE_STATUS_IDLE = "Refresh note types from Anki to load the available note types."; +const FOLDER_TREE_STATUS_LOADING = "正在读取当前 vault 文件夹..."; interface MappingSectionConfig { cardType: CardType; @@ -27,6 +32,10 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private readonly draftMappings: Record = {}; private readonly loadedModelDetails: Record = {}; private readonly sectionStatuses: Partial> = {}; + private folderTree: FolderTreeNode[] = []; + private folderTreeStatus = FOLDER_TREE_STATUS_LOADING; + private folderTreeLoadPromise: Promise | null = null; + private hasLoadedFolderTree = false; constructor(plugin: AnkiHeadingSyncPlugin) { super(plugin.app, plugin); @@ -86,23 +95,7 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { }); }); - new Setting(containerEl) - .setName("Include folders") - .setDesc("Comma-separated folder paths. Empty means scan the whole vault.") - .addTextArea((textArea) => { - textArea.setValue(settings.includeFolders.join(", ")).onChange((value) => { - void this.plugin.updateSettings({ includeFolders: splitFolders(value) }); - }); - }); - - new Setting(containerEl) - .setName("Exclude folders") - .setDesc("Comma-separated folder paths always filtered out of vault sync.") - .addTextArea((textArea) => { - textArea.setValue(settings.excludeFolders.join(", ")).onChange((value) => { - void this.plugin.updateSettings({ excludeFolders: splitFolders(value) }); - }); - }); + this.renderScopeSection(containerEl, settings); new Setting(containerEl) .setName("Add Obsidian backlink") @@ -388,11 +381,131 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private getSelectedNoteType(cardType: CardType): string { return cardType === "basic" ? this.plugin.settings.qaNoteType : this.plugin.settings.clozeNoteType; } + + private renderScopeSection(containerEl: HTMLElement, settings: AnkiHeadingSyncPlugin["settings"]): void { + new Setting(containerEl) + .setName("运行范围") + .setDesc(getScopeModeSummary(settings.scopeMode)) + .addDropdown((dropdown) => { + dropdown + .addOption("all", "全部文件") + .addOption("include", "仅在指定文件夹") + .addOption("exclude", "排除指定文件夹") + .setValue(settings.scopeMode) + .onChange((value) => { + if (value !== "all" && value !== "include" && value !== "exclude") { + return; + } + + void this.updateScopeMode(value); + }); + }); + + if (settings.scopeMode === "all") { + return; + } + + this.ensureFolderTreeLoaded(); + + const scopeContainer = containerEl.createDiv(); + scopeContainer.createEl("p", { text: getScopeModeTreeDescription(settings.scopeMode) }); + + if (this.folderTreeLoadPromise) { + scopeContainer.createEl("p", { text: this.folderTreeStatus }); + return; + } + + if (this.folderTree.length === 0) { + scopeContainer.createEl("p", { text: this.folderTreeStatus }); + return; + } + + const selectedFolders = settings.scopeMode === "include" ? settings.includeFolders : settings.excludeFolders; + const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders); + const treeContainer = scopeContainer.createDiv(); + + for (const node of selectionTree) { + this.renderFolderNode(treeContainer, node, settings.scopeMode); + } + } + + private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode): void { + const row = containerEl.createDiv(); + const checkbox = row.createEl("input") as HTMLInputElement; + checkbox.type = "checkbox"; + checkbox.checked = node.checked; + checkbox.indeterminate = node.indeterminate; + checkbox.dataset.folderPath = node.path; + checkbox.addEventListener("change", () => { + void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); + }); + + const label = row.createEl("span", { text: node.name }); + label.dataset.folderPathLabel = node.path; + + if (node.children.length === 0) { + return; + } + + const childrenContainer = containerEl.createDiv(); + for (const child of node.children) { + this.renderFolderNode(childrenContainer, child, scopeMode); + } + } + + private ensureFolderTreeLoaded(): void { + if (this.hasLoadedFolderTree || this.folderTreeLoadPromise) { + return; + } + + this.folderTreeStatus = FOLDER_TREE_STATUS_LOADING; + this.folderTreeLoadPromise = this.plugin + .listFolderTree() + .then((folderTree) => { + this.folderTree = folderTree; + this.folderTreeStatus = folderTree.length > 0 ? "" : "当前 vault 中没有可选文件夹。"; + }) + .catch((error) => { + this.folderTree = []; + this.folderTreeStatus = error instanceof Error ? error.message : "读取 vault 文件夹失败。"; + }) + .finally(() => { + this.hasLoadedFolderTree = true; + this.folderTreeLoadPromise = null; + this.display(); + }); + } + + private async updateScopeMode(scopeMode: ScopeMode): Promise { + await this.plugin.updateSettings({ scopeMode }); + this.display(); + } + + private async updateFolderSelection(scopeMode: ScopeMode, folderPath: string, checked: boolean): Promise { + const currentSelection = scopeMode === "include" ? this.plugin.settings.includeFolders : this.plugin.settings.excludeFolders; + const nextSelection = toggleFolderTreeSelection(this.folderTree, currentSelection, folderPath, checked); + + await this.plugin.updateSettings(scopeMode === "include" ? { includeFolders: nextSelection } : { excludeFolders: nextSelection }); + this.display(); + } } -function splitFolders(value: string): string[] { - return value - .split(",") - .map((segment) => segment.trim()) - .filter(Boolean); +function getScopeModeSummary(scopeMode: ScopeMode): string { + if (scopeMode === "include") { + return "仅处理下方勾选文件夹中的 Markdown 文件"; + } + + if (scopeMode === "exclude") { + return "处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件"; + } + + return "处理整个 vault 中的 Markdown 文件"; +} + +function getScopeModeTreeDescription(scopeMode: ScopeMode): string { + if (scopeMode === "include") { + return "仅在指定文件夹:只处理下方勾选文件夹中的 Markdown 文件"; + } + + return "排除指定文件夹:处理整个 vault,但跳过下方勾选文件夹中的 Markdown 文件"; } \ No newline at end of file diff --git a/src/test-support/manualSyncFakes.ts b/src/test-support/manualSyncFakes.ts index 98fc2c4..b8db066 100644 --- a/src/test-support/manualSyncFakes.ts +++ b/src/test-support/manualSyncFakes.ts @@ -1,5 +1,6 @@ import type { PluginSettings } from "@/application/config/PluginSettings"; import { DEFAULT_SETTINGS } from "@/application/config/PluginSettings"; +import type { FolderTreeNode } from "@/application/dto/FolderTreeNode"; import type { AddAnkiNoteInput, AnkiGateway, AnkiNoteSummary, ChangeDeckInput, UpdateAnkiNoteInput } from "@/application/ports/AnkiGateway"; import type { ManualSyncVaultGateway } from "@/application/ports/ManualSyncVaultGateway"; import type { PluginStateRepository } from "@/application/ports/PluginStateRepository"; @@ -49,6 +50,36 @@ export class FakeManualSyncVaultGateway implements ManualSyncVaultGateway { })); } + async listFolderTree(): Promise { + const rootMap = new Map(); + + for (const path of this.files.keys()) { + const segments = path.split("/"); + segments.pop(); + + let currentPath = ""; + let siblings = rootMap; + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const existing = siblings.get(currentPath); + if (existing) { + siblings = new Map(existing.children.map((child) => [child.path, child])); + continue; + } + + const nextNode: FolderTreeNode = { + path: currentPath, + name: segment, + children: [], + }; + siblings.set(currentPath, nextNode); + siblings = new Map(); + } + } + + return buildFolderTree(Array.from(this.files.keys())); + } + async readMarkdownFile(path: string): Promise { this.readCalls.push(path); const file = this.files.get(path); @@ -196,4 +227,37 @@ export function createModule3Settings(overrides: Partial = {}): }, ...overrides, }; +} + +function buildFolderTree(filePaths: string[]): FolderTreeNode[] { + const root: FolderTreeNode = { + path: "", + name: "", + children: [], + }; + const nodeByPath = new Map([["", root]]); + + for (const filePath of filePaths) { + const folderSegments = filePath.split("/").slice(0, -1); + let currentPath = ""; + + for (const segment of folderSegments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + if (nodeByPath.has(currentPath)) { + continue; + } + + const node: FolderTreeNode = { + path: currentPath, + name: segment, + children: [], + }; + nodeByPath.set(currentPath, node); + + const parentPath = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : ""; + nodeByPath.get(parentPath)?.children.push(node); + } + } + + return root.children; } \ No newline at end of file diff --git a/src/test-support/obsidianStub.ts b/src/test-support/obsidianStub.ts index f779a47..7244f51 100644 --- a/src/test-support/obsidianStub.ts +++ b/src/test-support/obsidianStub.ts @@ -79,4 +79,16 @@ export class TFile { this.extension = extensionIndex >= 0 ? this.name.slice(extensionIndex + 1) : ""; this.basename = extensionIndex >= 0 ? this.name.slice(0, extensionIndex) : this.name; } +} + +export class TFolder { + public readonly name: string; + + constructor( + public readonly path: string, + public readonly children: Array = [], + ) { + const segments = path.split("/").filter(Boolean); + this.name = segments[segments.length - 1] ?? ""; + } } \ No newline at end of file