Repository: obsidian-tasks-group/obsidian-tasks
Pull request #850 (2022-07-02 16:33:36)

feat: Allow task components (due, priority etc) and tags in almost any order
## Description

With the hope that auto-complete (https://github.com/obsidian-tasks-group/obsidian-tasks/pull/822) is going to become a widely used approach to entering tasks with Obsidian Tasks, it is also expected to cause confusion and major usability issues for some users as it currently is.
The reason is that today's users, who are mostly entering and editing tasks through the Tasks dialog, are probably mainly unaware of formatting rules that obsidian-tasks requires. Most notably:
1. Tags cannot be mixed with task components.
2. Due dates (or other reference dates) must come after recurrence rules.

The result is that an unsuspecting user who will use auto-complete to enter tasks, without carefully reading the plugin documentation, will likely enter malformed tasks that will cause Obsidian Tasks to **silently fail** -- e.g. task components will not be parsed, and (in the case of recurrence rules coming after due dates) completed tasks will not get correct due dates -- **with zero feedback that the user did something wrong**.

This PR comes to fix this by removing the two limitations above.
**It will still be required that the task components be after the actual task description** (which I think is not ideal but that's what it is right now), but at least within this limitation, any order of tags and components is good.

## Motivation and Context

Described above; users may find the current strict ordering requirement confusing and may enter tasks that will never be handled correctly by the plugin.

## How has this been tested?

Tested on desktop and Android.

## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] 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)
- [x] 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.

By creating a Pull Request you agree to our [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md). For further guidance on contributing please see [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)


esm7: Great comments here :sunglasses: I will be without a computer in the next few days, but expecting to take care of everything on Wednesday.
esm7: Huge change in plans (due to turning COVID-positive and everything getting canceled...), working on this today.
claremacrae: > Huge change in plans (due to turning COVID-positive and everything getting canceled...), working on this today.

Oh nooooooo.... Hope you manage to escape the worst of it and recover soon. My very best wishes... 
esm7: All fixed & pushed.

Patch:
diff --git a/src/Task.ts b/src/Task.ts
index 15c7c855..d5ff8ec5 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -109,6 +109,9 @@ export class Task {
     // description: '#dog #car http://www/ddd#ere #house'
     // matches: #dog, #car, #house
     public static readonly hashTags = /(^|\s)#[^ !@#$%^&*(),.?":{}|<>]*/g;
+    public static readonly hashTagsFromEnd = new RegExp(
+        this.hashTags.source + '$',
+    );
 
     private _urgency: number | null = null;
 
@@ -242,10 +245,16 @@ export class Task {
         let scheduledDate: Moment | null = null;
         let dueDate: Moment | null = null;
         let doneDate: Moment | null = null;
+        let recurrenceRule: string = '';
         let recurrence: Recurrence | null = null;
         let tags: any = [];
+        // Tags that are removed from the end while parsing, but we want to add them back for being part of the description.
+        // In the original task description they are possibly mixed with other components
+        // (e.g. #tag1 <due date> #tag2), they do not have to all trail all task components,
+        // but eventually we want to paste them back to the task description at the end
+        let trailingTags = '';
         // Add a "max runs" failsafe to never end in an endless loop:
-        const maxRuns = 7;
+        const maxRuns = 20;
         let runs = 0;
         do {
             matched = false;
@@ -310,22 +319,51 @@ export class Task {
 
             const recurrenceMatch = description.match(Task.recurrenceRegex);
             if (recurrenceMatch !== null) {
-                recurrence = Recurrence.fromText({
-                    recurrenceRuleText: recurrenceMatch[1].trim(),
-                    startDate,
-                    scheduledDate,
-                    dueDate,
-                });
-
+                // Save the recurrence rule, but *do not parse it yet*.
+                // Creating the Recurrence object requires a reference date (e.g. a due date),
+                // and it might appear in the next (earlier in the line) tokens to parse
+                recurrenceRule = recurrenceMatch[1].trim();
                 description = description
                     .replace(Task.recurrenceRegex, '')
                     .trim();
                 matched = true;
             }
 
+            // Match tags from the end to allow users to mix the various task components with
+            // tags. These tags will be added back to the description below
+            const tagsMatch = description.match(Task.hashTagsFromEnd);
+            if (tagsMatch != null) {
+                description = description
+                    .replace(Task.hashTagsFromEnd, '')
+                    .trim();
+                matched = true;
+                const tagName = tagsMatch[0].trim();
+                // Adding to the left because the matching is done right-to-left
+                trailingTags =
+                    trailingTags.length > 0
+                        ? [tagName, trailingTags].join(' ')
+                        : tagName;
+            }
+
             runs++;
         } while (matched && runs <= maxRuns);
 
+        // Now that we have all the task details, parse the recurrence rule if we found any
+        if (recurrenceRule.length > 0) {
+            recurrence = Recurrence.fromText({
+                recurrenceRuleText: recurrenceRule,
+                startDate,
+                scheduledDate,
+                dueDate,
+            });
+        }
+
+        // Add back any trailing tags to the description. We removed them so we can parse the rest of the
+        // components but now we want them back.
+        // The goal is for a task of them form 'Do something #tag1 (due) tomorrow #tag2 (start) today'
+        // to actually have the description 'Do something #tag1 #tag2'
+        if (trailingTags.length > 0) description += ' ' + trailingTags;
+
         // Tags are found in the string and pulled out but not removed,
         // so when returning the entire task it will match what the user
         // entered.


Test patch:
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index c98ff222..28b1d87b 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -2,8 +2,9 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
-import { Status, Task } from '../src/Task';
+import { Priority, Status, Task } from '../src/Task';
 import { getSettings, updateSettings } from '../src/config/Settings';
+import { fromLine } from './TestHelpers';
 
 jest.mock('obsidian');
 window.moment = moment;
@@ -129,6 +130,30 @@ describe('parsing', () => {
         ).toStrictEqual(true);
         expect(task!.blockLink).toEqual(' ^my-precious');
     });
+
+    it('supports tag anywhere in the description and separates them correctly from signifier emojis', () => {
+        // Arrange
+        const line =
+            '- [ ] this is a task due 📅 2021-09-12 #inside_tag ⏫ #some/tags_with_underscore';
+
+        // Act
+        const task = fromLine({
+            line,
+        });
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task!.description).toEqual(
+            'this is a task due #inside_tag #some/tags_with_underscore',
+        );
+        expect(task!.tags).toEqual([
+            '#inside_tag',
+            '#some/tags_with_underscore',
+        ]);
+        expect(task!.dueDate).not.toBeNull();
+        expect(task!.dueDate!.isSame(moment('2021-09-12', 'YYYY-MM-DD')));
+        expect(task!.priority == Priority.High);
+    });
 });
 
 type TagParsingExpectations = {
@@ -153,9 +178,10 @@ describe('parsing tags', () => {
     test.each<TagParsingExpectations>([
         {
             markdownTask:
-                '- [x] this is a done task #tagone 🗓 2021-09-12 ✅ 2021-06-20',
-            expectedDescription: 'this is a done task #tagone',
-            extractedTags: ['#tagone'],
+                '- [x] this is a done task #tagone 🗓 2021-09-12 #another-tag ✅ 2021-06-20 #and_another',
+            expectedDescription:
+                'this is a done task #tagone #another-tag #and_another',
+            extractedTags: ['#tagone', '#another-tag', '#and_another'],
             globalFilter: '',
         },
         {
@@ -214,10 +240,10 @@ describe('parsing tags', () => {
         },
         {
             markdownTask:
-                '- [ ] #someglobaltasktag this is a normal task #tagone #tagtwo 🗓 2021-09-12 ✅ 2021-06-20',
+                '- [ ] #someglobaltasktag this is a normal task #tagone #tagtwo 🗓 2021-09-12 #tagthree ✅ 2021-06-20 #tagfour',
             expectedDescription:
-                '#someglobaltasktag this is a normal task #tagone #tagtwo',
-            extractedTags: ['#tagone', '#tagtwo'],
+                '#someglobaltasktag this is a normal task #tagone #tagtwo #tagthree #tagfour',
+            extractedTags: ['#tagone', '#tagtwo', '#tagthree', '#tagfour'],
             globalFilter: '#someglobaltasktag',
         },
         {
@@ -230,10 +256,18 @@ describe('parsing tags', () => {
         },
         {
             markdownTask:
-                '- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15',
+                '- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15 #context/more_context',
             expectedDescription:
-                'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare',
-            extractedTags: ['#context/pc_clare'],
+                'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare #context/more_context',
+            extractedTags: ['#context/pc_clare', '#context/more_context'],
+            globalFilter: '',
+        },
+        {
+            markdownTask:
+                '- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare ⏳ 2022-05-15 🔁 every 4 weeks on Sunday #context/more_context',
+            expectedDescription:
+                'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare #context/more_context',
+            extractedTags: ['#context/pc_clare', '#context/more_context'],
             globalFilter: '',
         },
         {
@@ -296,7 +330,7 @@ describe('to string', () => {
     it('retains the tags', () => {
         // Arrange
         const line =
-            '- [x] this is a done task #tagone #journal/daily 📅 2021-09-12 ✅ 2021-06-20';
+            '- [x] this is a done task #tagone 📅 2021-09-12 ✅ 2021-06-20 #journal/daily';
 
         // Act
         const task: Task = Task.fromLine({
@@ -308,7 +342,9 @@ describe('to string', () => {
         }) as Task;
 
         // Assert
-        expect(task.toFileLineString()).toStrictEqual(line);
+        const expectedLine =
+            '- [x] this is a done task #tagone #journal/daily 📅 2021-09-12 ✅ 2021-06-20';
+        expect(task.toFileLineString()).toStrictEqual(expectedLine);
     });
 });
 
@@ -603,4 +639,74 @@ describe('toggle done', () => {
             todaySpy.mockClear();
         },
     );
+
+    it('supports recurrence rule after a due date', () => {
+        // Arrange
+        const line = '- [ ] this is a task 🗓 2021-09-12 🔁 every day';
+        const path = 'this/is a path/to a/file.md';
+        const sectionStart = 1337;
+        const sectionIndex = 1209;
+        const precedingHeader = 'Eloquent Section';
+
+        // Act
+        const task = Task.fromLine({
+            line,
+            path,
+            sectionStart,
+            sectionIndex,
+            precedingHeader,
+        });
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task!.dueDate).not.toBeNull();
+        expect(
+            task!.dueDate!.isSame(moment('2021-09-12', 'YYYY-MM-DD')),
+        ).toStrictEqual(true);
+
+        const nextTask: Task = task!.toggle()[0];
+        expect({
+            nextDue: nextTask.dueDate?.format('YYYY-MM-DD'),
+            nextScheduled: nextTask.scheduledDate?.format('YYYY-MM-DD'),
+            nextStart: nextTask.startDate?.format('YYYY-MM-DD'),
+        }).toMatchObject({
+            nextDue: '2021-09-13',
+            nextScheduled: undefined,
+            nextStart: undefined,
+        });
+    });
+
+    it('supports parsing large number of values', () => {
+        // Arrange
+        const line =
+            '- [ ] Wobble ⏫  #tag1 ✅ 2022-07-02 #tag2  📅 2022-07-02 #tag3 ⏳ 2022-07-02 #tag4 🛫 2022-07-02 #tag5  🔁 every day  #tag6 #tag7 #tag8 #tag9 #tag10';
+
+        // Act
+        const task = fromLine({
+            line,
+        });
+
+        // Assert
+        expect(task).not.toBeNull();
+        expect(task!.description).toEqual(
+            'Wobble #tag1 #tag2 #tag3 #tag4 #tag5 #tag6 #tag7 #tag8 #tag9 #tag10',
+        );
+        expect(task!.dueDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
+        expect(task!.doneDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
+        expect(task!.startDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
+        expect(task!.scheduledDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
+        expect(task!.priority == Priority.High);
+        expect(task!.tags).toStrictEqual([
+            '#tag1',
+            '#tag2',
+            '#tag3',
+            '#tag4',
+            '#tag5',
+            '#tag6',
+            '#tag7',
+            '#tag8',
+            '#tag9',
+            '#tag10',
+        ]);
+    });
 });