mirror of
https://github.com/panatgithub/AnkiHeadingSync.git
synced 2026-07-22 06:51:43 +00:00
feat: redesign folder scope settings
This commit is contained in:
parent
a0ac404712
commit
cb968b1d17
27 changed files with 1292 additions and 249 deletions
|
|
@ -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 写回与手动同步主流程
|
||||
|
|
@ -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
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
5
src/application/dto/FolderTreeNode.ts
Normal file
5
src/application/dto/FolderTreeNode.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface FolderTreeNode {
|
||||
path: string;
|
||||
name: string;
|
||||
children: FolderTreeNode[];
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe("FileIndexerService", () => {
|
|||
|
||||
await service.indexVault(
|
||||
createModule3Settings({
|
||||
scopeMode: "include",
|
||||
includeFolders: ["keep"],
|
||||
}),
|
||||
createEmptyPluginState(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
40
src/infrastructure/obsidian/ObsidianVaultGateway.test.ts
Normal file
40
src/infrastructure/obsidian/ObsidianVaultGateway.test.ts
Normal 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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
62
src/presentation/settings/FolderScopeTree.test.ts
Normal file
62
src/presentation/settings/FolderScopeTree.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
207
src/presentation/settings/FolderScopeTree.ts
Normal file
207
src/presentation/settings/FolderScopeTree.ts
Normal 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}/`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 文件";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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] ?? "";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue