Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
Tasks should recognise tasks within Obsidian callouts
## Expected Behavior
Should recognise tasks within inbuilt Obsidian callouts (since these are recognised as tasks by Obsidian) and other markdown code such as lists work OK. 

## Current Behavior
Not recognised by Obsidian-tasks (or Dataview Plugin)

## Steps to Reproduce

    > [!QUOTE]
    > - [ ] #task xxxxx test in callout 📅 2022-02-07
    > [[A link]]

In above environment, the tasks function as tasks to all intents and purposes. Neither Dataview nor Tasks detect the task. Dataview does however detect other things within the block (such as the link above)

## Context (Environment)
* Obsidian version: 0.14.6
* Tasks version: 1.5.1

I don't see this as a major issue, but needs documenting

Pull request:
fix: find tasks in blockquotes and Obsidian callouts
<!--- Provide a general summary of your changes in the Title above -->

# Description
<!--- Describe your changes in detail -->
Now that I am familiar with the dataview codebase, I found their regex for parsing list items, which works with Obsidian callouts, and it turns out the fix is super easy to add to Tasks! Blockquote/callout indicators are now stored along with spaces and tabs in the `indentation` field of the Task, so they are preserved when the task is rendered out to string.

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes #656 including nested blockquotes/callouts

## How has this been tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->
Added unit tests for parsing the task, parsing tags out of the task, and toString. Something mentioned in the issue thread is testing specifically that the `indentation` is preserved when a new instance of a recurring task is generated - I could not figure out where in the tests to put that, if you still think it is needed, could you give me a pointer please to where in the tests it should go?

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] Refactor (non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## Checklist
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] My change has adequate Unit Test coverage. (mostly, see note above!)

By creating a Pull Request I agree to the [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).

Patch:
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 616e2c9f..259fce0d 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -78,6 +78,22 @@ The following _does not work:_
 
 ---
 
+Warning
+{: .label .label-yellow}
+Tasks can read tasks that are inside blockquotes or [Obsidian's built-in callouts](https://help.obsidian.md/How+to/Use+callouts).
+However, in the Live Preview editor mode (pencil icon in lower right corner), tasks written inside callouts cannot be completed by clicking the checkbox.
+You will see a warning; use the command `Tasks: Toggle Done`, or switch to Reading View (book icon in lower right corner) to click the checkbox.
+Completing a task by clicking its checkbox from a `tasks` query block _will_ work in any editor mode, even if the query is inside a callout.
+
+---
+
+Warning
+{: .label .label-yellow}
+
+Tasks cannot read tasks that are inside code blocks, such as the ones used by the Admonitions plugin. Use Obsidian's built-in callouts instead.
+
+---
+
 Warning
 {: .label .label-yellow}
 Tasks can only render inline footnotes. Regular footnotes are not supported.
diff --git a/src/Cache.ts b/src/Cache.ts
index 9a64886e..be05b6d6 100644
--- a/src/Cache.ts
+++ b/src/Cache.ts
@@ -1,6 +1,6 @@
 import { MetadataCache, TAbstractFile, TFile, Vault } from 'obsidian';
-import type { CachedMetadata, ListItemCache } from 'obsidian';
-import type { EventRef, SectionCache } from 'obsidian';
+import type { CachedMetadata, EventRef } from 'obsidian';
+import type { ListItemCache, SectionCache } from 'obsidian';
 import { Mutex } from 'async-mutex';
 
 import { Task } from './Task';
@@ -320,7 +320,6 @@ export class Cache {
 
         for (const section of sections) {
             if (
-                section.type === 'list' &&
                 section.position.start.line <= lineNumberTask &&
                 section.position.end.line >= lineNumberTask
             ) {
diff --git a/src/Commands/CreateOrEdit.ts b/src/Commands/CreateOrEdit.ts
index cfc970d0..bae133fc 100644
--- a/src/Commands/CreateOrEdit.ts
+++ b/src/Commands/CreateOrEdit.ts
@@ -58,7 +58,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
 
     // If we are not on a line of a task, we take what we have.
     // The non-task line can still be a checklist, for example if it is lacking the global filter.
-    const nonTaskRegex: RegExp = /^([\s\t]*)[-*]? *(\[(.)\])? *(.*)/u;
+    const nonTaskRegex: RegExp = /^([\s\t>]*)[-*]? *(\[(.)\])? *(.*)/u;
     const nonTaskMatch = line.match(nonTaskRegex);
     if (nonTaskMatch === null) {
         // Should never happen; everything in the regex is optional.
diff --git a/src/Commands/ToggleDone.ts b/src/Commands/ToggleDone.ts
index a595f69f..30545799 100644
--- a/src/Commands/ToggleDone.ts
+++ b/src/Commands/ToggleDone.ts
@@ -72,13 +72,13 @@ const toggleLine = ({ line, path }: { line: string; path: string }): string => {
             // 1. a list item
             // 2. a simple text line
 
-            const listItemRegex = /^([\s\t]*)([-*])/;
+            const listItemRegex = /^([\s\t>]*)([-*])/;
             if (listItemRegex.test(line)) {
                 // Let's convert the list item to a checklist item.
                 toggledLine = line.replace(listItemRegex, '$1$2 [ ]');
             } else {
                 // Let's convert the line to a list item.
-                toggledLine = line.replace(/^([\s\t]*)/, '$1- ');
+                toggledLine = line.replace(/^([\s\t>]*)/, '$1- ');
             }
         }
     }
diff --git a/src/Events.ts b/src/Events.ts
index ae80a0ef..bf87d834 100644
--- a/src/Events.ts
+++ b/src/Events.ts
@@ -16,8 +16,8 @@ interface CacheUpdateData {
 export class Events {
     private obsidianEvents: ObsidianEvents;
 
-    constructor({ obsidianEents }: { obsidianEents: ObsidianEvents }) {
-        this.obsidianEvents = obsidianEents;
+    constructor({ obsidianEvents }: { obsidianEvents: ObsidianEvents }) {
+        this.obsidianEvents = obsidianEvents;
     }
 
     public onCacheUpdate(
diff --git a/src/LivePreviewExtension.ts b/src/LivePreviewExtension.ts
index 7a0715fb..372f274b 100644
--- a/src/LivePreviewExtension.ts
+++ b/src/LivePreviewExtension.ts
@@ -1,5 +1,6 @@
 import { EditorView, ViewPlugin } from '@codemirror/view';
 import type { PluginValue } from '@codemirror/view';
+import { Notice } from 'obsidian';
 
 import { Task } from './Task';
 
@@ -33,8 +34,31 @@ class LivePreviewExtension implements PluginValue {
             return false;
         }
 
+        /* Right now Obsidian API does not give us a way to handle checkbox clicks inside rendered-widgets-in-LP such as
+         * callouts, tables, and transclusions because `this.view.posAtDOM` will return the beginning of the widget
+         * as the position for any click inside the widget.
+         * For callouts, this means that the task will never be found, since the `lineAt` will be the beginning of the callout.
+         * Therefore, produce an error message pop-up using Obsidian's "Notice" feature, log a console warning, then return.
+         */
+
+        // Tasks from "task" query codeblocks handle themselves thanks to `toLi`, so be specific about error messaging, but still return.
+        const ancestor = target.closest(
+            'ul.plugin-tasks-query-result, div.callout-content',
+        );
+        if (ancestor) {
+            if (ancestor.matches('div.callout-content')) {
+                // Error message for now.
+                const msg =
+                    'obsidian-tasks-plugin warning: Tasks cannot add or remove completion dates or make the next copy of a recurring task for tasks written inside a callout when you click their checkboxes in Live Preview. \n' +
+                    'If you wanted Tasks to do these things, please undo your change, then either click the line of the task and use the "Toggle Task Done" command, or switch to Reading View to click the checkbox.';
+                console.warn(msg);
+                new Notice(msg, 45000);
+            }
+            return false;
+        }
+
         const { state } = this.view;
-        const position = this.view.posAtDOM(target as Node);
+        const position = this.view.posAtDOM(target);
         const line = state.doc.lineAt(position);
         const task = Task.fromLine({
             line: line.text,
@@ -47,6 +71,10 @@ class LivePreviewExtension implements PluginValue {
             precedingHeader: null,
         });
 
+        console.debug(
+            `Live Preview Extension: toggle called. Position: ${position} Line: ${line.text}`,
+        );
+
         // Only handle checkboxes of tasks.
         if (task === null) {
             return false;
@@ -58,7 +86,7 @@ class LivePreviewExtension implements PluginValue {
         // Clicked on a task's checkbox. Toggle the task and set it.
         const toggled = task.toggle();
         const toggledString = toggled
-            .map((task) => task.toFileLineString())
+            .map((t) => t.toFileLineString())
             .join(state.lineBreak);
 
         // Creates a CodeMirror transaction in order to update the document.
diff --git a/src/Query/Filter/ExcludeSubItemsField.ts b/src/Query/Filter/ExcludeSubItemsField.ts
index 227a777a..b8c551c5 100644
--- a/src/Query/Filter/ExcludeSubItemsField.ts
+++ b/src/Query/Filter/ExcludeSubItemsField.ts
@@ -7,10 +7,15 @@ export class ExcludeSubItemsField extends FilterInstructionsBasedField {
     constructor() {
         super();
 
-        this._filters.add(
-            'exclude sub-items',
-            (task) => task.indentation === '',
-        );
+        this._filters.add('exclude sub-items', (task) => {
+            if (task.indentation === '') return true; // no indentation, not a subitem
+
+            const lastBlockquoteMark = task.indentation.lastIndexOf('>');
+            if (lastBlockquoteMark === -1) return false; // indentation present, not in a blockquote, subitem
+
+            // Up to one space allowed after last > in blockquote/callout, otherwise subitem
+            return /^ ?$/.test(task.indentation.slice(lastBlockquoteMark + 1));
+        });
     }
 
     protected fieldName(): string {
diff --git a/src/Task.ts b/src/Task.ts
index 436090d6..9cfee567 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -85,10 +85,10 @@ export class Task {
     public static readonly dateFormat = 'YYYY-MM-DD';
 
     // Main regex for parsing a line. It matches the following:
-    // - Indentation
+    // - Indentation (including > for potentially nested blockquotes or Obsidian callouts)
     // - Status character
     // - Rest of task after checkbox markdown
-    public static readonly taskRegex = /^([\s\t]*)[-*] +\[(.)\] *(.*)/u;
+    public static readonly taskRegex = /^([\s\t>]*)[-*] +\[(.)\] *(.*)/u;
 
     // Match on block link at end.
     public static readonly blockLinkRegex = / \^[a-zA-Z0-9-]+$/u;
diff --git a/src/main.ts b/src/main.ts
index 2df60804..e0fe1340 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -27,7 +27,7 @@ export default class TasksPlugin extends Plugin {
             vault: this.app.vault,
         });
 
-        const events = new Events({ obsidianEents: this.app.workspace });
+        const events = new Events({ obsidianEvents: this.app.workspace });
         this.cache = new Cache({
             metadataCache: this.app.metadataCache,
             vault: this.app.vault,


Test patch:
diff --git a/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md b/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md
index 078d3d71..f4584087 100644
--- a/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md
+++ b/resources/sample_vaults/Tasks-Demo/Manual Testing/Smoke Testing the Tasks Plugin.md
@@ -63,12 +63,17 @@ Work through all the tasks below, until zero tasks remain in this query:
 
 ### Recurring Tasks
 
-Confirm that when a recurring task is completed, a new task is created and all the date fields are incremented.
+Confirm that when a recurring task is completed, a new task is created, all the date fields are incremented, and the indentation is unchanged.
+
+> [!Todo]
+>
+> - [ ] #task Complete this recurring task in **Source view** using **Tasks: Toggle task done** command 🔁 every day 🛫 2022-02-17 ⏳ 2022-02-18 📅 2022-02-19
+>
+> > - [ ] #task Complete this recurring task in **Reading view**🔁 every day 🛫 2022-02-17 ⏳ 2022-02-18 📅 2022-02-19
 
-- [ ] #task Complete this recurring task in **Source view** using **Tasks: Toggle task done** command 🔁 every day 🛫 2022-02-17 ⏳ 2022-02-18 📅 2022-02-19
-- [ ] #task Complete this recurring task in **Reading view**🔁 every day 🛫 2022-02-17 ⏳ 2022-02-18 📅 2022-02-19
 - [ ] #task Complete this recurring task in **Live Preview**🔁 every day 🛫 2022-02-17 ⏳ 2022-02-18 📅 2022-02-19
-- [ ] #task **check**: Checked all above steps for **recurring tasks** worked
+
+> - [ ] #task **check**: Checked all above steps for **recurring tasks** worked
 
 ### Rendering of Task Blocks
 
diff --git a/tests/Query/Filter/ExcludeSubItemsField.test.ts b/tests/Query/Filter/ExcludeSubItemsField.test.ts
index 3029f767..c12df033 100644
--- a/tests/Query/Filter/ExcludeSubItemsField.test.ts
+++ b/tests/Query/Filter/ExcludeSubItemsField.test.ts
@@ -14,4 +14,20 @@ describe('sub-items', () => {
         testTaskFilter(filter, fromLine({ line: '  - [ ] Subtask1' }), false);
         testTaskFilter(filter, fromLine({ line: '    - [ ] Subtask2' }), false);
     });
+    it('subitem has more than one space after last > of blockquotes or callouts', () => {
+        // Arrange
+        const filter = new ExcludeSubItemsField().createFilterOrErrorMessage(
+            'exclude sub-items',
+        );
+
+        // Assert
+        testTaskFilter(filter, fromLine({ line: '> - [ ] Task' }), true);
+        testTaskFilter(filter, fromLine({ line: '> > - [ ] Task' }), true);
+        testTaskFilter(filter, fromLine({ line: '>>  - [ ] Subtask1' }), false);
+        testTaskFilter(
+            filter,
+            fromLine({ line: '> >  - [ ] Subtask2' }),
+            false,
+        );
+    });
 });
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index dcaa78a8..9dad92ed 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -194,6 +194,35 @@ describe('parsing', () => {
             });
         }
     });
+
+    it('supports parsing of tasks inside blockquotes or callouts', () => {
+        // Arrange
+        const lines = [
+            '> - [ ] Task inside a blockquote or callout 📅2022-07-29',
+            '>>> - [ ] Task inside a blockquote or callout 📅2022-07-29',
+            '> > > * [ ] Task inside a blockquote or callout 📅2022-07-29',
+        ];
+
+        // Act
+        for (const line of lines) {
+            const task = fromLine({
+                line,
+            });
+
+            // Assert
+            expect({
+                _input: line, // Line is included, so it is shown in any failure output
+                description: task.description,
+                due: task.dueDate?.format('YYYY-MM-DD'),
+                indentation: task.indentation,
+            }).toMatchObject({
+                _input: line,
+                description: 'Task inside a blockquote or callout',
+                due: '2022-07-29',
+                indentation: line.split(/[-*]/)[0],
+            });
+        }
+    });
 });
 
 type TagParsingExpectations = {
@@ -295,7 +324,7 @@ describe('parsing tags', () => {
         },
         {
             markdownTask:
-                '- [ ] Export [Cloud Feedly feeds](https://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15 #context/more_context',
+                '* [ ] Export [Cloud Feedly feeds](https://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15 #context/more_context',
             expectedDescription:
                 'Export [Cloud Feedly feeds](https://cloud.feedly.com/#opml) #context/pc_clare #context/more_context',
             extractedTags: ['#context/pc_clare', '#context/more_context'],
@@ -317,6 +346,20 @@ describe('parsing tags', () => {
             extractedTags: ['#context/pc_clare'],
             globalFilter: '',
         },
+        {
+            markdownTask: '> - [ ] Task inside a blockquote or callout #tagone',
+            expectedDescription: 'Task inside a blockquote or callout #tagone',
+            extractedTags: ['#tagone'],
+            globalFilter: '',
+        },
+        {
+            markdownTask:
+                '>>> * [ ] Task inside a nested blockquote or callout #tagone',
+            expectedDescription:
+                'Task inside a nested blockquote or callout #tagone',
+            extractedTags: ['#tagone'],
+            globalFilter: '',
+        },
     ])(
         'should parse "$markdownTask" and extract "$extractedTags"',
         ({
@@ -348,6 +391,18 @@ describe('parsing tags', () => {
 });
 
 describe('to string', () => {
+    it('retains the indentation', () => {
+        const line = '> > > - [ ] Task inside a nested blockquote or callout';
+
+        // Act
+        const task: Task = fromLine({
+            line,
+        }) as Task;
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task.toFileLineString()).toStrictEqual(line);
+    });
     it('retains the block link', () => {
         // Arrange
         const line = '- [ ] this is a task 📅 2021-09-12 ^my-precious';