Skip to content
Open
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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## Unreleased

### Bug Fixes

* **tree view:** prevent invalid task nesting across headings, bullets, and intervening text
- Reset parser hierarchy when non-task structural lines break a task block
- Ignore stale or invalid parent links in tree rendering when parent and child cross heading boundaries


## [9.13.1](https://github.com/Quorafind/Obsidian-Task-Genius/compare/9.13.0...9.13.1) (2025-12-11)

Expand Down Expand Up @@ -842,4 +850,4 @@ All notable changes to this project will be documented in this file.

### Tests

* improve test reliability and fix flaky date tests ([d66a13a](https://github.com/Quorafind/Obsidian-Task-Progress-Bar/commit/d66a13a5f41a5ea74d22c7b9215087aef80b5b07))
* improve test reliability and fix flaky date tests ([d66a13a](https://github.com/Quorafind/Obsidian-Task-Progress-Bar/commit/d66a13a5f41a5ea74d22c7b9215087aef80b5b07))
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Task Genius plugin transforms Obsidian into a powerful task management system wi

For detailed feature documentation, visit [taskgenius.md](https://taskgenius.md).

Task hierarchy in tree-style views follows real markdown structure. Tasks are no longer treated as children across intervening bullets, text, or heading boundaries just because a later line is more indented.

---

## Installation
Expand Down
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ packages:

onlyBuiltDependencies:
- esbuild
- "@codemirror/language"
55 changes: 55 additions & 0 deletions src/__tests__/taskParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,61 @@ This project involves software development tasks.
expect(tasks[3].content).toBe("Child task 2");
expect(tasks[3].metadata.parent).toBe(tasks[0].id);
});

test("should not treat tasks after an intermediate bullet as children", () => {
const content = `- [ ] Parent task
- Plain bullet
- [ ] Nested task`;
const tasks = parser.parseLegacy(content, "test.md");

expect(tasks).toHaveLength(2);
expect(tasks[0].content).toBe("Parent task");
expect(tasks[0].metadata.children).toHaveLength(0);
expect(tasks[1].content).toBe("Nested task");
expect(tasks[1].metadata.parent).toBeUndefined();
});

test("should not treat tasks after intervening text as children", () => {
const content = `- [ ] Parent task
Notes about the parent
- [ ] Nested task`;
const tasks = parser.parseLegacy(content, "test.md");

expect(tasks).toHaveLength(2);
expect(tasks[0].content).toBe("Parent task");
expect(tasks[0].metadata.children).toHaveLength(0);
expect(tasks[1].content).toBe("Nested task");
expect(tasks[1].metadata.parent).toBeUndefined();
});

test("should not link tasks across headings and plain bullets", () => {
const content = `# Tasks


- [ ] This is a task
- [ ] This other task

- [ ] This a new task

- [ ] Tirst other
### New section
- Bullet

- [ ] This task should be not chilren of "This other task"`;
const tasks = parser.parseLegacy(content, "test.md");

expect(tasks).toHaveLength(5);
expect(tasks[0].content).toBe("This is a task");
expect(tasks[1].content).toBe("This other task");
expect(tasks[2].content).toBe("This a new task");
expect(tasks[3].content).toBe("Tirst other");
expect(tasks[4].content).toBe(
'This task should be not chilren of "This other task"',
);
expect(tasks[4].metadata.parent).toBeUndefined();
expect(tasks[1].metadata.children).toHaveLength(0);
expect(tasks[3].metadata.children).toHaveLength(0);
});
});

describe("Edge Cases", () => {
Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/treeViewUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Task } from "../types/task";
import { isValidTreeParent, tasksToTree } from "../utils/ui/tree-view-utils";

function createTask(
id: string,
content: string,
line: number,
heading: string[] = [],
parent?: string,
filePath = "test.md",
): Task {
return {
id,
content,
filePath,
line,
completed: false,
status: " ",
originalMarkdown: `- [ ] ${content}`,
metadata: {
tags: [],
children: [],
heading,
parent,
},
};
}

describe("tree view hierarchy guards", () => {
it("rejects parent links that cross headings", () => {
const parent = createTask("parent", "Parent", 4, ["Tasks"]);
const child = createTask(
"child",
"Child",
10,
["New section"],
parent.id,
);

expect(isValidTreeParent(child, parent)).toBe(false);
});

it("keeps valid parent links in the same heading", () => {
const parent = createTask("parent", "Parent", 4, ["Tasks"]);
const child = createTask("child", "Child", 5, ["Tasks"], parent.id);

expect(isValidTreeParent(child, parent)).toBe(true);
});

it("treats invalid parent links as roots when building a tree", () => {
const parent = createTask("parent", "This other task", 4, ["Tasks"]);
const independent = createTask(
"independent",
'This task should be not chilren of "This other task"',
10,
["New section"],
parent.id,
);

const roots = tasksToTree([parent, independent]);

expect(roots.map((task) => task.id)).toEqual(["parent", "independent"]);
});
});
7 changes: 6 additions & 1 deletion src/components/features/table/TreeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TreeNode, TableRow, TableCell, TableColumn } from "./TableTypes";
import { SortCriterion } from "@/common/setting-definition";
import { sortTasks } from "@/commands/sortTaskCommands";
import { t } from "@/translations/helper";
import { isValidTreeParent } from "@/utils/ui/tree-view-utils";

/**
* Tree manager component responsible for handling hierarchical task display
Expand Down Expand Up @@ -91,10 +92,14 @@ export class TreeManager extends Component {
tasks.forEach((task) => {
const node = this.treeNodes.get(task.id);
if (!node) return;
const parentTask = task.metadata.parent
? taskMap.get(task.metadata.parent)
: undefined;

if (
task.metadata.parent &&
this.treeNodes.has(task.metadata.parent)
this.treeNodes.has(task.metadata.parent) &&
isValidTreeParent(task, parentTask)
) {
// This task has a parent
const parentNode = this.treeNodes.get(task.metadata.parent);
Expand Down
9 changes: 7 additions & 2 deletions src/components/features/task/view/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
import { Task } from "@/types/task";
import { TaskListItemComponent } from "./listItem"; // Re-import needed components
import { ViewMode, getViewSettingOrDefault } from "@/common/setting-definition"; // 导入 SortCriterion
import { tasksToTree } from "@/utils/ui/tree-view-utils"; // Re-import needed utils
import {
tasksToTree,
isValidTreeParent,
} from "@/utils/ui/tree-view-utils"; // Re-import needed utils
import { TaskTreeItemComponent } from "./treeItem"; // Re-import needed components
import { t } from "@/translations/helper";
import TaskProgressBarPlugin from "@/index";
Expand Down Expand Up @@ -1218,7 +1221,9 @@ export class ContentComponent extends Component {
for (let i = start; i < end; i++) {
const rootTask = this.rootTasks[i];
const childTasks = this.notFilteredTasks.filter(
(task) => task.metadata.parent === rootTask.id,
(task) =>
task.metadata.parent === rootTask.id &&
isValidTreeParent(task, rootTask),
);

const treeComponent = new TaskTreeItemComponent(
Expand Down
6 changes: 5 additions & 1 deletion src/components/features/task/view/treeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TaskSelectionManager } from "@/components/features/task/selection/TaskS
import { showBulkOperationsMenu } from "./BulkOperationsMenu";
import { TaskStatusIndicator } from "./TaskStatusIndicator";
import { TaskTimerManager } from "@/managers/timer-manager";
import { isValidTreeParent } from "@/utils/ui/tree-view-utils";

export class TaskTreeItemComponent extends Component {
public element: HTMLElement;
Expand Down Expand Up @@ -1398,7 +1399,10 @@ export class TaskTreeItemComponent extends Component {
// Find *grandchildren* by looking up children of the current childTask in the *full* taskMap
const grandchildren: Task[] = [];
this.taskMap.forEach((potentialGrandchild) => {
if (potentialGrandchild.metadata.parent === childTask.id) {
if (
potentialGrandchild.metadata.parent === childTask.id &&
isValidTreeParent(potentialGrandchild, childTask)
) {
grandchildren.push(potentialGrandchild);
}
});
Expand Down
15 changes: 15 additions & 0 deletions src/dataflow/core/ConfigurableTaskParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class MarkdownTaskParser {
const [level, headingText] = headingResult;
this.currentHeading = headingText;
this.currentHeadingLevel = level;
this.clearIndentStack();
i++;
continue;
}
Expand Down Expand Up @@ -251,6 +252,8 @@ export class MarkdownTaskParser {

this.updateIndentStack(taskId, indentLevel, actualSpaces);
this.tasks.push(enhancedTask);
} else {
this.handleNonTaskLine(line);
}

i++;
Expand Down Expand Up @@ -1313,6 +1316,18 @@ export class MarkdownTaskParser {
this.indentStack.push({ taskId, indentLevel, actualSpaces });
}

private handleNonTaskLine(line: string): void {
if (line.trim().length === 0) {
return;
}

this.clearIndentStack();
}

private clearIndentStack(): void {
this.indentStack = [];
}

private getStatusFromMapping(rawStatus: string): string | undefined {
// Find status name corresponding to raw character
for (const [statusName, mappedChar] of Object.entries(
Expand Down
49 changes: 46 additions & 3 deletions src/utils/ui/tree-view-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import { Task } from "../../types/task";

function normalizeHeadingPath(task: Task): string[] {
const heading = task.metadata.heading;
if (Array.isArray(heading)) {
return heading.map((item) => String(item).trim().toLowerCase());
}
return [];
}

export function isValidTreeParent(child: Task, parent?: Task): boolean {
if (!parent) {
return false;
}

if (child.filePath !== parent.filePath) {
return false;
}

if ((child.line ?? 0) <= (parent.line ?? -1)) {
return false;
}

const childHeadingPath = normalizeHeadingPath(child);
const parentHeadingPath = normalizeHeadingPath(parent);
if (
childHeadingPath.length > 0 &&
parentHeadingPath.length > 0 &&
childHeadingPath.join("\n") !== parentHeadingPath.join("\n")
) {
return false;
}

return true;
}

/**
* Convert a flat list of tasks to a hierarchical tree structure
* @param tasks Flat list of tasks
Expand All @@ -9,7 +43,13 @@ export function tasksToTree(tasks: Task[]): Task[] {
// Create a map for quick task lookup
const taskMap = new Map<string, Task>();
tasks.forEach((task) => {
taskMap.set(task.id, { ...task });
taskMap.set(task.id, {
...task,
metadata: {
...task.metadata,
children: [...(task.metadata.children || [])],
},
});
});

// Find root tasks and build hierarchy
Expand All @@ -18,10 +58,13 @@ export function tasksToTree(tasks: Task[]): Task[] {
// First pass: connect children to parents
tasks.forEach((task) => {
const taskWithChildren = taskMap.get(task.id)!;
const parentTask = task.metadata.parent
? taskMap.get(task.metadata.parent)
: undefined;

if (task.metadata.parent && taskMap.has(task.metadata.parent)) {
if (task.metadata.parent && isValidTreeParent(task, parentTask)) {
// This task has a parent, add it to parent's children
const parent = taskMap.get(task.metadata.parent)!;
const parent = parentTask!;
if (!parent.metadata.children.includes(task.id)) {
parent.metadata.children.push(task.id);
}
Expand Down
Loading