Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,11 +16,12 @@ interface ExtendedTaskListsSettings {

const DEFAULT_SETTINGS: ExtendedTaskListsSettings = {
todoFilename: "TODO.md",
useFullFilepath: false,
useHierarchy: false,
excludeFilePattern: "<!-- exclude TODO -->",
excludeFolderFilename: ".exclude_todos",
excludeRegionBegin: "%% exclude: start %%",
excludeRegionEnd: "%% exclude: end %%",
useFullFilepath: false,
includeNotStarted: true,
includeInProgress: true,
includeWontDo: false,
Expand Down Expand Up @@ -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)
Expand Down
233 changes: 233 additions & 0 deletions src/todoService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const MOCK_SETTINGS = {
excludeRegionBegin: "%% exclude: start %%",
excludeRegionEnd: "%% exclude: end %%",
useFullFilepath: false,
useHierarchy: false,
includeNotStarted: true,
includeInProgress: true,
includeWontDo: false,
Expand Down Expand Up @@ -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(
Expand Down
76 changes: 67 additions & 9 deletions src/todoService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IFile, Todo[]>): string {
interface FolderNode {
name: string;
children: Map<string, FolderNode>;
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<TaskType> {
const taskTypes = new Set<TaskType>();
if (this.settings.includeNotStarted) taskTypes.add(TaskType.NotStarted);
Expand Down