Skip to content

Commit 650f652

Browse files
authored
feat: Allow task components (due, priority etc) and tags in almost any order (#850)
* Allow loose order: tags, recurrence and other task components can now be in an arbitrary order * Code Review fixes
1 parent 6fd5bff commit 650f652

File tree

2 files changed

+164
-20
lines changed

2 files changed

+164
-20
lines changed

src/Task.ts

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ export class Task {
109109
// description: '#dog #car http://www/ddd#ere #house'
110110
// matches: #dog, #car, #house
111111
public static readonly hashTags = /(^|\s)#[^ !@#$%^&*(),.?":{}|<>]*/g;
112+
public static readonly hashTagsFromEnd = new RegExp(
113+
this.hashTags.source + '$',
114+
);
112115

113116
private _urgency: number | null = null;
114117

@@ -242,10 +245,16 @@ export class Task {
242245
let scheduledDate: Moment | null = null;
243246
let dueDate: Moment | null = null;
244247
let doneDate: Moment | null = null;
248+
let recurrenceRule: string = '';
245249
let recurrence: Recurrence | null = null;
246250
let tags: any = [];
251+
// Tags that are removed from the end while parsing, but we want to add them back for being part of the description.
252+
// In the original task description they are possibly mixed with other components
253+
// (e.g. #tag1 <due date> #tag2), they do not have to all trail all task components,
254+
// but eventually we want to paste them back to the task description at the end
255+
let trailingTags = '';
247256
// Add a "max runs" failsafe to never end in an endless loop:
248-
const maxRuns = 7;
257+
const maxRuns = 20;
249258
let runs = 0;
250259
do {
251260
matched = false;
@@ -310,22 +319,51 @@ export class Task {
310319

311320
const recurrenceMatch = description.match(Task.recurrenceRegex);
312321
if (recurrenceMatch !== null) {
313-
recurrence = Recurrence.fromText({
314-
recurrenceRuleText: recurrenceMatch[1].trim(),
315-
startDate,
316-
scheduledDate,
317-
dueDate,
318-
});
319-
322+
// Save the recurrence rule, but *do not parse it yet*.
323+
// Creating the Recurrence object requires a reference date (e.g. a due date),
324+
// and it might appear in the next (earlier in the line) tokens to parse
325+
recurrenceRule = recurrenceMatch[1].trim();
320326
description = description
321327
.replace(Task.recurrenceRegex, '')
322328
.trim();
323329
matched = true;
324330
}
325331

332+
// Match tags from the end to allow users to mix the various task components with
333+
// tags. These tags will be added back to the description below
334+
const tagsMatch = description.match(Task.hashTagsFromEnd);
335+
if (tagsMatch != null) {
336+
description = description
337+
.replace(Task.hashTagsFromEnd, '')
338+
.trim();
339+
matched = true;
340+
const tagName = tagsMatch[0].trim();
341+
// Adding to the left because the matching is done right-to-left
342+
trailingTags =
343+
trailingTags.length > 0
344+
? [tagName, trailingTags].join(' ')
345+
: tagName;
346+
}
347+
326348
runs++;
327349
} while (matched && runs <= maxRuns);
328350

351+
// Now that we have all the task details, parse the recurrence rule if we found any
352+
if (recurrenceRule.length > 0) {
353+
recurrence = Recurrence.fromText({
354+
recurrenceRuleText: recurrenceRule,
355+
startDate,
356+
scheduledDate,
357+
dueDate,
358+
});
359+
}
360+
361+
// Add back any trailing tags to the description. We removed them so we can parse the rest of the
362+
// components but now we want them back.
363+
// The goal is for a task of them form 'Do something #tag1 (due) tomorrow #tag2 (start) today'
364+
// to actually have the description 'Do something #tag1 #tag2'
365+
if (trailingTags.length > 0) description += ' ' + trailingTags;
366+
329367
// Tags are found in the string and pulled out but not removed,
330368
// so when returning the entire task it will match what the user
331369
// entered.

tests/Task.test.ts

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* @jest-environment jsdom
33
*/
44
import moment from 'moment';
5-
import { Status, Task } from '../src/Task';
5+
import { Priority, Status, Task } from '../src/Task';
66
import { getSettings, updateSettings } from '../src/config/Settings';
7+
import { fromLine } from './TestHelpers';
78

89
jest.mock('obsidian');
910
window.moment = moment;
@@ -129,6 +130,30 @@ describe('parsing', () => {
129130
).toStrictEqual(true);
130131
expect(task!.blockLink).toEqual(' ^my-precious');
131132
});
133+
134+
it('supports tag anywhere in the description and separates them correctly from signifier emojis', () => {
135+
// Arrange
136+
const line =
137+
'- [ ] this is a task due 📅 2021-09-12 #inside_tag ⏫ #some/tags_with_underscore';
138+
139+
// Act
140+
const task = fromLine({
141+
line,
142+
});
143+
144+
// Assert
145+
expect(task).not.toBeNull();
146+
expect(task!.description).toEqual(
147+
'this is a task due #inside_tag #some/tags_with_underscore',
148+
);
149+
expect(task!.tags).toEqual([
150+
'#inside_tag',
151+
'#some/tags_with_underscore',
152+
]);
153+
expect(task!.dueDate).not.toBeNull();
154+
expect(task!.dueDate!.isSame(moment('2021-09-12', 'YYYY-MM-DD')));
155+
expect(task!.priority == Priority.High);
156+
});
132157
});
133158

134159
type TagParsingExpectations = {
@@ -153,9 +178,10 @@ describe('parsing tags', () => {
153178
test.each<TagParsingExpectations>([
154179
{
155180
markdownTask:
156-
'- [x] this is a done task #tagone 🗓 2021-09-12 ✅ 2021-06-20',
157-
expectedDescription: 'this is a done task #tagone',
158-
extractedTags: ['#tagone'],
181+
'- [x] this is a done task #tagone 🗓 2021-09-12 #another-tag ✅ 2021-06-20 #and_another',
182+
expectedDescription:
183+
'this is a done task #tagone #another-tag #and_another',
184+
extractedTags: ['#tagone', '#another-tag', '#and_another'],
159185
globalFilter: '',
160186
},
161187
{
@@ -214,10 +240,10 @@ describe('parsing tags', () => {
214240
},
215241
{
216242
markdownTask:
217-
'- [ ] #someglobaltasktag this is a normal task #tagone #tagtwo 🗓 2021-09-12 ✅ 2021-06-20',
243+
'- [ ] #someglobaltasktag this is a normal task #tagone #tagtwo 🗓 2021-09-12 #tagthree ✅ 2021-06-20 #tagfour',
218244
expectedDescription:
219-
'#someglobaltasktag this is a normal task #tagone #tagtwo',
220-
extractedTags: ['#tagone', '#tagtwo'],
245+
'#someglobaltasktag this is a normal task #tagone #tagtwo #tagthree #tagfour',
246+
extractedTags: ['#tagone', '#tagtwo', '#tagthree', '#tagfour'],
221247
globalFilter: '#someglobaltasktag',
222248
},
223249
{
@@ -230,10 +256,18 @@ describe('parsing tags', () => {
230256
},
231257
{
232258
markdownTask:
233-
'- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15',
259+
'- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare 🔁 every 4 weeks on Sunday ⏳ 2022-05-15 #context/more_context',
234260
expectedDescription:
235-
'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare',
236-
extractedTags: ['#context/pc_clare'],
261+
'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare #context/more_context',
262+
extractedTags: ['#context/pc_clare', '#context/more_context'],
263+
globalFilter: '',
264+
},
265+
{
266+
markdownTask:
267+
'- [ ] Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare ⏳ 2022-05-15 🔁 every 4 weeks on Sunday #context/more_context',
268+
expectedDescription:
269+
'Export [Cloud Feedly feeds](http://cloud.feedly.com/#opml) #context/pc_clare #context/more_context',
270+
extractedTags: ['#context/pc_clare', '#context/more_context'],
237271
globalFilter: '',
238272
},
239273
{
@@ -296,7 +330,7 @@ describe('to string', () => {
296330
it('retains the tags', () => {
297331
// Arrange
298332
const line =
299-
'- [x] this is a done task #tagone #journal/daily 📅 2021-09-12 ✅ 2021-06-20';
333+
'- [x] this is a done task #tagone 📅 2021-09-12 ✅ 2021-06-20 #journal/daily';
300334

301335
// Act
302336
const task: Task = Task.fromLine({
@@ -308,7 +342,9 @@ describe('to string', () => {
308342
}) as Task;
309343

310344
// Assert
311-
expect(task.toFileLineString()).toStrictEqual(line);
345+
const expectedLine =
346+
'- [x] this is a done task #tagone #journal/daily 📅 2021-09-12 ✅ 2021-06-20';
347+
expect(task.toFileLineString()).toStrictEqual(expectedLine);
312348
});
313349
});
314350

@@ -603,4 +639,74 @@ describe('toggle done', () => {
603639
todaySpy.mockClear();
604640
},
605641
);
642+
643+
it('supports recurrence rule after a due date', () => {
644+
// Arrange
645+
const line = '- [ ] this is a task 🗓 2021-09-12 🔁 every day';
646+
const path = 'this/is a path/to a/file.md';
647+
const sectionStart = 1337;
648+
const sectionIndex = 1209;
649+
const precedingHeader = 'Eloquent Section';
650+
651+
// Act
652+
const task = Task.fromLine({
653+
line,
654+
path,
655+
sectionStart,
656+
sectionIndex,
657+
precedingHeader,
658+
});
659+
660+
// Assert
661+
expect(task).not.toBeNull();
662+
expect(task!.dueDate).not.toBeNull();
663+
expect(
664+
task!.dueDate!.isSame(moment('2021-09-12', 'YYYY-MM-DD')),
665+
).toStrictEqual(true);
666+
667+
const nextTask: Task = task!.toggle()[0];
668+
expect({
669+
nextDue: nextTask.dueDate?.format('YYYY-MM-DD'),
670+
nextScheduled: nextTask.scheduledDate?.format('YYYY-MM-DD'),
671+
nextStart: nextTask.startDate?.format('YYYY-MM-DD'),
672+
}).toMatchObject({
673+
nextDue: '2021-09-13',
674+
nextScheduled: undefined,
675+
nextStart: undefined,
676+
});
677+
});
678+
679+
it('supports parsing large number of values', () => {
680+
// Arrange
681+
const line =
682+
'- [ ] 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';
683+
684+
// Act
685+
const task = fromLine({
686+
line,
687+
});
688+
689+
// Assert
690+
expect(task).not.toBeNull();
691+
expect(task!.description).toEqual(
692+
'Wobble #tag1 #tag2 #tag3 #tag4 #tag5 #tag6 #tag7 #tag8 #tag9 #tag10',
693+
);
694+
expect(task!.dueDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
695+
expect(task!.doneDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
696+
expect(task!.startDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
697+
expect(task!.scheduledDate!.isSame(moment('022-07-02', 'YYYY-MM-DD')));
698+
expect(task!.priority == Priority.High);
699+
expect(task!.tags).toStrictEqual([
700+
'#tag1',
701+
'#tag2',
702+
'#tag3',
703+
'#tag4',
704+
'#tag5',
705+
'#tag6',
706+
'#tag7',
707+
'#tag8',
708+
'#tag9',
709+
'#tag10',
710+
]);
711+
});
606712
});

0 commit comments

Comments
 (0)