This commit is contained in:
Sammy Carbajal 2026-06-30 16:21:12 +08:00 committed by GitHub
commit e6cff67eb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 210 additions and 8 deletions

View file

@ -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)
@ -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))

View file

@ -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

View file

@ -3,3 +3,4 @@ packages:
onlyBuiltDependencies:
- esbuild
- "@codemirror/language"

View file

@ -830,6 +830,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", () => {

View file

@ -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"]);
});
});

View file

@ -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
@ -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);

View file

@ -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";
@ -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(

View file

@ -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;
@ -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);
}
});

View file

@ -120,6 +120,7 @@ export class MarkdownTaskParser {
const [level, headingText] = headingResult;
this.currentHeading = headingText;
this.currentHeadingLevel = level;
this.clearIndentStack();
i++;
continue;
}
@ -257,6 +258,8 @@ export class MarkdownTaskParser {
this.updateIndentStack(taskId, indentLevel, actualSpaces);
this.tasks.push(enhancedTask);
} else {
this.handleNonTaskLine(line);
}
i++;
@ -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(

View file

@ -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
@ -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
@ -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);
}