mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
Merge 11a0538042 into 16c778bc8d
This commit is contained in:
commit
e6cff67eb4
10 changed files with 210 additions and 8 deletions
10
CHANGELOG.md
10
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))
|
||||
* improve test reliability and fix flaky date tests ([d66a13a](https://github.com/Quorafind/Obsidian-Task-Progress-Bar/commit/d66a13a5f41a5ea74d22c7b9215087aef80b5b07))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ packages:
|
|||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- "@codemirror/language"
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
64
src/__tests__/treeViewUtils.test.ts
Normal file
64
src/__tests__/treeViewUtils.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue