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

Issue:
Edit modal not recognising scheduled date if task line didn't have the Global Filter (when it is set)
### Please check that this issue hasn't been reported before.

- [X] I searched previous [Bug Reports](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+bug%22+sort%3Acomments-desc) didn't find any similar reports.

### Expected Behavior

Task edit modal shall recognise all dates in the line string.

### Current behaviour

Global Filter set to `#task`, the following line is then not treated by Tasks:
![Снимок экрана 2023-06-13 в 13 06 37](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/93825870/0fbac066-28ed-490a-be28-a1e23155a955)

If I place the cursor on that line and run the `Tasks: Create or edit task` command, the edit task modal opens but the scheduled date is in the task description and not in the correct section.
![Снимок экрана 2023-06-13 в 13 06 50](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/93825870/585acf3f-58e7-4be1-9803-27accbf7b460)

After setting a date (2023-06-14 in my example) in the scheduled date field in the edit task modal the scheduled date appears twice:
![Снимок экрана 2023-06-13 в 13 07 15](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/93825870/84875b77-5d3b-4ed8-8db9-4f857ceb773b)

I believe the same thing is valid for all other date.

### Steps to reproduce

1. Set global filter to a word not present in line below.

2. Add the line
```
- [ ] without global filter but with scheduled date ⏳ 2023-06-13
```

3. Open edit task modal

### Which Operating Systems are you using?

- [ ] Android
- [ ] iPhone/iPad
- [ ] Linux
- [X] macOS
- [ ] Windows

### Obsidian Version

1.3.5

### Tasks Plugin Version

3.10.0

### Checks

- [ ] I have tried it with all other plugins disabled and the error still occurs

### Possible solution

_No response_

Pull request description:
fix: Make Edit modal use existing emojis if Global Filter missing
<!--- Provide a general summary of your changes in the Title above -->

# Description

Parse the task line for all the details including recurrence, scheduled, due & starts dates when opening the task edit modal.

## Motivation and Context

Fix #2037 

## How has this been tested?

Added unit tests + smoke test on my live vault on the following:

```
- [ ] #task action1
- [ ] action2 🛫 2023-06-27 ⏳ 2023-06-20 📅 2023-06-21
```

## Screenshots (if appropriate)
![Снимок экрана 2023-06-20 в 18 23 59](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/93825870/7785ea7e-5a51-4803-a98c-c18a4eda433f)

## 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)

## 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](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow 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/Commands/CreateOrEditTaskParser.ts b/src/Commands/CreateOrEditTaskParser.ts
index d97ed8a8..f4f93d41 100644
--- a/src/Commands/CreateOrEditTaskParser.ts
+++ b/src/Commands/CreateOrEditTaskParser.ts
@@ -19,23 +19,20 @@ import { getSettings } from '../Config/Settings';
  * @param path - The path of the file containing the line
  */
 export const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
-    const fallbackDate = DateFallback.fromPath(path);
-
-    const task = Task.fromLine({
+    // We get all signifiers from the line, even if the Global Filter is missing.
+    // This helps users who, for some reason, have data in a task line without the Global Filter.
+    const task = Task.parseTaskSignifiers(
         line,
-        taskLocation: TaskLocation.fromUnknownPosition(path), // We don't need precise location to toggle it here in the editor.
-        fallbackDate, // set the scheduled date from the filename, so it can be displayed in the dialog
-    });
+        TaskLocation.fromUnknownPosition(path), // We don't need precise location to toggle it here in the editor.
+        DateFallback.fromPath(path), // set the scheduled date from the filename, so it can be displayed in the dialog
+    );
 
     if (task !== null) {
         return task;
     }
 
     const { setCreatedDate } = getSettings();
-    let createdDate: moment.Moment | null = null;
-    if (setCreatedDate) {
-        createdDate = window.moment();
-    }
+    const createdDate = setCreatedDate ? window.moment() : null;
 
     // 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.
