diff --git a/CHANGELOG.md b/CHANGELOG.md index 54eecd61..dce85785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) \ No newline at end of file +* improve test reliability and fix flaky date tests ([d66a13a](https://github.com/Quorafind/Obsidian-Task-Progress-Bar/commit/d66a13a5f41a5ea74d22c7b9215087aef80b5b07)) diff --git a/README.md b/README.md index f129b6c3..15ce39fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 082815b9..f92389d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: onlyBuiltDependencies: - esbuild + - "@codemirror/language" diff --git a/src/__tests__/taskParser.test.ts b/src/__tests__/taskParser.test.ts index 55c2eaec..b5828bc2 100644 --- a/src/__tests__/taskParser.test.ts +++ b/src/__tests__/taskParser.test.ts @@ -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", () => { diff --git a/src/__tests__/treeViewUtils.test.ts b/src/__tests__/treeViewUtils.test.ts new file mode 100644 index 00000000..d13405b5 --- /dev/null +++ b/src/__tests__/treeViewUtils.test.ts @@ -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"]); + }); +}); diff --git a/src/components/features/table/TreeManager.ts b/src/components/features/table/TreeManager.ts index 5313c0af..561ad734 100644 --- a/src/components/features/table/TreeManager.ts +++ b/src/components/features/table/TreeManager.ts @@ -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); diff --git a/src/components/features/task/view/content.ts b/src/components/features/task/view/content.ts index ac24b9e2..4dfa7317 100644 --- a/src/components/features/task/view/content.ts +++ b/src/components/features/task/view/content.ts @@ -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( diff --git a/src/components/features/task/view/treeItem.ts b/src/components/features/task/view/treeItem.ts index 42d37171..eeb69c94 100644 --- a/src/components/features/task/view/treeItem.ts +++ b/src/components/features/task/view/treeItem.ts @@ -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); } }); diff --git a/src/dataflow/core/ConfigurableTaskParser.ts b/src/dataflow/core/ConfigurableTaskParser.ts index c897d871..8663dd4c 100644 --- a/src/dataflow/core/ConfigurableTaskParser.ts +++ b/src/dataflow/core/ConfigurableTaskParser.ts @@ -119,6 +119,7 @@ export class MarkdownTaskParser { const [level, headingText] = headingResult; this.currentHeading = headingText; this.currentHeadingLevel = level; + this.clearIndentStack(); i++; continue; } @@ -251,6 +252,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( diff --git a/src/utils/ui/tree-view-utils.ts b/src/utils/ui/tree-view-utils.ts index 682db474..9ea52d7c 100644 --- a/src/utils/ui/tree-view-utils.ts +++ b/src/utils/ui/tree-view-utils.ts @@ -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(); 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); }