Skip to content

Commit 7d77a56

Browse files
authored
Merge pull request #37 from joeriddles/26-hierarchy
feat: hierarchy
2 parents b85ba54 + 4516ef4 commit 7d77a56

3 files changed

Lines changed: 318 additions & 11 deletions

File tree

src/settings.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
22

33
interface ExtendedTaskListsSettings {
44
todoFilename: string;
5+
useFullFilepath: boolean;
6+
useHierarchy: boolean;
57
excludeFilePattern: string;
68
excludeFolderFilename: string;
79
excludeRegionBegin: string;
810
excludeRegionEnd: string;
9-
useFullFilepath: boolean;
1011
includeNotStarted: boolean;
1112
includeInProgress: boolean;
1213
includeWontDo: boolean;
@@ -15,11 +16,12 @@ interface ExtendedTaskListsSettings {
1516

1617
const DEFAULT_SETTINGS: ExtendedTaskListsSettings = {
1718
todoFilename: "TODO.md",
19+
useFullFilepath: false,
20+
useHierarchy: false,
1821
excludeFilePattern: "<!-- exclude TODO -->",
1922
excludeFolderFilename: ".exclude_todos",
2023
excludeRegionBegin: "%% exclude: start %%",
2124
excludeRegionEnd: "%% exclude: end %%",
22-
useFullFilepath: false,
2325
includeNotStarted: true,
2426
includeInProgress: true,
2527
includeWontDo: false,
@@ -69,6 +71,20 @@ class ExtendedTaskListsSettingTab extends PluginSettingTab {
6971
}),
7072
);
7173

74+
new Setting(containerEl)
75+
.setName("Use hierarchy")
76+
.setDesc(
77+
"If checked, task items in the generated TODO file are organized according to the folder structure of the Vault.",
78+
)
79+
.addToggle((toggle) =>
80+
toggle
81+
.setValue(this.plugin.settings.useHierarchy)
82+
.onChange(async (value) => {
83+
this.plugin.settings.useHierarchy = value;
84+
await this.plugin.saveSettings();
85+
}),
86+
);
87+
7288
new Setting(containerEl).setName("Excludes").setHeading();
7389

7490
new Setting(containerEl)

src/todoService.test.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const MOCK_SETTINGS = {
1010
excludeRegionBegin: "%% exclude: start %%",
1111
excludeRegionEnd: "%% exclude: end %%",
1212
useFullFilepath: false,
13+
useHierarchy: false,
1314
includeNotStarted: true,
1415
includeInProgress: true,
1516
includeWontDo: false,
@@ -452,6 +453,238 @@ describe("TodoService", () => {
452453
expect(actual.map((t) => t.text)).toEqual(["Included"]);
453454
});
454455

456+
test("saveTodos with useHierarchy organizes by folder structure", async () => {
457+
// Arrange
458+
const year = createMockFile("2022", "");
459+
const month = createMockFile("06_June", "", year);
460+
const dayFile = createMockFile("08_Wednesday.md", "", month);
461+
462+
const todos = [
463+
{
464+
task: TaskType.NotStarted,
465+
text: "do the thing",
466+
indentation: "",
467+
lineno: 0,
468+
file: dayFile,
469+
} as Todo,
470+
];
471+
472+
const todoFile = createMockFile("TODO.md", "");
473+
const mockFileService = new MockFileService([todoFile]);
474+
475+
const settings = {
476+
...MOCK_SETTINGS,
477+
useHierarchy: true,
478+
};
479+
480+
// Act
481+
const todoService = new TodoService(mockFileService, settings);
482+
await todoService.saveTodos(todoFile, todos);
483+
484+
// Assert
485+
const expected = `- 2022
486+
\t- 06_June
487+
\t\t- [08_Wednesday.md](/2022/06_June/08_Wednesday.md)
488+
\t\t\t- [ ] do the thing
489+
`;
490+
491+
const actual = todoFile.content;
492+
expect(actual).toEqual(expected);
493+
});
494+
495+
test("saveTodos with useHierarchy handles multiple files in different folders", async () => {
496+
// Arrange
497+
const folderA = createMockFile("FolderA", "");
498+
const fileA = createMockFile("Tasks.md", "", folderA);
499+
500+
const folderB = createMockFile("FolderB", "");
501+
const fileB = createMockFile("Notes.md", "", folderB);
502+
503+
const todos = [
504+
{
505+
task: TaskType.NotStarted,
506+
text: "task A",
507+
indentation: "",
508+
lineno: 0,
509+
file: fileA,
510+
} as Todo,
511+
{
512+
task: TaskType.NotStarted,
513+
text: "task B",
514+
indentation: "",
515+
lineno: 0,
516+
file: fileB,
517+
} as Todo,
518+
];
519+
520+
const todoFile = createMockFile("TODO.md", "");
521+
const mockFileService = new MockFileService([todoFile]);
522+
523+
const settings = {
524+
...MOCK_SETTINGS,
525+
useHierarchy: true,
526+
};
527+
528+
// Act
529+
const todoService = new TodoService(mockFileService, settings);
530+
await todoService.saveTodos(todoFile, todos);
531+
532+
// Assert
533+
const expected = `- FolderA
534+
\t- [Tasks.md](/FolderA/Tasks.md)
535+
\t\t- [ ] task A
536+
- FolderB
537+
\t- [Notes.md](/FolderB/Notes.md)
538+
\t\t- [ ] task B
539+
`;
540+
541+
const actual = todoFile.content;
542+
expect(actual).toEqual(expected);
543+
});
544+
545+
test("saveTodos with useHierarchy handles files at root level", async () => {
546+
// Arrange
547+
const rootFile = createMockFile("Tasks.md", "");
548+
const folder = createMockFile("Folder", "");
549+
const nestedFile = createMockFile("Notes.md", "", folder);
550+
551+
const todos = [
552+
{
553+
task: TaskType.NotStarted,
554+
text: "root task",
555+
indentation: "",
556+
lineno: 0,
557+
file: rootFile,
558+
} as Todo,
559+
{
560+
task: TaskType.NotStarted,
561+
text: "nested task",
562+
indentation: "",
563+
lineno: 0,
564+
file: nestedFile,
565+
} as Todo,
566+
];
567+
568+
const todoFile = createMockFile("TODO.md", "");
569+
const mockFileService = new MockFileService([todoFile]);
570+
571+
const settings = {
572+
...MOCK_SETTINGS,
573+
useHierarchy: true,
574+
};
575+
576+
// Act
577+
const todoService = new TodoService(mockFileService, settings);
578+
await todoService.saveTodos(todoFile, todos);
579+
580+
// Assert
581+
const expected = `- Folder
582+
\t- [Notes.md](/Folder/Notes.md)
583+
\t\t- [ ] nested task
584+
- [Tasks.md](/Tasks.md)
585+
\t- [ ] root task
586+
`;
587+
588+
const actual = todoFile.content;
589+
expect(actual).toEqual(expected);
590+
});
591+
592+
test("saveTodos with useHierarchy preserves nested todo indentation", async () => {
593+
// Arrange
594+
const folder = createMockFile("Projects", "");
595+
const file = createMockFile("Work.md", "", folder);
596+
597+
const todos = [
598+
{
599+
task: TaskType.NotStarted,
600+
text: "parent task",
601+
indentation: "",
602+
lineno: 0,
603+
file: file,
604+
} as Todo,
605+
{
606+
task: TaskType.InProgress,
607+
text: "child task",
608+
indentation: " ",
609+
lineno: 1,
610+
file: file,
611+
} as Todo,
612+
];
613+
614+
const todoFile = createMockFile("TODO.md", "");
615+
const mockFileService = new MockFileService([todoFile]);
616+
617+
const settings = {
618+
...MOCK_SETTINGS,
619+
useHierarchy: true,
620+
};
621+
622+
// Act
623+
const todoService = new TodoService(mockFileService, settings);
624+
await todoService.saveTodos(todoFile, todos);
625+
626+
// Assert
627+
const expected = `- Projects
628+
\t- [Work.md](/Projects/Work.md)
629+
\t\t- [ ] parent task
630+
\t\t - [.] child task
631+
`;
632+
633+
const actual = todoFile.content;
634+
expect(actual).toEqual(expected);
635+
});
636+
637+
test("saveTodos with useHierarchy shares common folder prefixes", async () => {
638+
// Arrange
639+
const folder = createMockFile("2022", "");
640+
const sub1 = createMockFile("Q1", "", folder);
641+
const sub2 = createMockFile("Q2", "", folder);
642+
const file1 = createMockFile("Jan.md", "", sub1);
643+
const file2 = createMockFile("Apr.md", "", sub2);
644+
645+
const todos = [
646+
{
647+
task: TaskType.NotStarted,
648+
text: "jan task",
649+
indentation: "",
650+
lineno: 0,
651+
file: file1,
652+
} as Todo,
653+
{
654+
task: TaskType.NotStarted,
655+
text: "apr task",
656+
indentation: "",
657+
lineno: 0,
658+
file: file2,
659+
} as Todo,
660+
];
661+
662+
const todoFile = createMockFile("TODO.md", "");
663+
const mockFileService = new MockFileService([todoFile]);
664+
665+
const settings = {
666+
...MOCK_SETTINGS,
667+
useHierarchy: true,
668+
};
669+
670+
// Act
671+
const todoService = new TodoService(mockFileService, settings);
672+
await todoService.saveTodos(todoFile, todos);
673+
674+
// Assert
675+
const expected = `- 2022
676+
\t- Q1
677+
\t\t- [Jan.md](/2022/Q1/Jan.md)
678+
\t\t\t- [ ] jan task
679+
\t- Q2
680+
\t\t- [Apr.md](/2022/Q2/Apr.md)
681+
\t\t\t- [ ] apr task
682+
`;
683+
684+
const actual = todoFile.content;
685+
expect(actual).toEqual(expected);
686+
});
687+
455688
test("Whole shebang formats TODO.md correctly with task items nested under normal lists", async () => {
456689
// Arrange
457690
const taskFile = createMockFile(

src/todoService.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -217,25 +217,83 @@ class TodoService {
217217
}
218218
});
219219

220-
todosByFile.forEach((todos, file) => {
221-
const urlEncodedFilePath = encodeURI(file.path);
220+
if (this.settings.useHierarchy) {
221+
data = this.formatHierarchy(todosByFile);
222+
} else {
223+
todosByFile.forEach((todos, file) => {
224+
const urlEncodedFilePath = encodeURI(file.path);
222225

223-
const heading = this.settings.useFullFilepath
224-
? `- [${file.path}](${urlEncodedFilePath})\n`
225-
: `- [${file.basename}](${urlEncodedFilePath})\n`;
226+
const heading = this.settings.useFullFilepath
227+
? `- [${file.path}](${urlEncodedFilePath})\n`
228+
: `- [${file.basename}](${urlEncodedFilePath})\n`;
226229

227-
data += heading;
230+
data += heading;
228231

229-
todos.forEach((todo) => {
230-
data += `\t${todo.indentation}- [${todo.task}] ${todo.text}\n`;
232+
todos.forEach((todo) => {
233+
data += `\t${todo.indentation}- [${todo.task}] ${todo.text}\n`;
234+
});
231235
});
232-
});
236+
}
233237

234238
this.fileService
235239
.updateFile(todoFile, data)
236240
.catch((err) => console.error(err));
237241
}
238242

243+
private formatHierarchy(todosByFile: Map<IFile, Todo[]>): string {
244+
interface FolderNode {
245+
name: string;
246+
children: Map<string, FolderNode>;
247+
files: { file: IFile; todos: Todo[] }[];
248+
}
249+
250+
const root: FolderNode = { name: "", children: new Map(), files: [] };
251+
252+
todosByFile.forEach((todos, file) => {
253+
const parts = file.path.replace(/^\//, "").split("/");
254+
parts.pop();
255+
let node = root;
256+
for (const part of parts) {
257+
if (!node.children.has(part)) {
258+
node.children.set(part, {
259+
name: part,
260+
children: new Map(),
261+
files: [],
262+
});
263+
}
264+
const child = node.children.get(part);
265+
if (child) {
266+
node = child;
267+
}
268+
}
269+
node.files.push({ file, todos });
270+
});
271+
272+
let data = "";
273+
const renderNode = (node: FolderNode, depth: number) => {
274+
const indent = "\t".repeat(depth);
275+
276+
const sortedChildren = [...node.children.values()].sort((a, b) =>
277+
a.name.localeCompare(b.name),
278+
);
279+
for (const child of sortedChildren) {
280+
data += `${indent}- ${child.name}\n`;
281+
renderNode(child, depth + 1);
282+
}
283+
284+
for (const { file, todos } of node.files) {
285+
const urlEncodedFilePath = encodeURI(file.path);
286+
data += `${indent}- [${file.basename}](${urlEncodedFilePath})\n`;
287+
for (const todo of todos) {
288+
data += `${indent}\t${todo.indentation}- [${todo.task}] ${todo.text}\n`;
289+
}
290+
}
291+
};
292+
293+
renderNode(root, 0);
294+
return data;
295+
}
296+
239297
getIncludedTaskTypes(): Set<TaskType> {
240298
const taskTypes = new Set<TaskType>();
241299
if (this.settings.includeNotStarted) taskTypes.add(TaskType.NotStarted);

0 commit comments

Comments
 (0)