diff --git a/src/Task.ts b/src/Task.ts
index a5533d50..06160dde 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -56,6 +56,8 @@ export class TaskRegularExpressions {
     // - List marker
     // - Status character
     // - Rest of task after checkbox markdown
+    // See Task.extractTaskComponents() for abstraction around this regular expression.
+    // That is private for now, but could be made public in future if needed.
     public static readonly taskRegex = new RegExp(
         TaskRegularExpressions.indentationRegex.source +
             TaskRegularExpressions.listMarkerRegex.source +
@@ -95,6 +97,18 @@ export class TaskRegularExpressions {
     public static readonly hashTagsFromEnd = new RegExp(this.hashTags.source + '$');
 }
 
+/**
+ * Storage for the task line, broken down in to sections.
+ * See {@link Task.extractTaskComponents} for use.
+ */
+interface TaskComponents {
+    indentation: string;
+    listMarker: string;
+    status: Status;
+    body: string;
+    blockLink: string;
+}
+
 /**
  * Task encapsulates the properties of the MarkDown task along with
  * the extensions provided by this plugin. This is used to parse and
@@ -195,7 +209,8 @@ export class Task {
     }
 
     /**
-     * Takes the given line from an obsidian note and returns a Task object.
+     * Takes the given line from an Obsidian note and returns a Task object.
+     * Will check if Global Filter is present in the line.
      *
      * @static
      * @param {string} line - The full line in the note to parse.
@@ -203,6 +218,7 @@ export class Task {
      * @param {(Moment | null)} fallbackDate - The date to use as the scheduled date if no other date is set
      * @return {*}  {(Task | null)}
      * @memberof Task
+     * @see parseTaskSignifiers
      */
     public static fromLine({
         line,
@@ -213,39 +229,49 @@ export class Task {
         taskLocation: TaskLocation;
         fallbackDate: Moment | null;
     }): Task | null {
+        const taskComponents = Task.extractTaskComponents(line);
         // Check the line to see if it is a markdown task.
-        const regexMatch = line.match(TaskRegularExpressions.taskRegex);
-        if (regexMatch === null) {
+        if (taskComponents === null) {
             return null;
         }
 
-        // match[4] includes the whole body of the task after the brackets.
-        const body = regexMatch[4].trim();
-
-        // return if task does not have the global filter. Do this before processing
-        // rest of match to improve performance.
-        if (!GlobalFilter.includedIn(body)) {
+        // return if the line does not have the global filter. Do this before
+        // any other processing to improve performance.
+        if (!GlobalFilter.includedIn(taskComponents.body)) {
             return null;
         }
 
-        let description = body;
-        const indentation = regexMatch[1];
-        const listMarker = regexMatch[2];
-
-        // Get the status of the task.
-        const statusString = regexMatch[3];
-        const status = StatusRegistry.getInstance().bySymbolOrCreate(statusString);
-
-        // Match for block link and remove if found. Always expected to be
-        // at the end of the line.
-        const blockLinkMatch = description.match(TaskRegularExpressions.blockLinkRegex);
-        const blockLink = blockLinkMatch !== null ? blockLinkMatch[0] : '';
+        return Task.parseTaskSignifiers(line, taskLocation, fallbackDate);
+    }
 
-        if (blockLink !== '') {
-            description = description.replace(TaskRegularExpressions.blockLinkRegex, '').trim();
+    /**
+     * Parses the line in attempt to get the task details.
+     *
+     * This reads the task even if the Global Filter is missing.
+     * If a Global Filter check is needed, use {@link Task.fromLine}.
+     *
+     * Task is returned regardless if Global Filter is present or not.
+     * However, if it is, it will be removed from the tags.
+     *
+     * @param line - the full line to parse
+     * @param taskLocation - The location of the task line
+     * @param fallbackDate - The date to use as the scheduled date if no other date is set
+     * @returns {*} {(Task | null)}
+     * @see fromLine
+     */
+    public static parseTaskSignifiers(
+        line: string,
+        taskLocation: TaskLocation,
+        fallbackDate: Moment | null,
+    ): Task | null {
+        const taskComponents = Task.extractTaskComponents(line);
+        // Check the line to see if it is a markdown task.
+        if (taskComponents === null) {
+            return null;
         }
+
         const { taskSerializer } = getUserSelectedTaskFormat();
-        const taskInfo = taskSerializer.deserialize(description);
+        const taskInfo = taskSerializer.deserialize(taskComponents.body);
 
         let scheduledDateIsInferred = false;
         // Infer the scheduled date from the file name if not set explicitly
@@ -261,16 +287,42 @@ export class Task {
         taskInfo.tags = taskInfo.tags.filter((tag) => !GlobalFilter.equals(tag));
 
         return new Task({
+            ...taskComponents,
             ...taskInfo,
-            status,
-            indentation,
-            listMarker,
             taskLocation: taskLocation,
-            blockLink,
             originalMarkdown: line,
             scheduledDateIsInferred,
         });
     }
+
+    private static extractTaskComponents(line: string): TaskComponents | null {
+        // Check the line to see if it is a markdown task.
+        const regexMatch = line.match(TaskRegularExpressions.taskRegex);
+        if (regexMatch === null) {
+            return null;
+        }
+
+        const indentation = regexMatch[1];
+        const listMarker = regexMatch[2];
+
+        // Get the status of the task.
+        const statusString = regexMatch[3];
+        const status = StatusRegistry.getInstance().bySymbolOrCreate(statusString);
+
+        // match[4] includes the whole body of the task after the brackets.
+        let body = regexMatch[4].trim();
+
+        // Match for block link and remove if found. Always expected to be
+        // at the end of the line.
+        const blockLinkMatch = body.match(TaskRegularExpressions.blockLinkRegex);
+        const blockLink = blockLinkMatch !== null ? blockLinkMatch[0] : '';
+
+        if (blockLink !== '') {
+            body = body.replace(TaskRegularExpressions.blockLinkRegex, '').trim();
+        }
+        return { indentation, listMarker, status, body, blockLink };
+    }
+
     /**
      * Create an HTML rendered List Item element (LI) for the current task.
      * @note Output is based on the {@link DefaultTaskSerializer}'s format, with default (emoji) symbols


Test patch:
diff --git a/tests/Commands/CreateOrEditTaskParser.test.ts b/tests/Commands/CreateOrEditTaskParser.test.ts
index be35ca94..f9c3352c 100644
--- a/tests/Commands/CreateOrEditTaskParser.test.ts
+++ b/tests/Commands/CreateOrEditTaskParser.test.ts
@@ -1,6 +1,13 @@
+/**
+ * @jest-environment jsdom
+ */
+import moment from 'moment';
+import { Priority } from '../../src/Task';
 import { taskFromLine } from '../../src/Commands/CreateOrEditTaskParser';
 import { GlobalFilter } from '../../src/Config/GlobalFilter';
 
+window.moment = moment;
+
 describe('CreateOrEditTaskParser - testing edited task if line is saved unchanged', () => {
     afterEach(() => {
         GlobalFilter.reset();
@@ -63,3 +70,29 @@ describe('CreateOrEditTaskParser - testing edited task if line is saved unchange
         },
     );
 });
+
+describe('CreateOrEditTaskParser - task recognition', () => {
+    afterEach(() => {
+        GlobalFilter.reset();
+    });
+
+    it('should recognize task details without global filter', () => {
+        GlobalFilter.set('#task');
+        const taskLine =
+            '- [ ] without global filter but with all the info ⏬ 🔁 every 2 days ➕ 2022-03-10 🛫 2022-01-31 ⏳ 2023-06-13 📅 2024-12-10 ✅ 2023-06-22';
+        const path = 'a/b/c.md';
+
+        const task = taskFromLine({ line: taskLine, path });
+
+        expect(task.toFileLineString()).toStrictEqual(taskLine);
+        expect(task.path).toStrictEqual('a/b/c.md');
+
+        expect(task.priority).toStrictEqual(Priority.Lowest);
+        expect(task.recurrenceRule).toStrictEqual('every 2 days');
+        expect(task.createdDate).toEqualMoment(moment('2022-03-10'));
+        expect(task.startDate).toEqualMoment(moment('2022-01-31'));
+        expect(task.scheduledDate).toEqualMoment(moment('2023-06-13'));
+        expect(task.dueDate).toEqualMoment(moment('2024-12-10'));
+        expect(task.doneDate).toEqualMoment(moment('2023-06-22'));
+    });
+});
diff --git a/tests/EditTask.test.ts b/tests/EditTask.test.ts
index daedbeb3..ed32253a 100644
--- a/tests/EditTask.test.ts
+++ b/tests/EditTask.test.ts
@@ -64,18 +64,15 @@ describe('Task rendering', () => {
         );
     });
 
-    it.failing(
-        'should display task description with emoji removed, even if the global filter is missing from initial line (bug 2037)',
-        () => {
-            GlobalFilter.set('#todo');
-            // When written, this was a demonstration of the behaviour logged in
-            // https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2037
-            testDescriptionRender(
-                'without global filter but with scheduled date ⏳ 2023-06-13',
-                'without global filter but with scheduled date', // fails, as the absence of the global filter means the line is not parsed, and the emoji stays in the description.
-            );
-        },
-    );
+    it('should display task description with emoji removed, even if the global filter is missing from initial line (bug 2037)', () => {
+        GlobalFilter.set('#todo');
+        // When written, this was a demonstration of the behaviour logged in
+        // https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2037
+        testDescriptionRender(
+            'without global filter but with scheduled date ⏳ 2023-06-13',
+            'without global filter but with scheduled date', // fails, as the absence of the global filter means the line is not parsed, and the emoji stays in the description.
+        );
+    });
 });
 
 describe('Task editing', () => {
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index f76c1120..b554e80d 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -87,23 +87,6 @@ describe('parsing', () => {
         expect(task!.originalMarkdown).toStrictEqual(line);
     });
 
-    it('returns null when task does not have global filter', () => {
-        // Arrange
-        GlobalFilter.set('#task');
-        const line = '- [x] this is a done task 🗓 2021-09-12 ✅ 2021-06-20';
-
-        // Act
-        const task = fromLine({
-            line,
-        });
-
-        // Assert
-        expect(task).toBeNull();
-
-        // Cleanup
-        GlobalFilter.reset();
-    });
-
     it('supports capitalised status characters', () => {
         // See https://github.com/obsidian-tasks-group/obsidian-tasks/issues/520
         // "In combination with SlrVb's S-Checkbox CSS, Task Plugin breaks that style"
@@ -411,6 +394,57 @@ describe('parsing tags', () => {
     );
 });
 
+describe('task parsing VS global filter', () => {
+    afterEach(() => {
+        GlobalFilter.reset();
+    });
+
+    it('returns null when task does not have global filter', () => {
+        // Arrange
+        GlobalFilter.set('#task');
+        const line = '- [x] this is a done task 🗓 2021-09-12 ✅ 2021-06-20';
+
+        // Act
+        const task = fromLine({
+            line,
+        });
+
+        // Assert
+        expect(task).toBeNull();
+    });
+
+    it.each([
+        '#task - [ ] this is a task 🗓 2021-09-12',
+        '- #task [ ] this is a task 🗓 2021-09-12',
+        '- [#task] this is a task 🗓 2021-09-12',
+        '- [ #task ] this is a task 🗓 2021-09-12',
+    ])('should not parse task with global filter outside of the description: "%s"', (line: string) => {
+        // Arrange
+        GlobalFilter.set('#task');
+
+        // Act
+        const task = fromLine({
+            line,
+        });
+
+        // Assert
+        expect(task).toBeNull();
+    });
+
+    it('should not consider task status when searching for global filter', () => {
+        // Arrange
+        GlobalFilter.set('@');
+
+        // Act
+        const task = fromLine({
+            line: '- [@] Hello',
+        });
+
+        // Assert
+        expect(task).toBeNull();
+    });
+});
+
 describe('properties for scripting', () => {
     it('should provide isDone for convenience', () => {
         expect(new TaskBuilder().status(Status.makeTodo()).build().isDone).toEqual(false);