refactor: reorganize architecture and add dataflow foundation

- Move settings-metadata from src/data to src/common for better organization
- Add initial dataflow architecture with core modules:
  - QueryAPI for unified task querying
  - Repository for centralized data management
  - Storage abstraction layer
  - Event system foundation
  - Parser interfaces for different entry types
  - Project resolver and augmentor modules
- Improve SettingsIndexer with better performance logging
- Update and enhance SettingsSearch tests
- Add comprehensive refactor documentation and specification
This commit is contained in:
quorafind 2025-08-18 11:56:30 +08:00
parent f5a275cc47
commit f5d4dca189
15 changed files with 769 additions and 80 deletions

1
.gitignore vendored
View file

@ -41,3 +41,4 @@ CLAUDE.md
.claude
dist
dist/*

View file

@ -0,0 +1,232 @@
## Task Genius 数据流全面重构计划Refactor Plan
### 目的Goals
- 去重与统一:消除项目识别、文件级元数据解析、任务解析、增强/继承、索引、缓存、持久化、事件传递在多处重复实现与分散入口的问题。
- 稳定与持久:建立单一数据源与可验证的数据流,降低未来演进/扩展成本。
- 一致与同步:保证视图数据与底层缓存/索引严格一致,避免“视图数据不同步”。
- 快速冷启动:最大程度复用持久化数据,避免用户反复 reindex。
- 事件驱动:所有上行/下行数据变更通过 Obsidian 事件系统传播,而不是上级组件直接 setTasks。
- 增量更新:跟随 Obsidian 的 vault/metadataCache/设置事件做最小代价的增量更新。
### 非目标Non-goals
- 不改变用户可见的功能语义与现有配置的含义(除非标注为改进项)。
- 不一次性大爆炸替换;采用阶段性迁移确保回退路径。
---
## 设计原则
- 单一职责与单一真相源Single Source of Truth
- 项目识别只在 ProjectResolver统一接口进行解析器与增强器不再分散探测。
- 继承/增强只在 TaskAugmentor 进行;解析器仅负责“从文本抽取”。
- 事件优于回调:统一使用 Obsidian workspace 事件分发数据变更,视图通过事件 + 查询 API 获取数据。
- 可重放、可回退Worker 与主线程遵循相同协议;主线程兜底。
- 可持久与可验证:内容哈希 + 版本/架构版本管理;命名空间化缓存;精细化失效与一致性校验。
- 增量优先:文件内容变动仅重解析该文件;项目配置变动仅重跑增强;设置变动按 scope 精准失效。
- 向后兼容:提供 Adapter 兼容旧 setTasks 调用路径,逐步下线。
---
## 数据拥有权与边界Ownership
- 源事件File eventsObsidianSource 拥有,统一发出内部文件事件。
- 项目识别ProjectProjectResolver 拥有,对外只暴露“文件 → {tgProject, enhancedMetadata}”。
- 解析器ParsersMarkdown/Canvas/FileLevel 仅负责“抽取任务/文件级任务”,不写入项目或其他增强信息。
- 增强/继承AugmentTaskAugmentor 拥有“task > file > project”的可配置优先级合并。
- 索引IndexingTaskIndexer 拥有内存索引与查询,不做解析与增强。
- 持久化PersistenceStorageAdapter基于 LocalStorageCache拥有命名空间与 hash/版本校验。
- 事件与查询Events & QueryEvents 模块拥有事件契约QueryAPI 对外只读查询。
---
## 目标数据流(高层)
1) Obsidian 事件 → Sources 标准化为 FileChanged/FileDeleted/Renamed 等内部事件。
2) ParsersEntry根据文件类型抽取
- FileLevelTaskParser从 frontmatter/tags 形成“文件级任务”。
- MarkdownTaskParserEntry / CanvasTaskParserEntry从正文生成“基础任务”。
3) ProjectResolver 计算项目 + 项目增强元数据(可并行)。
4) TaskAugmentor将“文件级 + 正文任务”与“文件元数据 + 项目增强元数据”按策略合并到任务对象。
5) TaskIndexer.updateIndexWithTasks(filePath, tasksEnhanced)。
6) Persistence写入命名空间缓存见下必要时更新 consolidated 索引快照。
7) 事件广播 task-genius:task-cache-updated { changedFiles }。
8) 视图订阅事件 → QueryAPI 拉取 → 渲染。
---
## 组件与目录结构(建议新增 src/dataflow/*
- sources/ObsidianSource.ts
- 订阅 vault/metadataCache/设置事件;统一去抖与批量合并;发出内部文件事件。
- project/ProjectResolver.ts
- 合并现有 ProjectConfigManager + ProjectDataCache/Worker 的“对外接口”。
- 输入filePath输出{ tgProject?, enhancedMetadata, timestamp }。
- 统一“frontmatter 指定项目、目录 config、tag/link 提示”的优先级;可配置。
- parsers/
- MarkdownTaskParserEntry.ts包装 ConfigurableTaskParser.parseLegacy
- CanvasTaskParserEntry.ts包装 CanvasParser
- FileLevelTaskParser.ts包装 FileMetadataTaskParser禁用内部项目探测
- augment/TaskAugmentor.ts
- 单一继承/增强实现:
- 标量task 显式 > file > project > 默认
- 数组:合并去重(保持稳定次序)
- 状态/完成:只取 task 行级
- 复发task 显式优先
- 子任务继承:按 FileMetadataInheritance/设置 per-key 控制
- workers/WorkerOrchestrator.ts
- 统一任务/项目命令parseFileTasks、batchParse、computeProjectData、batchCompute
- 并发控制、重试、回退、指标
- indexer/TaskRepository.ts
- 组合 TaskIndexer + Persistence提供查询 API 的后端依赖
- persistence/StorageAdapter.ts
- 命名空间 + hash内容+ 版本/架构版本 + 粒度化失效
- Keyspace
- tasks.raw:<filePath>
- project.data:<filePath>
- tasks.augmented:<filePath>
- consolidated:taskIndex
- meta:version / meta:schemaVersion
- events/Events.ts
- 事件常量/载荷类型/帮助方法emit/subscribe。与 obsidian-ex.d.ts 契约一致。
- api/QueryAPI.ts
- 为视图提供只读查询(按项目/标签/状态/时间窗口等),屏蔽内部实现细节。
---
## 最大化避免与现有功能重复的策略
1) 单一入口与关停重复:
- FileMetadataTaskParser关闭其内部项目探测统一由 ProjectResolver 提供 project 值后在 Augmentor 注入。
- ConfigurableTaskParser/CanvasParser不处理项目/继承,仅返回“基础任务”。
- TaskManager 内的解析/增强/持久化逻辑收敛到新模块TaskManager 变为编排薄层。
2) 功能所有权迁移表(旧 → 新):
- 项目识别FileMetadataTaskParser.detectProjectFromFile / ProjectData* → ProjectResolver
- 继承/增强ConfigurableTaskParser 内部继承 / FileMetadataInheritance 分散 → TaskAugmentor
- 数据广播:上层 setTasks → Events + QueryAPI
- Worker 管理TaskWorkerManager/ProjectDataWorkerManager → WorkerOrchestrator内部保留原 Worker
- 持久化LocalStorageCache 直接使用 → StorageAdapter 管理命名空间/版本/hash
3) Adapter 与 Deprecation
- ViewComponentManager.setTasks 标记 deprecated提供兼容 AdaptersetTasks 内部转为发事件 + 触发查询,避免一次性改动所有视图。
- FileMetadataTaskParser 暴露“允许/禁止项目探测”开关;重构期禁用。
---
## 视图同步与一致性保证
- 事件驱动 + 拉取查询:视图统一订阅 task-genius:task-cache-updated / filter-changed随后调用 QueryAPI 拉取。彻底替代上级 setTasks 链接。
- 原子性:单文件变更导出单批次“增强后任务”提交到 Indexer再触发事件视图永远看到同一版本。
- 序号/时间戳:在广播载荷中携带 sequence/timestamp视图可丢弃过期更新。
- 去抖/批量Sources 对频繁变动进行批处理,减少 UI 抖动。
- 兼容策略:在迁移期间,旧视图通过 Adapter 仍可工作。
---
## 持久化与缓存策略
- 命名空间:
- tasks.raw:<filePath> → 基础解析结果
- project.data:<filePath> → {tgProject, enhancedMetadata}
- tasks.augmented:<filePath> → Augmentor 合成产物(可选,提升冷启动)
- consolidated:taskIndex → 全量索引快照(可选)
- 版本与架构版本:
- meta:version插件版本 + meta:schemaVersion缓存结构版本不兼容时精准清理对应命名空间。
- 内容哈希:
- tasks.raw 基于文件内容 hashproject.data 基于“有效配置 + frontmatter + tags/links”等来源 hash。
- 仅当 hash 改变时重算mtime 仅用于快速预判。
- 失效矩阵:
- 文件正文变动:重算 tasks.raw → augment → 更新 index → 事件。
- 文件 frontmatter/tags 变动:重做 file→task 继承与 augment若正文未变可跳过正文解析
- 项目配置变动:仅失效 project.data 与 tasks.augmented重做 augment跳过正文解析。
- 设置变动:按 scope解析/增强/索引)精准失效。
---
## 失败与回退
- Worker 异常:自动回退主线程解析;熔断/退避重试;错误事件与日志。
- 缓存损坏:校验失败则清理对应命名空间并重建;保留其余命名空间。
- 事件风暴:限流与批处理;视图侧忽略过期序号。
---
## 性能与 SLO
- 冷启动:优先使用 consolidated:taskIndex 或 tasks.augmented目标 P50 < 300ms视库大小微调
- 增量更新:单文件保存到视图更新 P95 < 150ms含解析 + augment + index + 事件
- 内存:索引结构保持与现有相当;新增缓存命名空间带来可控增量。
---
## 向后兼容与迁移
- Adapter
- 视图 setTasks 调用 → 转为发事件并触发 QueryAPI从而兼容旧代码逐视图切换到纯事件。
- 逐步替换:
- ProjectResolver 接管后,禁用 FileMetadataTaskParser 的项目探测。
- Augmentor 接管继承后,解析器不再做继承。
- 配置兼容:沿用原设置字段并提供合理默认;新增 per-key 策略作为高级选项。
---
## 阶段性实施与检查清单
Phase A事件封装 + 查询 API2 天)
- [ ] 新增 events/Events.ts封装现有触发点保持事件名兼容
- [ ] 新增 api/QueryAPI.ts提供最小查询全部任务/按项目/按标签)。
- [ ] TaskManager 触发统一走 Events.ts。
Phase B解析入口收敛 + Augmentor3 天)
- [ ] 新建 parsers/*Entry.ts主线程路径先打通。
- [ ] 新建 augment/TaskAugmentor.ts落地继承策略task>file>project
- [ ] 修改 FileMetadataTaskParser允许禁用项目探测默认禁用
Phase C持久化命名空间 + hash2 天)
- [ ] StorageAdapter.ts命名空间 + 版本/架构版本 + hash。
- [ ] 冷启动优先加载 consolidated 或 tasks.augmented兼容旧数据。
Phase DWorkerOrchestrator3 天)
- [ ] 合并任务/项目 worker 调度;统一命令协议与回退策略。
- [ ] 指标与日志:成功率、延迟、退避情况。
Phase E视图去 setTasks试点 2 个视图3 天)
- [ ] 视图订阅事件 + QueryAPI 拉取;移除直接 setTasks。
- [ ] 保留 Adapter 以兼容未迁移视图。
Phase F项目配置变更仅增强重算2 天)
- [ ] 变更监听 → 失效 project.data 与 tasks.augmented → augment → 事件。
Phase G全面测试与性能调优2 天)
- [ ] 单测/集成测补齐SLO 校验;回归修复。
---
## 测试计划(重点)
- 冷启动:无修改 → 不 reindex版本兼容 → 直接用。
- 文件增量正文变更、frontmatter/tags 变更、重命名/删除;仅影响对应文件。
- 项目配置变更:仅增强重算;正文不解析。
- 设置变更:按 scope 精准失效。
- 事件一致性:视图通过事件 + 查询看到一致数据;序号去重验证。
- 兼容回归:
- 空/Null 排序规则:空值永远排在非空之后(双向排序)。
- 中文标签重复历史问题:复发任务创建链路校验不重复,解析层不二次注入。
- 性能P50/P95 指标达标;压力下稳定。
---
## 回退计划
- 任意阶段可切回旧路径:
- 解析:保留旧 TaskWorkerManager/TaskManager 主路径开关。
- 事件:保留 setTasks Adapter。
- 缓存:清空新命名空间后回退旧 consolidated 流程。
---
## 开放问题(需确认)
- 事件载荷是否需要强制携带 diff新增/删除/修改的 taskIds以优化大视图刷新
- 默认继承策略中是否存在“项目优先于文件”的字段(例如 SLA 类字段)?
- consolidated:taskIndex 是否纳入默认启用,还是作为可选以节省空间?
---
## 附:事件契约(建议)
- task-genius:cache-ready { initial: boolean, timestamp, seq }
- task-genius:task-cache-updated { changedFiles?: string[], stats?, timestamp, seq }
- task-genius:file-updated { path, reason, timestamp }
- task-genius:project-data-updated { affectedFiles: string[], timestamp }
- task-genius:settings-changed { scopes: string[], timestamp }
说明:保持与现有 obsidian-ex.d.ts 的兼容命名(如 task-cache-updated
---
## 结束语
本方案以“单一真相源 + 事件驱动 + 命名空间缓存 + 可回退”为核心,最大化复用现有解析/索引能力,在不破坏现有功能的前提下,消除重复与耦合点,确保视图数据与缓存/索引一致。分阶段推进、可测可回退,可支撑后续长期演进。

View file

@ -0,0 +1,164 @@
## Task Genius 数据流重构实施规范Implementation Spec
本规范补充《task-genius-refactor-plan.md》面向执行同学提供可直接落地的事件载荷类型、QueryAPI 函数签名、StorageAdapter 的 Key 规则,以及示例代码与任务清单。
---
### 1. 事件载荷类型(建议)
事件名称与 obsidian-ex.d.ts 兼容,并统一通过 Events.ts 导出常量与帮助方法。
- task-genius:cache-ready
- payload: { initial: boolean; timestamp: number; seq: number }
- task-genius:task-cache-updated
- payload: { changedFiles?: string[]; stats?: { total: number; changed: number }; timestamp: number; seq: number }
- task-genius:file-updated
- payload: { path: string; reason: "modify" | "frontmatter" | "rename" | "delete"; timestamp: number }
- task-genius:project-data-updated
- payload: { affectedFiles: string[]; reason?: string; timestamp: number }
- task-genius:settings-changed
- payload: { scopes: ("parser" | "augment" | "index" | "view")[]; timestamp: number }
- 保留task-genius:task-completed/task-added/task-updated/task-deleted按现有类型
辅助:
- seq 为单调递增序号TaskRepository 维护)
- timestamp 为事件生成时间
---
### 2. QueryAPI 函数签名(草案)
文件src/dataflow/api/QueryAPI.ts
- getAllTasks(): Promise<Task[]>
- getTasksByProject(projectName: string): Promise<Task[]>
- getTasksByTags(tags: string[]): Promise<Task[]>
- getTasksByStatus(completed: boolean): Promise<Task[]>
- getTasksByDateRange(opts: { from?: number; to?: number; field?: "due" | "start" | "scheduled" }): Promise<Task[]>
- getTaskById(id: string): Promise<Task | null>
- getIndexSummary(): Promise<{ total: number; byProject: Record<string, number>; byTag: Record<string, number> }>
实现要点:
- 背后依赖 TaskRepository组合 TaskIndexer + Persistence严禁直接访问内部索引结构于视图层。
- 函数返回的 Task 均为“增强后任务”。
---
### 3. StorageAdapter Key 规则与版本
文件src/dataflow/persistence/StorageAdapter.ts
- 命名空间规范namespace:key
- tasks.raw:<filePath>
- project.data:<filePath>
- tasks.augmented:<filePath>
- consolidated:taskIndex
- meta:version插件版本
- meta:schemaVersion缓存架构版本
- 哈希与版本
- tasks.raw 记录:{ hash: string; time: number; version: string; schema: number; data: Task[] }
- project.data 记录:{ hash: string; time: number; version: string; schema: number; data: { tgProject?: TgProject; enhancedMetadata: Record<string, any> } }
- tasks.augmented 记录:{ hash: string; time: number; version: string; schema: number; data: Task[] }
- consolidated:taskIndex 记录:{ time: number; version: string; schema: number; data: TaskCacheSnapshot }
- 兼容策略
- 版本或 schema 不兼容时仅清理受影响命名空间。
- hash 优先判定是否需要重算mtime 仅作为辅助。
---
### 4. 代码示例2 段)
示例 A视图订阅事件 + QueryAPI 拉取(替代 setTasks
```ts
// 在某视图 onOpen 内
import { subscribeTaskCacheUpdated } from "src/dataflow/events/Events";
import { QueryAPI } from "src/dataflow/api/QueryAPI";
let unsub: () => void;
this.registerEvent(
(unsub = subscribeTaskCacheUpdated(async (payload) => {
const tasks = await QueryAPI.getTasksByProject(this.currentProject ?? "");
this.renderer.render(tasks); // 视图自身的渲染方法
}))
);
// onClose 时
if (unsub) unsub();
```
示例 B单文件变更 → 解析 + 增强 + 索引 + 事件
```ts
import { ParsersEntry } from "src/dataflow/parsers";
import { ProjectResolver } from "src/dataflow/project/ProjectResolver";
import { TaskAugmentor } from "src/dataflow/augment/TaskAugmentor";
import { TaskRepository } from "src/dataflow/indexer/TaskRepository";
import { emitTaskCacheUpdated } from "src/dataflow/events/Events";
import { StorageAdapter } from "src/dataflow/persistence/StorageAdapter";
async function handleFileChanged(filePath: string) {
// 1. 尝试读取 tasks.rawhash 命中则跳过正文解析)
const raw = await StorageAdapter.loadRaw(filePath);
const needsParse = !(raw && StorageAdapter.isRawValid(filePath, raw));
const rawTasks = needsParse
? await ParsersEntry.parseFile(filePath) // Markdown/Canvas/FileLevel 分流
: raw.data;
if (needsParse) await StorageAdapter.storeRaw(filePath, rawTasks);
// 2. 读取/计算项目数据(可并行)
const proj = await ProjectResolver.getProjectData(filePath);
await StorageAdapter.storeProjectData(filePath, proj);
// 3. 增强合并
const augmented = await TaskAugmentor.merge({ filePath, rawTasks, project: proj });
await StorageAdapter.storeAugmented(filePath, augmented);
// 4. 更新索引
await TaskRepository.updateFile(filePath, augmented);
// 5. 广播事件
emitTaskCacheUpdated({ changedFiles: [filePath], timestamp: Date.now() });
}
```
---
### 5. 执行清单(给贡献者)
1) 新增骨架文件
- src/dataflow/events/Events.ts事件常量与 emit/subscribe内部维护 seq。
- src/dataflow/api/QueryAPI.ts空壳方法先返回现有 TaskManager 数据(过渡)。
- src/dataflow/parsers/MarkdownTaskParserEntry.ts包装现有 ConfigurableTaskParser.parseLegacy。
- src/dataflow/parsers/CanvasTaskParserEntry.ts包装现有 CanvasParser。
- src/dataflow/parsers/FileLevelTaskParser.ts包装现有 FileMetadataTaskParser并添加“禁用项目探测”配置默认禁用
- src/dataflow/augment/TaskAugmentor.ts实现继承策略task>file>project支持子任务控制。
- src/dataflow/project/ProjectResolver.ts统一项目识别接口内部复用 ProjectConfigManager/ProjectDataCache。
- src/dataflow/persistence/StorageAdapter.ts命名空间 + 版本/schema + hash 校验;读写 helpers。
- src/dataflow/indexer/TaskRepository.ts组合 TaskIndexer 与 StorageAdapter对外提供查询。
2) TaskManager 接入
- 触发事件统一改为 Events.ts 的封装。
- 冷启动改为优先读取 consolidated 或 tasks.augmented失败再回落旧流程。
- 单文件变更路径替换为“解析入口 + Augmentor + Repository”的新流水线逐步灰度
3) 视图迁移(首批 1-2 个)
- 去除直接 setTasks 调用,改成订阅 task-cache-updated + QueryAPI 拉取。
- 保留 Adapter 以兼容未迁移视图。
4) 持久化与失效策略
- 引入命名空间 key 规则与 hash 计算;对项目配置变更只失效 project.data 与 tasks.augmented。
5) 测试
- 单测ParsersEntry、TaskAugmentor继承策略、StorageAdapterhash/版本/失效、ProjectResolver优先级
- 集成:单文件改动/重命名/删除、项目配置变更、冷启动、事件序号。
6) 文档
- 在 README 或开发文档中标注 setTasks 已废弃(迁移至事件 + QueryAPI
---
如需我补充具体类型定义TypeScript 接口)或更详细的函数注释,请告知要优先完善的模块。

View file

@ -10,15 +10,15 @@ const mockPrepareFuzzySearch = jest.fn((query: string) => {
const mockT = jest.fn((key: string) => key);
jest.mock("obsidian", () => ({
prepareFuzzySearch: mockPrepareFuzzySearch
prepareFuzzySearch: mockPrepareFuzzySearch,
}));
jest.mock("../translations/helper", () => ({
t: mockT
t: mockT,
}));
import { SettingsIndexer } from "../components/settings/SettingsIndexer";
import { SETTINGS_METADATA } from "../data/settings-metadata";
import { SETTINGS_METADATA } from "../common/settings-metadata";
describe("Settings Search Tests", () => {
let indexer: SettingsIndexer;
@ -45,7 +45,9 @@ describe("Settings Search Tests", () => {
});
test("should have progress bar settings", () => {
const progressBarMain = SETTINGS_METADATA.find(item => item.id === "progress-bar-main");
const progressBarMain = SETTINGS_METADATA.find(
(item) => item.id === "progress-bar-main"
);
expect(progressBarMain).toBeDefined();
expect(progressBarMain?.name).toBe("Progress bar");
expect(progressBarMain?.tabId).toBe("progress-bar");
@ -53,7 +55,9 @@ describe("Settings Search Tests", () => {
});
test("should have enable task filter setting", () => {
const taskFilter = SETTINGS_METADATA.find(item => item.id === "enable-task-filter");
const taskFilter = SETTINGS_METADATA.find(
(item) => item.id === "enable-task-filter"
);
expect(taskFilter).toBeDefined();
expect(taskFilter?.name).toBe("Enable Task Filter");
expect(taskFilter?.tabId).toBe("task-filter");
@ -63,7 +67,7 @@ describe("Settings Search Tests", () => {
describe("SettingsIndexer", () => {
test("should initialize correctly", () => {
expect(indexer).toBeDefined();
// Should not be initialized yet
const stats = indexer.getStats();
expect(stats.itemCount).toBeGreaterThan(0);
@ -76,30 +80,40 @@ describe("Settings Search Tests", () => {
test("should search by name", () => {
const results = indexer.search("progress");
console.log("Search results for 'progress':", results.length);
results.forEach(result => {
console.log(`- ${result.item.name} (${result.matchType}, score: ${result.score})`);
results.forEach((result) => {
console.log(
`- ${result.item.name} (${result.matchType}, score: ${result.score})`
);
});
expect(results.length).toBeGreaterThan(0);
// Should find progress bar settings
const progressResult = results.find(result =>
result.item.id === "progress-bar-main" ||
result.item.name.toLowerCase().includes("progress")
const progressResult = results.find(
(result) =>
result.item.id === "progress-bar-main" ||
result.item.name.toLowerCase().includes("progress")
);
expect(progressResult).toBeDefined();
});
test("should search by description", () => {
const results = indexer.search("toggle");
console.log("Search results for 'toggle':", results.length);
results.forEach(result => {
console.log(`- ${result.item.name} (${result.matchType}, score: ${result.score})`);
results.forEach((result) => {
console.log(
`- ${result.item.name} (${result.matchType}, score: ${result.score})`
);
if (result.item.description) {
console.log(` Description: ${result.item.description.substring(0, 100)}...`);
console.log(
` Description: ${result.item.description.substring(
0,
100
)}...`
);
}
});
@ -108,10 +122,12 @@ describe("Settings Search Tests", () => {
test("should search by keywords", () => {
const results = indexer.search("checkbox");
console.log("Search results for 'checkbox':", results.length);
results.forEach(result => {
console.log(`- ${result.item.name} (${result.matchType}, score: ${result.score})`);
results.forEach((result) => {
console.log(
`- ${result.item.name} (${result.matchType}, score: ${result.score})`
);
console.log(` Keywords: ${result.item.keywords.join(", ")}`);
});
@ -120,22 +136,30 @@ describe("Settings Search Tests", () => {
test("should prioritize name matches over description matches", () => {
const results = indexer.search("filter");
console.log("Search results for 'filter' with scores:");
results.forEach(result => {
console.log(`- ${result.item.name} (${result.matchType}, score: ${result.score})`);
results.forEach((result) => {
console.log(
`- ${result.item.name} (${result.matchType}, score: ${result.score})`
);
});
expect(results.length).toBeGreaterThan(0);
// Find name matches and description matches
const nameMatches = results.filter(r => r.matchType === "name");
const descriptionMatches = results.filter(r => r.matchType === "description");
const nameMatches = results.filter((r) => r.matchType === "name");
const descriptionMatches = results.filter(
(r) => r.matchType === "description"
);
if (nameMatches.length > 0 && descriptionMatches.length > 0) {
// Name matches should have higher scores
const highestNameScore = Math.max(...nameMatches.map(r => r.score));
const highestDescScore = Math.max(...descriptionMatches.map(r => r.score));
const highestNameScore = Math.max(
...nameMatches.map((r) => r.score)
);
const highestDescScore = Math.max(
...descriptionMatches.map((r) => r.score)
);
expect(highestNameScore).toBeGreaterThan(highestDescScore);
}
});
@ -165,7 +189,7 @@ describe("Settings Search Tests", () => {
const lowerResults = indexer.search("progress");
const upperResults = indexer.search("PROGRESS");
const mixedResults = indexer.search("Progress");
expect(lowerResults.length).toBe(upperResults.length);
expect(lowerResults.length).toBe(mixedResults.length);
});
@ -174,14 +198,14 @@ describe("Settings Search Tests", () => {
describe("Translation Integration", () => {
test("should call translation function for setting names", () => {
indexer.search("progress");
// Should have called t() for translating setting names
expect(mockT).toHaveBeenCalled();
// Check if it was called with expected translation keys
const calls = mockT.mock.calls.map((call: any) => call[0]);
console.log("Translation calls:", calls.slice(0, 10)); // Show first 10 calls
expect(calls).toContain("Progress bar");
});
});
@ -208,7 +232,7 @@ describe("Settings Search Tests", () => {
const start = performance.now();
indexer.initialize();
const end = performance.now();
const buildTime = end - start;
console.log(`Index build time: ${buildTime.toFixed(2)}ms`);
expect(buildTime).toBeLessThan(50); // Should be under 50ms
@ -216,13 +240,17 @@ describe("Settings Search Tests", () => {
test("should search quickly", () => {
indexer.initialize(); // Pre-initialize
const start = performance.now();
const results = indexer.search("progress");
const end = performance.now();
const searchTime = end - start;
console.log(`Search time: ${searchTime.toFixed(2)}ms for ${results.length} results`);
console.log(
`Search time: ${searchTime.toFixed(2)}ms for ${
results.length
} results`
);
expect(searchTime).toBeLessThan(10); // Should be under 10ms
});
});
@ -230,45 +258,50 @@ describe("Settings Search Tests", () => {
describe("Specific Setting Tests", () => {
test("should find 'Enable Task Filter' setting", () => {
const results = indexer.search("enable task filter");
console.log("Searching for 'enable task filter':");
results.forEach(result => {
results.forEach((result) => {
console.log(`- ${result.item.name} (ID: ${result.item.id})`);
});
const exactMatch = results.find(r => r.item.id === "enable-task-filter");
const exactMatch = results.find(
(r) => r.item.id === "enable-task-filter"
);
expect(exactMatch).toBeDefined();
});
test("should find progress bar settings", () => {
const results = indexer.search("progress bar");
console.log("Searching for 'progress bar':");
results.forEach(result => {
results.forEach((result) => {
console.log(`- ${result.item.name} (ID: ${result.item.id})`);
});
const progressBarMain = results.find(r => r.item.id === "progress-bar-main");
const progressBarMain = results.find(
(r) => r.item.id === "progress-bar-main"
);
expect(progressBarMain).toBeDefined();
expect(progressBarMain?.matchType).toBe("name");
});
test("should find settings by partial name", () => {
const results = indexer.search("workflow");
console.log("Searching for 'workflow':");
results.forEach(result => {
results.forEach((result) => {
console.log(`- ${result.item.name} (ID: ${result.item.id})`);
});
expect(results.length).toBeGreaterThan(0);
// Should find workflow-related settings
const workflowSettings = results.filter(r =>
r.item.name.toLowerCase().includes("workflow") ||
r.item.tabId === "workflow"
const workflowSettings = results.filter(
(r) =>
r.item.name.toLowerCase().includes("workflow") ||
r.item.tabId === "workflow"
);
expect(workflowSettings.length).toBeGreaterThan(0);
});
});
});
});

View file

@ -1,6 +1,10 @@
import { prepareFuzzySearch } from "obsidian";
import { SETTINGS_METADATA } from "../../data/settings-metadata";
import { SettingsSearchIndex, SettingSearchItem, SearchResult } from "../../types/SettingsSearch";
import { SETTINGS_METADATA } from "../../common/settings-metadata";
import {
SettingsSearchIndex,
SettingSearchItem,
SearchResult,
} from "../../types/SettingsSearch";
import { t } from "../../translations/helper";
/**
@ -12,7 +16,6 @@ export class SettingsIndexer {
private isInitialized: boolean = false;
private rootEl: HTMLElement | null = null;
constructor(rootEl?: HTMLElement | null) {
this.index = {
items: [],
@ -39,13 +42,14 @@ export class SettingsIndexer {
const endTime = performance.now();
console.log(
`[SettingsIndexer] Index built in ${(endTime - startTime).toFixed(2)}ms with ${this.index.items.length} items`
`[SettingsIndexer] Index built in ${(endTime - startTime).toFixed(
2
)}ms with ${this.index.items.length} items`
);
this.isInitialized = true;
}
/**
* SETTINGS_METADATA 退
*/
@ -54,7 +58,9 @@ export class SettingsIndexer {
const translatedItem: SettingSearchItem = {
...item,
name: t(item.translationKey),
description: item.descriptionKey ? t(item.descriptionKey) : item.description,
description: item.descriptionKey
? t(item.descriptionKey)
: item.description,
};
this.addItemToIndex(translatedItem);
}
@ -65,17 +71,24 @@ export class SettingsIndexer {
*/
private buildIndexFromDOM(root: HTMLElement): void {
// 查找所有设置 section
const sectionEls = Array.from(root.querySelectorAll<HTMLElement>(".settings-tab-section"));
const sectionEls = Array.from(
root.querySelectorAll<HTMLElement>(".settings-tab-section")
);
const seenIds = new Set<string>();
sectionEls.forEach((section) => {
const tabId = section.getAttribute("data-tab-id") || "general";
const category = section.getAttribute("data-category") || "core";
const settingItems = Array.from(section.querySelectorAll<HTMLElement>(".setting-item"));
const settingItems = Array.from(
section.querySelectorAll<HTMLElement>(".setting-item")
);
settingItems.forEach((el, idx) => {
const nameEl = el.querySelector<HTMLElement>(".setting-item-name");
const descEl = el.querySelector<HTMLElement>(".setting-item-description");
const nameEl =
el.querySelector<HTMLElement>(".setting-item-name");
const descEl = el.querySelector<HTMLElement>(
".setting-item-description"
);
const name = (nameEl?.textContent || "").trim();
const description = (descEl?.textContent || "").trim();
@ -142,9 +155,7 @@ export class SettingsIndexer {
.filter(Boolean);
// 去重并限制数量,优先较长的词
const uniq = Array.from(new Set(tokens));
return uniq
.sort((a, b) => b.length - a.length)
.slice(0, 12);
return uniq.sort((a, b) => b.length - a.length).slice(0, 12);
}
/**
@ -192,8 +203,12 @@ export class SettingsIndexer {
if (nameMatch) {
results.push({
item,
score: this.calculateScore(normalizedQuery, item.name, 'name'),
matchType: 'name'
score: this.calculateScore(
normalizedQuery,
item.name,
"name"
),
matchType: "name",
});
seenIds.add(item.id);
}
@ -207,8 +222,12 @@ export class SettingsIndexer {
if (descMatch) {
results.push({
item,
score: this.calculateScore(normalizedQuery, item.description, 'description'),
matchType: 'description'
score: this.calculateScore(
normalizedQuery,
item.description,
"description"
),
matchType: "description",
});
seenIds.add(item.id);
}
@ -223,8 +242,12 @@ export class SettingsIndexer {
if (keywordMatch) {
results.push({
item,
score: this.calculateScore(normalizedQuery, keyword, 'keyword'),
matchType: 'keyword'
score: this.calculateScore(
normalizedQuery,
keyword,
"keyword"
),
matchType: "keyword",
});
seenIds.add(item.id);
break; // 只需要一个关键词匹配即可
@ -233,9 +256,7 @@ export class SettingsIndexer {
}
// 按分数排序并限制结果数量
return results
.sort((a, b) => b.score - a.score)
.slice(0, maxResults);
return results.sort((a, b) => b.score - a.score).slice(0, maxResults);
}
/**
@ -245,7 +266,11 @@ export class SettingsIndexer {
* @param matchType
* @returns
*/
private calculateScore(query: string, target: string, matchType: 'name' | 'description' | 'keyword'): number {
private calculateScore(
query: string,
target: string,
matchType: "name" | "description" | "keyword"
): number {
const lowerTarget = target.toLowerCase();
const lowerQuery = query.toLowerCase();
@ -255,7 +280,7 @@ export class SettingsIndexer {
const baseScores = {
name: 100,
description: 60,
keyword: 80
keyword: 80,
};
score += baseScores[matchType];
@ -299,7 +324,7 @@ export class SettingsIndexer {
this.initialize();
}
return this.index.items.find(item => item.id === itemId);
return this.index.items.find((item) => item.id === itemId);
}
/**
@ -318,7 +343,11 @@ export class SettingsIndexer {
*
* @returns
*/
public getStats(): { itemCount: number; tabCount: number; keywordCount: number } {
public getStats(): {
itemCount: number;
tabCount: number;
keywordCount: number;
} {
if (!this.isInitialized) {
this.initialize();
}
@ -326,7 +355,7 @@ export class SettingsIndexer {
return {
itemCount: this.index.items.length,
tabCount: this.index.tabMap.size,
keywordCount: this.index.keywordMap.size
keywordCount: this.index.keywordMap.size,
};
}
}

View file

@ -0,0 +1,35 @@
import type { Task } from "../../types/task";
import type { App } from "obsidian";
// Simple names; a thin facade over internal repository/index
export class QueryAPI {
constructor(private app: App) {}
// TODO: wire to TaskRepository after it is introduced
async all(): Promise<Task[]> {
// Placeholder: later call repository.getAll()
// Keep behavior consistent with current TaskManager.getAllTasks()
return [];
}
async byProject(project: string): Promise<Task[]> {
return [];
}
async byTags(tags: string[]): Promise<Task[]> {
return [];
}
async byStatus(completed: boolean): Promise<Task[]> {
return [];
}
async byDateRange(opts: { from?: number; to?: number; field?: "due" | "start" | "scheduled" }): Promise<Task[]> {
return [];
}
async byId(id: string): Promise<Task | null> {
return null;
}
}

View file

@ -0,0 +1,34 @@
import type { Task } from "../../types/task";
export interface FileContext {
filePath: string;
fileMeta?: Record<string, any>;
project?: { name?: string; data?: Record<string, any> } | null;
}
// Simple naming: merge file/project context into tasks
export class Augmentor {
constructor(private options?: { inherit?: Record<string, "task" | "file" | "project" | "merge-array"> }) {}
merge(ctx: FileContext, tasks: Task[]): Task[] {
const inherit = this.options?.inherit || {};
return tasks.map((t) => this.mergeOne(ctx, t, inherit));
}
private mergeOne(ctx: FileContext, t: Task, inherit: Record<string, string>): Task {
const meta = { ...(t.metadata || {}) } as Record<string, any>;
// Example basic rules (keep minimal; extend later):
// project
if (ctx.project?.name && (meta.project == null)) {
meta.project = ctx.project.name;
}
// tags (merge-array)
const fileTags = Array.isArray(ctx.fileMeta?.tags) ? ctx.fileMeta!.tags : [];
const taskTags = Array.isArray(meta.tags) ? meta.tags : [];
meta.tags = Array.from(new Set([...(taskTags as any), ...fileTags]));
return { ...t, metadata: meta } as Task;
}
}

View file

@ -0,0 +1,42 @@
import type { App, EventRef } from "obsidian";
// Keep names simple and consistent
export const Events = {
CACHE_READY: "task-genius:cache-ready",
TASK_CACHE_UPDATED: "task-genius:task-cache-updated",
FILE_UPDATED: "task-genius:file-updated",
PROJECT_DATA_UPDATED: "task-genius:project-data-updated",
SETTINGS_CHANGED: "task-genius:settings-changed",
TASK_COMPLETED: "task-genius:task-completed",
TASK_ADDED: "task-genius:task-added",
TASK_UPDATED: "task-genius:task-updated",
TASK_DELETED: "task-genius:task-deleted",
} as const;
export type SeqClock = { next(): number };
let _seq = 0;
export const Seq: SeqClock = {
next() {
_seq = (_seq + 1) >>> 0;
return _seq;
},
};
// Emit helpers (payload kept as any to maintain compatibility with existing trigger signatures)
export function emit(app: App, name: string, payload?: any): void {
// @ts-expect-error keep compatibility with existing Obsidian typing overloads
app.workspace.trigger(name, payload);
}
export function on(app: App, name: string, handler: (...args: any[]) => void): EventRef {
// Consumers should use component.registerEvent(on(...)) to auto-unsub
return app.workspace.on(name as any, handler) as EventRef;
}
// Convenience
export const onTaskCacheUpdated = (app: App, handler: (payload: any) => void) =>
on(app, Events.TASK_CACHE_UPDATED, handler);
export const emitTaskCacheUpdated = (app: App, payload: any) =>
emit(app, Events.TASK_CACHE_UPDATED, payload);

View file

@ -0,0 +1,17 @@
import type { Task } from "../../types/task";
// Thin repository façade; will delegate to existing TaskIndexer
export class Repository {
async updateFile(path: string, tasks: Task[]): Promise<void> {
// TODO: wire to TaskIndexer.updateIndexWithTasks
}
async all(): Promise<Task[]> {
return [];
}
async byProject(project: string): Promise<Task[]> {
return [];
}
}

View file

@ -0,0 +1,12 @@
import { CanvasParser } from "../../utils/parsing/CanvasParser";
import type { Task } from "../../types/task";
import { getConfig } from "../../common/task-parser-config";
import TaskProgressBarPlugin from "../../index";
// This entry requires plugin to provide config like original code did
export async function parseCanvas(content: string, filePath: string, plugin: TaskProgressBarPlugin): Promise<Task[]> {
const config = getConfig(plugin.settings.preferMetadataFormat, plugin);
const parser = new CanvasParser(config);
return parser.parseCanvasFile(content, filePath);
}

View file

@ -0,0 +1,19 @@
import { FileMetadataTaskParser } from "../../utils/workers/FileMetadataTaskParser";
import type { Task } from "../../types/task";
import type { FileParsingConfiguration } from "../../common/setting-definition";
// Project detection is disabled here; project will be added in augment stage
export async function parseFileMeta(
content: string,
filePath: string,
fileCache: any,
config: FileParsingConfiguration
): Promise<Task[]> {
const parser = new FileMetadataTaskParser({
...config,
// ensure project detection defaults are respected; detection methods are passed as undefined here
} as any, undefined);
const { tasks } = parser.parseFileForTasks(filePath, content, fileCache);
return tasks;
}

View file

@ -0,0 +1,30 @@
import { MarkdownTaskParser } from "../../utils/workers/ConfigurableTaskParser";
import type { Task } from "../../types/task";
import { TaskParserConfig, MetadataParseMode } from "../../types/TaskParserConfig";
// Default config for compatibility
const defaultConfig: TaskParserConfig = {
parseMetadata: true,
parseTags: true,
parseComments: true,
parseHeadings: true,
maxIndentSize: 10,
maxParseIterations: 1000,
maxMetadataIterations: 10,
maxTagLength: 50,
maxEmojiValueLength: 100,
maxStackOperations: 100,
maxStackSize: 50,
statusMapping: {},
emojiMapping: {},
metadataParseMode: MetadataParseMode.Both,
specialTagPrefixes: {}
};
const parser = new MarkdownTaskParser(defaultConfig);
export async function parseMarkdown(content: string, filePath: string, fileMetadata?: Record<string, any>): Promise<Task[]> {
// Use existing legacy output for compatibility
return parser.parseLegacy(content, filePath, fileMetadata);
}

View file

@ -0,0 +1,26 @@
import type { Task } from "../../types/task";
// Simple storage facade; will wrap LocalStorageCache later
export type RawRecord = { hash: string; time: number; data: Task[] };
export type ProjectRecord = { hash: string; time: number; data: { project?: string; meta: Record<string, any> } };
export type AugmentedRecord = { hash: string; time: number; data: Task[] };
export const Keys = {
raw: (path: string) => `tasks.raw:${path}`,
project: (path: string) => `project.data:${path}`,
augmented: (path: string) => `tasks.augmented:${path}`,
consolidated: () => `consolidated:taskIndex`,
};
export class Storage {
// Placeholder API; integrate with LocalStorageCache in next step
async loadRaw(path: string): Promise<RawRecord | null> { return null }
async storeRaw(path: string, rec: RawRecord): Promise<void> {}
async loadProject(path: string): Promise<ProjectRecord | null> { return null }
async storeProject(path: string, rec: ProjectRecord): Promise<void> {}
async loadAugmented(path: string): Promise<AugmentedRecord | null> { return null }
async storeAugmented(path: string, rec: AugmentedRecord): Promise<void> {}
}

View file

@ -0,0 +1,15 @@
// Simple project resolver skeleton
export interface ProjectData {
name?: string;
meta: Record<string, any>;
}
export class Resolver {
constructor() {}
// TODO: integrate with ProjectConfigManager/ProjectDataCache
async get(path: string): Promise<ProjectData | null> {
return { name: undefined, meta: {} };
}
}