diff --git a/src/settings.ts b/src/settings.ts index 65c930c..6c89a10 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,11 +2,12 @@ import { App, Plugin, PluginSettingTab, Setting } from "obsidian"; interface ExtendedTaskListsSettings { todoFilename: string; + useFullFilepath: boolean; + useHierarchy: boolean; excludeFilePattern: string; excludeFolderFilename: string; excludeRegionBegin: string; excludeRegionEnd: string; - useFullFilepath: boolean; includeNotStarted: boolean; includeInProgress: boolean; includeWontDo: boolean; @@ -15,11 +16,12 @@ interface ExtendedTaskListsSettings { const DEFAULT_SETTINGS: ExtendedTaskListsSettings = { todoFilename: "TODO.md", + useFullFilepath: false, + useHierarchy: false, excludeFilePattern: "", excludeFolderFilename: ".exclude_todos", excludeRegionBegin: "%% exclude: start %%", excludeRegionEnd: "%% exclude: end %%", - useFullFilepath: false, includeNotStarted: true, includeInProgress: true, includeWontDo: false, @@ -69,6 +71,20 @@ class ExtendedTaskListsSettingTab extends PluginSettingTab { }), ); + new Setting(containerEl) + .setName("Use hierarchy") + .setDesc( + "If checked, task items in the generated TODO file are organized according to the folder structure of the Vault.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.useHierarchy) + .onChange(async (value) => { + this.plugin.settings.useHierarchy = 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 c1d6d32..27021f8 100644 --- a/src/todoService.test.ts +++ b/src/todoService.test.ts @@ -10,6 +10,7 @@ const MOCK_SETTINGS = { excludeRegionBegin: "%% exclude: start %%", excludeRegionEnd: "%% exclude: end %%", useFullFilepath: false, + useHierarchy: false, includeNotStarted: true, includeInProgress: true, includeWontDo: false, @@ -452,6 +453,238 @@ describe("TodoService", () => { expect(actual.map((t) => t.text)).toEqual(["Included"]); }); + test("saveTodos with useHierarchy organizes by folder structure", async () => { + // Arrange + const year = createMockFile("2022", ""); + const month = createMockFile("06_June", "", year); + const dayFile = createMockFile("08_Wednesday.md", "", month); + + const todos = [ + { + task: TaskType.NotStarted, + text: "do the thing", + indentation: "", + lineno: 0, + file: dayFile, + } as Todo, + ]; + + const todoFile = createMockFile("TODO.md", ""); + const mockFileService = new MockFileService([todoFile]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(todoFile, todos); + + // Assert + const expected = `- 2022 +\t- 06_June +\t\t- [08_Wednesday.md](/2022/06_June/08_Wednesday.md) +\t\t\t- [ ] do the thing +`; + + const actual = todoFile.content; + expect(actual).toEqual(expected); + }); + + test("saveTodos with useHierarchy handles multiple files in different folders", async () => { + // Arrange + const folderA = createMockFile("FolderA", ""); + const fileA = createMockFile("Tasks.md", "", folderA); + + const folderB = createMockFile("FolderB", ""); + const fileB = createMockFile("Notes.md", "", folderB); + + const todos = [ + { + task: TaskType.NotStarted, + text: "task A", + indentation: "", + lineno: 0, + file: fileA, + } as Todo, + { + task: TaskType.NotStarted, + text: "task B", + indentation: "", + lineno: 0, + file: fileB, + } as Todo, + ]; + + const todoFile = createMockFile("TODO.md", ""); + const mockFileService = new MockFileService([todoFile]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(todoFile, todos); + + // Assert + const expected = `- FolderA +\t- [Tasks.md](/FolderA/Tasks.md) +\t\t- [ ] task A +- FolderB +\t- [Notes.md](/FolderB/Notes.md) +\t\t- [ ] task B +`; + + const actual = todoFile.content; + expect(actual).toEqual(expected); + }); + + test("saveTodos with useHierarchy handles files at root level", async () => { + // Arrange + const rootFile = createMockFile("Tasks.md", ""); + const folder = createMockFile("Folder", ""); + const nestedFile = createMockFile("Notes.md", "", folder); + + const todos = [ + { + task: TaskType.NotStarted, + text: "root task", + indentation: "", + lineno: 0, + file: rootFile, + } as Todo, + { + task: TaskType.NotStarted, + text: "nested task", + indentation: "", + lineno: 0, + file: nestedFile, + } as Todo, + ]; + + const todoFile = createMockFile("TODO.md", ""); + const mockFileService = new MockFileService([todoFile]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(todoFile, todos); + + // Assert + const expected = `- Folder +\t- [Notes.md](/Folder/Notes.md) +\t\t- [ ] nested task +- [Tasks.md](/Tasks.md) +\t- [ ] root task +`; + + const actual = todoFile.content; + expect(actual).toEqual(expected); + }); + + test("saveTodos with useHierarchy preserves nested todo indentation", async () => { + // Arrange + const folder = createMockFile("Projects", ""); + const file = createMockFile("Work.md", "", folder); + + const todos = [ + { + task: TaskType.NotStarted, + text: "parent task", + indentation: "", + lineno: 0, + file: file, + } as Todo, + { + task: TaskType.InProgress, + text: "child task", + indentation: " ", + lineno: 1, + file: file, + } as Todo, + ]; + + const todoFile = createMockFile("TODO.md", ""); + const mockFileService = new MockFileService([todoFile]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(todoFile, todos); + + // Assert + const expected = `- Projects +\t- [Work.md](/Projects/Work.md) +\t\t- [ ] parent task +\t\t - [.] child task +`; + + const actual = todoFile.content; + expect(actual).toEqual(expected); + }); + + test("saveTodos with useHierarchy shares common folder prefixes", async () => { + // Arrange + const folder = createMockFile("2022", ""); + const sub1 = createMockFile("Q1", "", folder); + const sub2 = createMockFile("Q2", "", folder); + const file1 = createMockFile("Jan.md", "", sub1); + const file2 = createMockFile("Apr.md", "", sub2); + + const todos = [ + { + task: TaskType.NotStarted, + text: "jan task", + indentation: "", + lineno: 0, + file: file1, + } as Todo, + { + task: TaskType.NotStarted, + text: "apr task", + indentation: "", + lineno: 0, + file: file2, + } as Todo, + ]; + + const todoFile = createMockFile("TODO.md", ""); + const mockFileService = new MockFileService([todoFile]); + + const settings = { + ...MOCK_SETTINGS, + useHierarchy: true, + }; + + // Act + const todoService = new TodoService(mockFileService, settings); + await todoService.saveTodos(todoFile, todos); + + // Assert + const expected = `- 2022 +\t- Q1 +\t\t- [Jan.md](/2022/Q1/Jan.md) +\t\t\t- [ ] jan task +\t- Q2 +\t\t- [Apr.md](/2022/Q2/Apr.md) +\t\t\t- [ ] apr task +`; + + const actual = todoFile.content; + expect(actual).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 beb3b93..a361986 100644 --- a/src/todoService.ts +++ b/src/todoService.ts @@ -217,25 +217,83 @@ class TodoService { } }); - todosByFile.forEach((todos, file) => { - const urlEncodedFilePath = encodeURI(file.path); + if (this.settings.useHierarchy) { + data = this.formatHierarchy(todosByFile); + } else { + todosByFile.forEach((todos, file) => { + const urlEncodedFilePath = encodeURI(file.path); - const heading = this.settings.useFullFilepath - ? `- [${file.path}](${urlEncodedFilePath})\n` - : `- [${file.basename}](${urlEncodedFilePath})\n`; + const heading = this.settings.useFullFilepath + ? `- [${file.path}](${urlEncodedFilePath})\n` + : `- [${file.basename}](${urlEncodedFilePath})\n`; - data += heading; + data += heading; - todos.forEach((todo) => { - data += `\t${todo.indentation}- [${todo.task}] ${todo.text}\n`; + todos.forEach((todo) => { + data += `\t${todo.indentation}- [${todo.task}] ${todo.text}\n`; + }); }); - }); + } this.fileService .updateFile(todoFile, data) .catch((err) => console.error(err)); } + private formatHierarchy(todosByFile: Map): string { + interface FolderNode { + name: string; + children: Map; + files: { file: IFile; todos: Todo[] }[]; + } + + const root: FolderNode = { name: "", children: new Map(), files: [] }; + + todosByFile.forEach((todos, file) => { + const parts = file.path.replace(/^\//, "").split("/"); + parts.pop(); + let node = root; + for (const part of parts) { + if (!node.children.has(part)) { + node.children.set(part, { + name: part, + children: new Map(), + files: [], + }); + } + const child = node.children.get(part); + if (child) { + node = child; + } + } + node.files.push({ file, todos }); + }); + + let data = ""; + const renderNode = (node: FolderNode, depth: number) => { + const indent = "\t".repeat(depth); + + const sortedChildren = [...node.children.values()].sort((a, b) => + a.name.localeCompare(b.name), + ); + for (const child of sortedChildren) { + data += `${indent}- ${child.name}\n`; + renderNode(child, depth + 1); + } + + for (const { file, todos } of node.files) { + const urlEncodedFilePath = encodeURI(file.path); + data += `${indent}- [${file.basename}](${urlEncodedFilePath})\n`; + for (const todo of todos) { + data += `${indent}\t${todo.indentation}- [${todo.task}] ${todo.text}\n`; + } + } + }; + + renderNode(root, 0); + return data; + } + getIncludedTaskTypes(): Set { const taskTypes = new Set(); if (this.settings.includeNotStarted) taskTypes.add(TaskType.NotStarted);