feat: redesign folder scope settings

This commit is contained in:
Dusk 2026-04-18 13:42:01 +08:00
parent a0ac404712
commit cb968b1d17
27 changed files with 1292 additions and 249 deletions

View file

@ -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 格式:`<!-- AHS:card=<cardId> note=<noteId> -->`
- 允许仅写 `cardId``<!-- AHS:card=<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` 为空数组,但批处理执行层保留接口位置
- 本模块只改“文件夹范围设置”相关能力
- 不改 deck、note type、字段映射设置
- 不引入单文件路径白名单/黑名单
- 不改 marker 写回与手动同步主流程

View file

@ -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` 只写 `<!-- AHS:<noteId> -->`
- `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 接管插件命令入口
1. 为 `PluginSettings` 增加显式 `scopeMode`,默认值为 `all`
2. 为 Obsidian vault gateway 增加只读文件夹树接口
3. 将设置页的两个文本框改为“模式选择 + 树状勾选”
4. 用纯函数收口三态树与最小集合保存逻辑,避免把选择算法揉进 UI 代码
5. 将范围门禁加到当前文件同步链路,并在命令层显示明确 notice

View file

@ -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.");
});
});

View file

@ -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<string, NoteModelFieldMapping>;
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<string, NoteModelFieldMapping>): void {
if (!noteFieldMappings || typeof noteFieldMappings !== "object" || Array.isArray(noteFieldMappings)) {
throw new Error("Note field mappings must be an object.");

View file

@ -0,0 +1,5 @@
export interface FolderTreeNode {
path: string;
name: string;
children: FolderTreeNode[];
}

View file

@ -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<FolderTreeNode[]>;
listMarkdownFileRefs(): Promise<MarkdownFileReference[]>;
readMarkdownFile(path: string): Promise<SourceFile | null>;
replaceMarkdownFile(path: string, expectedContent: string, nextContent: string): Promise<void>;

View file

@ -15,6 +15,7 @@ describe("FileIndexerService", () => {
await service.indexVault(
createModule3Settings({
scopeMode: "include",
includeFolders: ["keep"],
}),
createEmptyPluginState(),

View file

@ -28,7 +28,12 @@ export class FileIndexerService {
) {}
async indexVault(settings: PluginSettings, state: PluginState, forceReadAll = false): Promise<FileIndexerResult> {
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);
}

View file

@ -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("<!-- AHS:card=");
expect(vaultGateway.getFileContent("notes/example.md")).not.toContain("note=");
});
});
it("restores the original marker without creating a new Anki note when marker was deleted but content is unchanged", async () => {
const settings = createModule3Settings();
const vaultGateway = new FakeManualSyncVaultGateway({
"notes/example.md": ["#### Prompt", "Answer"].join("\n"),
});
const storedCard = createStoredSyncedCard(settings);
const stateRepository = new InMemoryPluginStateRepository({
files: {},
cards: {
ahs_known: storedCard,
},
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("notes/example.md", settings);
expect(result.created).toBe(0);
expect(result.updated).toBe(0);
expect(result.rewrittenMarkers).toBe(1);
expect(ankiGateway.addedNotes).toHaveLength(0);
expect(ankiGateway.updatedNotes).toHaveLength(0);
expect(vaultGateway.getFileContent("notes/example.md")).toContain("<!-- AHS:card=ahs_known note=42 -->");
});
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> = {}): 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,
};
}

View file

@ -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<ManualSyncResult> {
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);

View file

@ -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"]);
});
});

View file

@ -1,21 +1,38 @@
import type { ScopeMode } from "@/application/config/PluginSettings";
export class ScanScopeService {
filter<T extends { path: string }>(files: T[], includeFolders: string[], excludeFolders: string[]): T[] {
const normalizedIncludes = includeFolders.map(normalizeFolderPath).filter(Boolean);
const normalizedExcludes = excludeFolders.map(normalizeFolderPath).filter(Boolean);
filter<T extends { path: string }>(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 {

View file

@ -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);
}

View file

@ -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();

View file

@ -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: "<!-- AHS:card=ahs_known note=42 -->",
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");
});
});
});
function createKnownCardState(overrides: Partial<CardState> = {}): 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,
};
}

