Repository: obsidian-tasks-group/obsidian-tasks
Pull request #3008 (2024-08-05 10:16:16)

fix: collect root `ListItem`s
# Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
  - The feature was not released officially yet, so the bug was not seen

## Description

- add `root` and `isRoot` getters to `ListItem` class and test them
- modify test function `printRoots` to use the new methods
- fix bug in `getTasksFromFileContent2()`

## Motivation and Context

- prepare release of parent-child relationship feature

## How has this been tested?

- unit tests

## Screenshots (if appropriate)

## Checklist

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

- [x] My contribution follows this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/src/Obsidian/Cache.ts b/src/Obsidian/Cache.ts
index 7454dad5..82dd5daa 100644
--- a/src/Obsidian/Cache.ts
+++ b/src/Obsidian/Cache.ts
@@ -117,15 +117,6 @@ export function getTasksFromFileContent2(
                 tasks.push(task);
             }
         } else {
-            // 1st:
-            // Root ListItems should not be parents of anything.
-            // This behavior was introduced in DataView plugin, so we want keep it for consistency reasons.
-            // 2nd:
-            // Usually, root listItem has parent = -lineNumber, but for listItems on the top of the file (on 0 line), it is -1.
-            if (listItem.parent < 0) {
-                continue;
-            }
-
             const lineNumber = listItem.position.start.line;
 
             const parentListItem: ListItem | null = line2ListItem.get(listItem.parent) ?? null;
diff --git a/src/Task/ListItem.ts b/src/Task/ListItem.ts
index 1eb070ed..447c849d 100644
--- a/src/Task/ListItem.ts
+++ b/src/Task/ListItem.ts
@@ -16,4 +16,32 @@ export class ListItem {
             parent.children.push(this);
         }
     }
+
+    /**
+     * Return the top-level parent of this list item or task,
+     * which will not be indented.
+     *
+     * The root of an unintended item is itself.
+     *
+     * This is useful because the Tasks plugin currently only stores a flat list of {@link Task} objects,
+     * and does not provide direct access to all the parsed {@link ListItem} objects.
+     *
+     * @see isRoot
+     */
+    get root(): ListItem {
+        if (this.parent === null) {
+            return this;
+        }
+
+        return this.parent.root;
+    }
+
+    /**
+     * Returns whether this is a top-level (unindented) list item or task.
+     *
+     * @see root
+     */
+    get isRoot(): boolean {
+        return this.parent === null;
+    }
 }


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_2roots_listitem_listitem_task.md b/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_2roots_listitem_listitem_task.md
new file mode 100644
index 00000000..baf3b0f8
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_2roots_listitem_listitem_task.md
@@ -0,0 +1,7 @@
+- parent list item 1
+    - child list item 1
+        - [ ] grandchild task 1
+
+- parent list item 2
+    - child list item 2
+        - [ ] grandchild task 2
diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_listitem_listitem_task.md b/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_listitem_listitem_task.md
new file mode 100644
index 00000000..d1e3a8f9
--- /dev/null
+++ b/resources/sample_vaults/Tasks-Demo/Test Data/inheritance_listitem_listitem_task.md
@@ -0,0 +1,3 @@
+- parent list item
+    - child list item
+        - [ ] grandchild task
diff --git a/tests/Obsidian/Cache.test.ts b/tests/Obsidian/Cache.test.ts
index fbfa6e4a..2a63145f 100644
--- a/tests/Obsidian/Cache.test.ts
+++ b/tests/Obsidian/Cache.test.ts
@@ -13,7 +13,9 @@ import { inheritance_1parent2children1sibling } from './__test_data__/inheritanc
 import { inheritance_1parent2children2grandchildren } from './__test_data__/inheritance_1parent2children2grandchildren';
 import { inheritance_1parent2children2grandchildren1sibling } from './__test_data__/inheritance_1parent2children2grandchildren1sibling';
 import { inheritance_1parent2children2grandchildren1sibling_start_with_heading } from './__test_data__/inheritance_1parent2children2grandchildren1sibling_start_with_heading';
+import { inheritance_2roots_listitem_listitem_task } from './__test_data__/inheritance_2roots_listitem_listitem_task';
 import { inheritance_2siblings } from './__test_data__/inheritance_2siblings';
+import { inheritance_listitem_listitem_task } from './__test_data__/inheritance_listitem_listitem_task';
 import { inheritance_listitem_task } from './__test_data__/inheritance_listitem_task';
 import { inheritance_listitem_task_siblings } from './__test_data__/inheritance_listitem_task_siblings';
 import { inheritance_task_2listitem_3task } from './__test_data__/inheritance_task_2listitem_3task';
@@ -77,14 +79,17 @@ function printHierarchy(listItem: ListItem, depth: number): string {
  * @param listItems
  */
 function printRoots(listItems: ListItem[]) {
-    let rootHierarchies = '';
-
+    const roots: ListItem[] = [];
     for (const listItem of listItems) {
-        if (listItem.parent === null) {
-            rootHierarchies += printHierarchy(listItem, 0);
+        if (!roots.includes(listItem.root)) {
+            roots.push(listItem.root);
         }
     }
 
+    let rootHierarchies = '';
+    roots.forEach((root) => {
+        rootHierarchies += printHierarchy(root, 0);
+    });
     return rootHierarchies;
 }
 
@@ -317,7 +322,53 @@ describe('cache', () => {
         expect(tasks.length).toEqual(1);
 
         expect(printRoots(tasks)).toMatchInlineSnapshot(`
-            "- [ ] child task : Task
+            "- parent list item : ListItem
+                - [ ] child task : Task
+            "
+        `);
+    });
+
+    it('should read grandchild task under parent and child listItem', () => {
+        const tasks = readTasksFromSimulatedFile(inheritance_listitem_listitem_task);
+        expect(inheritance_listitem_listitem_task.fileContents).toMatchInlineSnapshot(`
+            "- parent list item
+                - child list item
+                    - [ ] grandchild task
+            "
+        `);
+
+        expect(tasks.length).toEqual(1);
+
+        expect(printRoots(tasks)).toMatchInlineSnapshot(`
+            "- parent list item : ListItem
+                - child list item : ListItem
+                    - [ ] grandchild task : Task
+            "
+        `);
+    });
+
+    it('should read 2 roots with grandchild task under parent and child listItem', () => {
+        const tasks = readTasksFromSimulatedFile(inheritance_2roots_listitem_listitem_task);
+        expect(inheritance_2roots_listitem_listitem_task.fileContents).toMatchInlineSnapshot(`
+            "- parent list item 1
+                - child list item 1
+                    - [ ] grandchild task 1
+
+            - parent list item 2
+                - child list item 2
+                    - [ ] grandchild task 2
+            "
+        `);
+
+        expect(tasks.length).toEqual(2);
+
+        expect(printRoots(tasks)).toMatchInlineSnapshot(`
+            "- parent list item 1 : ListItem
+                - child list item 1 : ListItem
+                    - [ ] grandchild task 1 : Task
+            - parent list item 2 : ListItem
+                - child list item 2 : ListItem
+                    - [ ] grandchild task 2 : Task
             "
         `);
     });
diff --git a/tests/Obsidian/__test_data__/inheritance_2roots_listitem_listitem_task.ts b/tests/Obsidian/__test_data__/inheritance_2roots_listitem_listitem_task.ts
new file mode 100644
index 00000000..29423b30
--- /dev/null
+++ b/tests/Obsidian/__test_data__/inheritance_2roots_listitem_listitem_task.ts
@@ -0,0 +1,124 @@
+export const inheritance_2roots_listitem_listitem_task = {
+    filePath: 'Test Data/inheritance_2roots_listitem_listitem_task.md',
+    fileContents:
+        '- parent list item 1\n' +
+        '    - child list item 1\n' +
+        '        - [ ] grandchild task 1\n' +
+        '\n' +
+        '- parent list item 2\n' +
+        '    - child list item 2\n' +
+        '        - [ ] grandchild task 2\n',
+    cachedMetadata: {
+        sections: [
+            {
+                type: 'list',
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 6,
+                        col: 31,
+                        offset: 154,
+                    },
+                },
+            },
+        ],
+        listItems: [
+            {
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 0,
+                        col: 20,
+                        offset: 20,
+                    },
+                },
+                parent: -1,
+            },
+            {
+                position: {
+                    start: {
+                        line: 1,
+                        col: 2,
+                        offset: 23,
+                    },
+                    end: {
+                        line: 1,
+                        col: 23,
+                        offset: 44,
+                    },
+                },
+                parent: 0,
+            },
+            {
+                position: {
+                    start: {
+                        line: 2,
+                        col: 6,
+                        offset: 51,
+                    },
+                    end: {
+                        line: 2,
+                        col: 31,
+                        offset: 76,
+                    },
+                },
+                parent: 1,
+                task: ' ',
+            },
+            {
+                position: {
+                    start: {
+                        line: 4,
+                        col: 0,
+                        offset: 78,
+                    },
+                    end: {
+                        line: 4,
+                        col: 20,
+                        offset: 98,
+                    },
+                },
+                parent: -1,
+            },
+            {
+                position: {
+                    start: {
+                        line: 5,
+                        col: 2,
+                        offset: 101,
+                    },
+                    end: {
+                        line: 5,
+                        col: 23,
+                        offset: 122,
+                    },
+                },
+                parent: 4,
+            },
+            {
+                position: {
+                    start: {
+                        line: 6,
+                        col: 6,
+                        offset: 129,
+                    },
+                    end: {
+                        line: 6,
+                        col: 31,
+                        offset: 154,
+                    },
+                },
+                parent: 5,
+                task: ' ',
+            },
+        ],
+    },
+};
diff --git a/tests/Obsidian/__test_data__/inheritance_listitem_listitem_task.ts b/tests/Obsidian/__test_data__/inheritance_listitem_listitem_task.ts
new file mode 100644
index 00000000..5b27d4d0
--- /dev/null
+++ b/tests/Obsidian/__test_data__/inheritance_listitem_listitem_task.ts
@@ -0,0 +1,71 @@
+export const inheritance_listitem_listitem_task = {
+    filePath: 'Test Data/inheritance_listitem_listitem_task.md',
+    fileContents: '- parent list item\n    - child list item\n        - [ ] grandchild task\n',
+    cachedMetadata: {
+        sections: [
+            {
+                type: 'list',
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 2,
+                        col: 29,
+                        offset: 70,
+                    },
+                },
+            },
+        ],
+        listItems: [
+            {
+                position: {
+                    start: {
+                        line: 0,
+                        col: 0,
+                        offset: 0,
+                    },
+                    end: {
+                        line: 0,
+                        col: 18,
+                        offset: 18,
+                    },
+                },
+                parent: -1,
+            },
+            {
+                position: {
+                    start: {
+                        line: 1,
+                        col: 2,
+                        offset: 21,
+                    },
+                    end: {
+                        line: 1,
+                        col: 21,
+                        offset: 40,
+                    },
+                },
+                parent: 0,
+            },
+            {
+                position: {
+                    start: {
+                        line: 2,
+                        col: 6,
+                        offset: 47,
+                    },
+                    end: {
+                        line: 2,
+                        col: 29,
+                        offset: 70,
+                    },
+                },
+                parent: 1,
+                task: ' ',
+            },
+        ],
+    },
+};
diff --git a/tests/Task/ListItem.test.ts b/tests/Task/ListItem.test.ts
index 574df3c5..0e016b23 100644
--- a/tests/Task/ListItem.test.ts
+++ b/tests/Task/ListItem.test.ts
@@ -61,4 +61,18 @@ describe('list item tests', () => {
         expect(parentTask!.children).toEqual([childListItem]);
         expect(childListItem.parent).toBe(parentTask);
     });
+
+    it('should identify root of the hierarchy', () => {
+        const grandParent = new ListItem('- grand parent', null);
+        const parent = new ListItem('- parent', grandParent);
+        const child = new ListItem('- child', parent);
+
+        expect(grandParent.root.originalMarkdown).toEqual('- grand parent');
+        expect(parent.root.originalMarkdown).toEqual('- grand parent');
+        expect(child.root.originalMarkdown).toEqual('- grand parent');
+
+        expect(grandParent.isRoot).toEqual(true);
+        expect(parent.isRoot).toEqual(false);
+        expect(child.isRoot).toEqual(false);
+    });
 });