diff --git a/main.ts b/main.ts index 2ba4d65..e58b68b 100644 --- a/main.ts +++ b/main.ts @@ -9,6 +9,7 @@ import TodoService, { Todo } from "src/todoService"; export default class ExtendedTaskListsPlugin extends Plugin { settings!: ExtendedTaskListsSettings; + private isUpdating = false; async onload() { await this.loadSettings(); @@ -117,20 +118,83 @@ export default class ExtendedTaskListsPlugin extends Plugin { } updateTodoFile = async () => { + if (this.isUpdating) return; + this.isUpdating = true; + try { + await this.doUpdateTodoFile(); + } finally { + this.isUpdating = false; + } + }; + + private async doUpdateTodoFile() { const vault = this.app.vault; const fileService = new VaultFileService(vault); const service = new TodoService(fileService, this.settings); - const todoFiles = await service.findTodosFiles(); - const todos: Todo[] = todoFiles + const sourceFiles = await service.findTodosFiles(); + const allTodos: Todo[] = sourceFiles .map((todoFile) => { const todos = service.parseTodos(todoFile.contents); todos.forEach((todo) => (todo.file = todoFile.file)); return todos; }) .reduce((prev, cur) => prev.concat(cur), []); - const todoFile = await this.getOrCreateTodoFile(vault); - await service.saveTodos(todoFile as IFile, todos); - }; + + if (!this.settings.enableNestedTodos) { + const todoFile = await this.getOrCreateTodoFile(vault); + await service.saveTodos(todoFile as IFile, allTodos); + return; + } + + const allTodoTargets = await service.findAllTodoFiles(); + const isRootTodoFile = (f: IFile) => + f.path === this.settings.todoFilename || + f.path === "/" + this.settings.todoFilename; + const rootTodoFile = allTodoTargets.find(isRootTodoFile); + const nestedTodoFiles = allTodoTargets.filter( + (f) => !isRootTodoFile(f), + ); + + const claimedTodoKeys = new Set(); + + nestedTodoFiles.sort((a, b) => { + const depthA = a.path.split("/").length; + const depthB = b.path.split("/").length; + return depthB - depthA; + }); + + for (const nestedTodo of nestedTodoFiles) { + let scopedTodos = service.filterTodosByScope( + allTodos, + nestedTodo.path, + ); + + if (this.settings.excludeNestedFromParent) { + scopedTodos = scopedTodos.filter( + (todo) => + !claimedTodoKeys.has( + `${todo.file.path}:${todo.lineno}`, + ), + ); + for (const todo of scopedTodos) { + claimedTodoKeys.add(`${todo.file.path}:${todo.lineno}`); + } + } + + await service.saveTodos(nestedTodo, scopedTodos); + } + + const rootFile = + rootTodoFile ?? ((await this.getOrCreateTodoFile(vault)) as IFile); + let rootTodos = allTodos; + if (this.settings.excludeNestedFromParent && claimedTodoKeys.size > 0) { + rootTodos = allTodos.filter( + (todo) => + !claimedTodoKeys.has(`${todo.file.path}:${todo.lineno}`), + ); + } + await service.saveTodos(rootFile, rootTodos); + } onTodoFileUpdated = async (todoFile: TFile): Promise => { const vault = this.app.vault; diff --git a/src/settings.ts b/src/settings.ts index 6c89a10..14a84bc 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,6 +4,8 @@ interface ExtendedTaskListsSettings { todoFilename: string; useFullFilepath: boolean; useHierarchy: boolean; + enableNestedTodos: boolean; + excludeNestedFromParent: boolean; excludeFilePattern: string; excludeFolderFilename: string; excludeRegionBegin: string; @@ -18,6 +20,8 @@ const DEFAULT_SETTINGS: ExtendedTaskListsSettings = { todoFilename: "TODO.md", useFullFilepath: false, useHierarchy: false, + enableNestedTodos: false, + excludeNestedFromParent: true, excludeFilePattern: "", excludeFolderFilename: ".exclude_todos", excludeRegionBegin: "%% exclude: start %%", @@ -85,6 +89,34 @@ class ExtendedTaskListsSettingTab extends PluginSettingTab { }), ); + new Setting(containerEl) + .setName("Enable nested TODOs") + .setDesc( + "When enabled, any TODO file you create in a subfolder will be populated with task items from that folder and its subfolders.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.enableNestedTodos) + .onChange(async (value) => { + this.plugin.settings.enableNestedTodos = value; + await this.plugin.saveSettings(); + }), + ); + + new Setting(containerEl) + .setName("Exclude nested from parent") + .setDesc( + "When enabled, task items that appear in a nested TODO file are excluded from ancestor TODO files to avoid duplication.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.excludeNestedFromParent) + .onChange(async (value) => { + this.plugin.settings.excludeNestedFromParent = value; + await this.plugin.saveSettings(); + }), + ); + new Setting(containerEl).setName("Excludes").setHeading(); new Setting(containerEl) diff --git a/src/todoService.test.ts b/src/todoService.test.ts index 27021f8..091ad7c 100644 --- a/src/todoService.test.ts +++ b/src/todoService.test.ts @@ -11,6 +11,8 @@ const MOCK_SETTINGS = { excludeRegionEnd: "%% exclude: end %%", useFullFilepath: false, useHierarchy: false, + enableNestedTodos: false, + excludeNestedFromParent: true, includeNotStarted: true, includeInProgress: true, includeWontDo: false, @@ -74,11 +76,11 @@ class MockFileService implements IFileService { } async readFile(file: IFile): Promise { - return this.files.filter((f) => f.name === file.name)[0].content; + return this.files.filter((f) => f.path === file.path)[0].content; } async updateFile(file: IFile, data: string): Promise { - this.files.filter((f) => f.name === file.name)[0].content = data; + this.files.filter((f) => f.path === file.path)[0].content = data; } async checkExists(filepath: string): Promise { @@ -685,6 +687,233 @@ describe("TodoService", () => { expect(actual).toEqual(expected); }); + test("findAllTodoFiles returns all TODO.md files including nested ones", async () => { + // Arrange + const rootTodo = createMockFile("TODO.md", ""); + const folder = createMockFile("Projects", ""); + const nestedTodo = createMockFile("TODO.md", "", folder); + const tasksFile = createMockFile("Tasks.md", "", folder); + + const mockFileService = new MockFileService([ + rootTodo, + folder, + nestedTodo, + tasksFile, + ]); + + // Act + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + const actual = await todoService.findAllTodoFiles(); + + // Assert + expect(actual).toEqual([rootTodo, nestedTodo]); + }); + + test("filterTodosByScope returns all todos for root TODO.md", () => { + // Arrange + const folderA = createMockFile("FolderA", ""); + const fileA = createMockFile("Tasks.md", "", folderA); + const fileRoot = createMockFile("Notes.md", ""); + + const todos: Todo[] = [ + { + task: TaskType.NotStarted, + text: "task A", + indentation: "", + lineno: 0, + file: fileA, + }, + { + task: TaskType.NotStarted, + text: "root task", + indentation: "", + lineno: 0, + file: fileRoot, + }, + ]; + + const mockFileService = new MockFileService([]); + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + + // Act + const actual = todoService.filterTodosByScope(todos, "/TODO.md"); + + // Assert + expect(actual).toEqual(todos); + }); + + test("filterTodosByScope returns only folder-scoped todos for nested TODO.md", () => { + // Arrange + const projects = createMockFile("Projects", ""); + const fileA = createMockFile("api.md", "", projects); + const notes = createMockFile("Notes", ""); + const fileB = createMockFile("diary.md", "", notes); + + const todoA: Todo = { + task: TaskType.NotStarted, + text: "api task", + indentation: "", + lineno: 0, + file: fileA, + }; + const todoB: Todo = { + task: TaskType.NotStarted, + text: "diary task", + indentation: "", + lineno: 0, + file: fileB, + }; + + const mockFileService = new MockFileService([]); + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + + // Act + const actual = todoService.filterTodosByScope( + [todoA, todoB], + "/Projects/TODO.md", + ); + + // Assert + expect(actual).toEqual([todoA]); + }); + + test("filterTodosByScope handles deeply nested scope", () => { + // Arrange + const projects = createMockFile("Projects", ""); + const backend = createMockFile("Backend", "", projects); + const fileDeep = createMockFile("db.md", "", backend); + const fileShallow = createMockFile("api.md", "", projects); + + const todoDeep: Todo = { + task: TaskType.NotStarted, + text: "db task", + indentation: "", + lineno: 0, + file: fileDeep, + }; + const todoShallow: Todo = { + task: TaskType.NotStarted, + text: "api task", + indentation: "", + lineno: 0, + file: fileShallow, + }; + + const mockFileService = new MockFileService([]); + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + + // Act + const actual = todoService.filterTodosByScope( + [todoDeep, todoShallow], + "/Projects/Backend/TODO.md", + ); + + // Assert + expect(actual).toEqual([todoDeep]); + }); + + test("filterTodosByScope handles paths without leading slash", () => { + // Arrange + const projects = createMockFile("Projects", ""); + const fileA = createMockFile("api.md", "", projects); + // Simulate Obsidian-style paths (no leading slash) + (fileA as MockFile).path = "Projects/api.md"; + + const todo: Todo = { + task: TaskType.NotStarted, + text: "api task", + indentation: "", + lineno: 0, + file: fileA, + }; + + const mockFileService = new MockFileService([]); + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + + // Act + const actual = todoService.filterTodosByScope( + [todo], + "Projects/TODO.md", + ); + + // Assert + expect(actual).toEqual([todo]); + }); + + test("saveTodos writes scoped todos to nested TODO.md", async () => { + // Arrange + const projects = createMockFile("Projects", ""); + const fileA = createMockFile("api.md", "", projects); + const notes = createMockFile("Notes", ""); + const fileB = createMockFile("diary.md", "", notes); + + const todoA: Todo = { + task: TaskType.NotStarted, + text: "api task", + indentation: "", + lineno: 0, + file: fileA, + }; + const todoB: Todo = { + task: TaskType.NotStarted, + text: "diary task", + indentation: "", + lineno: 0, + file: fileB, + }; + + const nestedTodo = createMockFile("TODO.md", "", projects); + const mockFileService = new MockFileService([nestedTodo]); + const todoService = new TodoService(mockFileService, MOCK_SETTINGS); + + // Act + const scoped = todoService.filterTodosByScope( + [todoA, todoB], + nestedTodo.path, + ); + await todoService.saveTodos(nestedTodo, scoped); + + // Assert + const expected = `- [api.md](/Projects/api.md)\n\t- [ ] api task\n`; + expect(nestedTodo.content).toEqual(expected); + }); + + test("saveTodos with useHierarchy and nested TODO strips scope prefix", async () => { + // Arrange + const year = createMockFile("2026", ""); + const month = createMockFile("05_May", "", year); + const dayFile = createMockFile("12_Tuesday.md", "", month); + + const todos = [ + { + task: TaskType.NotStarted, + text: "do the thing", + indentation: "", + lineno: 0, + file: dayFile, + } as Todo, + ]; + + const nestedTodo = createMockFile("TODO.md", "", month); + const mockFileService = new MockFileService([nestedTodo]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(nestedTodo, todos); + + // Assert — hierarchy should be relative to the TODO's folder + const expected = `- [12_Tuesday.md](/2026/05_May/12_Tuesday.md) +\t- [ ] do the thing +`; + + expect(nestedTodo.content).toEqual(expected); + }); + test("Whole shebang formats TODO.md correctly with task items nested under normal lists", async () => { // Arrange const taskFile = createMockFile( diff --git a/src/todoService.ts b/src/todoService.ts index a361986..a5a0c07 100644 --- a/src/todoService.ts +++ b/src/todoService.ts @@ -80,6 +80,29 @@ class TodoService { return todoFiles; } + async findAllTodoFiles(): Promise { + const allFiles = await this.fileService.getFiles(); + return allFiles.filter( + (file) => file.name === this.settings.todoFilename, + ); + } + + filterTodosByScope(todos: Todo[], todoFilePath: string): Todo[] { + const normalizedPath = todoFilePath.replace(/^\//, ""); + const lastSlash = normalizedPath.lastIndexOf("/"); + + if (lastSlash < 0) { + return [...todos]; + } + + const folderPrefix = normalizedPath.substring(0, lastSlash + 1); + + return todos.filter((todo) => { + const filePath = todo.file.path.replace(/^\//, ""); + return filePath.startsWith(folderPrefix); + }); + } + /** * Parse the auto-generated TODO file */ @@ -218,7 +241,7 @@ class TodoService { }); if (this.settings.useHierarchy) { - data = this.formatHierarchy(todosByFile); + data = this.formatHierarchy(todosByFile, todoFile); } else { todosByFile.forEach((todos, file) => { const urlEncodedFilePath = encodeURI(file.path); @@ -240,17 +263,26 @@ class TodoService { .catch((err) => console.error(err)); } - private formatHierarchy(todosByFile: Map): string { + private formatHierarchy( + todosByFile: Map, + todoFile: IFile, + ): string { interface FolderNode { name: string; children: Map; files: { file: IFile; todos: Todo[] }[]; } + const todoDir = todoFile.path.replace(/^\//, "").replace(/[^/]+$/, ""); + const root: FolderNode = { name: "", children: new Map(), files: [] }; todosByFile.forEach((todos, file) => { - const parts = file.path.replace(/^\//, "").split("/"); + let relativePath = file.path.replace(/^\//, ""); + if (todoDir && relativePath.startsWith(todoDir)) { + relativePath = relativePath.substring(todoDir.length); + } + const parts = relativePath.split("/"); parts.pop(); let node = root; for (const part of parts) {