View file

@ -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<string>();
@ -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<string, CardState>,
knownCardsByBlockKey: Map<string, CardState[]>,
pendingByCardId: Map<string, PendingWriteBackState>,
usedCardIds: Set<string>,
): { 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<string, CardState[]> {
const grouped = new Map<string, CardState[]>();
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.");

View file

@ -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 = {

View file

@ -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 | TFile>) => 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: [],
},
]);
});
});

View file

@ -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<FolderTreeNode[]> {
return this.app.vault
.getRoot()
.children.filter((child): child is TFolder => child instanceof TFolder)
.map((folder) => this.toFolderTreeNode(folder));
}
async listMarkdownFileRefs(): Promise<MarkdownFileReference[]> {
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 } {

View file

@ -18,12 +18,13 @@ class InMemoryPluginDataStore implements PluginDataStore<PluginDataSnapshot> {
}
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 () => {

View file

@ -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<void> {
const pluginDataStore = new ObsidianPluginDataStore<PluginDataSnapshot>(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<FolderTreeNode[]> {
if (!this.vaultGateway) {
return [];
}
return this.vaultGateway.listFolderTree();
}
async runSyncCurrentFile(): Promise<void> {
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.");
}

View file

@ -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"]);
});
});

View file

@ -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<string>,
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<string>,
): { 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<string, FolderTreeNode>;
parentByPath: Map<string, string | undefined>;
} {
const nodeByPath = new Map<string, FolderTreeNode>();
const parentByPath = new Map<string, string | undefined>();
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<string>,
parentByPath: Map<string, string | undefined>,
): 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<string>, 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}/`);
}

View file

@ -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<string, string> = {};
public checked = false;
public indeterminate = false;
public type = "";
public value = "";
public text = "";
private readonly listeners = new Map<string, Array<() => void | Promise<void>>>();
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>): void {
const callbacks = this.listeners.get(eventName) ?? [];
callbacks.push(callback);
this.listeners.set(eventName, callbacks);
}
async trigger(eventName: string): Promise<void> {
for (const callback of this.listeners.get(eventName) ?? []) {
await callback();
}
}
setAttr(name: string, value: string | number): this {
(this as Record<string, unknown>)[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<typeof FakePluginSettingTab>["contai
type FakeSettingInstance = InstanceType<typeof FakeSetting>;
type FakeButtonComponentInstance = InstanceType<typeof FakeButtonComponent>;
type FakeDropdownComponentInstance = InstanceType<typeof FakeDropdownComponent>;
type FakeElementInstance = InstanceType<typeof FakeElement>;
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<string, unknown>): Promise<void> {
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<void> {
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();
});
});

View file

@ -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<string, NoteModelFieldMapping> = {};
private readonly loadedModelDetails: Record<string, NoteModelDetails> = {};
private readonly sectionStatuses: Partial<Record<CardType, string>> = {};
private folderTree: FolderTreeNode[] = [];
private folderTreeStatus = FOLDER_TREE_STATUS_LOADING;
private folderTreeLoadPromise: Promise<void> | 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<void> {
await this.plugin.updateSettings({ scopeMode });
this.display();
}
private async updateFolderSelection(scopeMode: ScopeMode, folderPath: string, checked: boolean): Promise<void> {
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 文件";
}

View file

@ -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<FolderTreeNode[]> {
const rootMap = new Map<string, FolderTreeNode>();
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<SourceFile | null> {
this.readCalls.push(path);
const file = this.files.get(path);
@ -196,4 +227,37 @@ export function createModule3Settings(overrides: Partial<PluginSettings> = {}):
},
...overrides,
};
}
function buildFolderTree(filePaths: string[]): FolderTreeNode[] {
const root: FolderTreeNode = {
path: "",
name: "",
children: [],
};
const nodeByPath = new Map<string, FolderTreeNode>([["", 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;
}

View file

@ -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<TFolder | TFile> = [],
) {
const segments = path.split("/").filter(Boolean);
this.name = segments[segments.length - 1] ?? "";
}
}