Skip to content

Commit 064b7ac

Browse files
authored
Merge pull request #2614 from obsidian-tasks-group/add-id-search
feat: Add search, sort and group by Task Id
2 parents 13cf047 + 4177f15 commit 064b7ac

File tree

9 files changed

+248
-2
lines changed

9 files changed

+248
-2
lines changed

docs/Getting Started/Task Dependencies.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ Query
7676

7777
## Adding Dependencies
7878

79-
## Searching For Dependencies
79+
## Using Dependencies in Tasks Searches
80+
81+
- [[Filters#Filters for Task Dependencies]]
82+
- [[Sorting#Sort by Task Dependencies]]
83+
- [[Grouping#Group by Task Dependencies]]
8084

8185
`is not blocked`
8286

docs/Queries/Filters.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This page is long. Here are some links to the main sections:
1717
- [[#Text filters]]
1818
- [[#Matching multiple filters]]
1919
- [[#Filters for Task Statuses]]
20+
- [[#Filters for Task Dependencies]]
2021
- [[#Filters for Dates in Tasks]]
2122
- [[#Filters for Other Task Properties]]
2223
- [[#Filters for File Properties]]
@@ -450,6 +451,26 @@ Find any tasks that have status symbols you have not yet added to your Tasks set
450451
group by path
451452
```
452453

454+
## Filters for Task Dependencies
455+
456+
### Id
457+
458+
- `has id`
459+
- `no id`
460+
- `id (includes|does not include) <string>`
461+
- Matches case-insensitive (disregards capitalization).
462+
- `id (regex matches|regex does not match) /<JavaScript-style Regex>/`
463+
- Does regular expression match (case-sensitive by default).
464+
- Essential reading: [[Regular Expressions|Regular Expression Searches]].
465+
466+
For more information, see [[Task Dependencies]].
467+
468+
> [!released]
469+
>
470+
> - Task Id was introduced in Tasks X.Y.Z.
471+
472+
Since Tasks X.Y.Z, **[[Custom Filters|custom filtering]] by Id** is now possible, using `task.id`.
473+
453474
## Filters for Dates in Tasks
454475

455476
### Due Date

docs/Queries/Grouping.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This page is long. Here are some links to the main sections:
1515
- [[#Basics]]
1616
- [[#Custom Groups]]
1717
- [[#Group by Task Statuses]]
18+
- [[#Group by Task Dependencies]]
1819
- [[#Group by Dates in Tasks]]
1920
- [[#Group by Other Task Properties]]
2021
- [[#Group by File Properties]]
@@ -162,6 +163,20 @@ group by function "Next status symbol: " + task.status.nextSymbol.replace(" ", "
162163

163164
<!-- placeholder to force blank line after included text --><!-- endInclude -->
164165

166+
## Group by Task Dependencies
167+
168+
### Id
169+
170+
- `group by id`
171+
172+
For more information, see [[Task Dependencies]].
173+
174+
> [!released]
175+
>
176+
> - Task Id was introduced in Tasks X.Y.Z.
177+
178+
Since Tasks X.Y.Z, **[[Custom Grouping|custom grouping]]] by Id** is now possible, using `task.id`.
179+
165180
## Group by Dates in Tasks
166181

167182
### Due Date

docs/Queries/Sorting.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This page is long. Here are some links to the main sections:
1515
- [[#Default sort order]]
1616
- [[#Custom Sorting]]
1717
- [[#Sort by Task Statuses]]
18+
- [[#Sort by Task Dependencies]]
1819
- [[#Sort by Dates in Tasks]]
1920
- [[#Sort by Other Task Properties]]
2021
- [[#Sort by File Properties]]
@@ -157,6 +158,20 @@ sort by function task.status.nextSymbol
157158

158159
<!-- placeholder to force blank line after included text --><!-- endInclude -->
159160

161+
## Sort by Task Dependencies
162+
163+
### Id
164+
165+
- `sort by id`
166+
167+
For more information, see [[Task Dependencies]].
168+
169+
> [!released]
170+
>
171+
> - Task Id was introduced in Tasks X.Y.Z.
172+
173+
Since Tasks X.Y.Z, **[[Custom Sorting|custom sorting]] by Id** is now possible, using `task.id`.
174+
160175
## Sort by Dates in Tasks
161176

162177
### How dates are sorted

docs/Quick Reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This table summarizes the filters and other options available inside a `tasks` b
1919
| | | | | `task.status.symbol` |
2020
| | | | | `task.status.nextSymbol` |
2121
| **[[Task Dependencies]]** | | | | |
22-
| `is blocking` | | | `hide id` | `task.id` |
22+
| `id (includes, does not include) <string>`<br>`id (regex matches, regex does not match) /regex/i`<br>`has id`<br>`no id`<br>`is blocking` | `sort by id` | `group by id` | `hide id` | `task.id` |
2323
| `is not blocked` | | | `hide blocked by` | `task.blockedBy` |
2424
| **[[Filters#Filters for Dates in Tasks\|Dates]]** | | | | |
2525
| `done (on, before, after, on or before, on or after) <date>`<br>`done (in, before, after, in or before, in or after) ...`<br>`... YYYY-MM-DD YYYY-MM-DD`<br>`... (last, this, next) (week, month, quarter, year)`<br>`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`<br>`has done date`<br>`no done date`<br>`done date is invalid` | `sort by done` | `group by done` | `hide done date` | `task.done` |

src/Query/Filter/IdField.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Task } from '../../Task/Task';
2+
import type { FilterOrErrorMessage } from './FilterOrErrorMessage';
3+
import { FilterInstructions } from './FilterInstructions';
4+
import { TextField } from './TextField';
5+
6+
export class IdField extends TextField {
7+
private readonly filterInstructions: FilterInstructions = new FilterInstructions();
8+
9+
constructor() {
10+
super();
11+
this.filterInstructions.add('has id', (task: Task) => task.id.length > 0);
12+
this.filterInstructions.add('no id', (task: Task) => task.id.length === 0);
13+
}
14+
15+
// -----------------------------------------------------------------------------------------------------------------
16+
// Filtering
17+
// -----------------------------------------------------------------------------------------------------------------
18+
19+
public canCreateFilterForLine(line: string): boolean {
20+
if (this.filterInstructions.canCreateFilterForLine(line)) {
21+
return true;
22+
}
23+
24+
return super.canCreateFilterForLine(line);
25+
}
26+
27+
public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
28+
const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
29+
if (filterResult.filter !== undefined) {
30+
return filterResult;
31+
}
32+
33+
return super.createFilterOrErrorMessage(line);
34+
}
35+
36+
public fieldName(): string {
37+
return 'id';
38+
}
39+
40+
public value(task: Task): string {
41+
return task.id;
42+
}
43+
44+
// -----------------------------------------------------------------------------------------------------------------
45+
// Sorting
46+
// -----------------------------------------------------------------------------------------------------------------
47+
48+
public supportsSorting(): boolean {
49+
return true;
50+
}
51+
52+
// -----------------------------------------------------------------------------------------------------------------
53+
// Grouping
54+
// -----------------------------------------------------------------------------------------------------------------
55+
56+
public supportsGrouping(): boolean {
57+
return true;
58+
}
59+
}

src/Query/FilterParser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { RootField } from './Filter/RootField';
2929
import { BacklinkField } from './Filter/BacklinkField';
3030
import { CancelledDateField } from './Filter/CancelledDateField';
3131
import { BlockingField } from './Filter/BlockingField';
32+
import { IdField } from './Filter/IdField';
3233

3334
// When parsing a query the fields are tested one by one according to this order.
3435
// Since BooleanField is a meta-field, which needs to aggregate a few fields together, it is intended to
@@ -61,6 +62,7 @@ export const fieldCreators: EndsWith<BooleanField> = [
6162
() => new UrgencyField(),
6263
() => new RecurrenceField(),
6364
() => new FunctionField(),
65+
() => new IdField(),
6466
() => new BlockingField(),
6567
() => new BooleanField(), // --- Please make sure to keep BooleanField last (see comment above) ---
6668
];

tests/Query/Filter/IdField.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { testFilter } from '../../TestingTools/FilterTestHelpers';
2+
import { TaskBuilder } from '../../TestingTools/TaskBuilder';
3+
import { IdField } from '../../../src/Query/Filter/IdField';
4+
import {
5+
expectTaskComparesAfter,
6+
expectTaskComparesBefore,
7+
expectTaskComparesEqual,
8+
} from '../../CustomMatchers/CustomMatchersForSorting';
9+
10+
const idField = new IdField();
11+
12+
// Helper function to create a task with a given id
13+
function with_id(id: string) {
14+
return new TaskBuilder().id(id).build();
15+
}
16+
17+
describe('id', () => {
18+
it('should supply field name', () => {
19+
expect(idField.fieldName()).toEqual('id');
20+
});
21+
22+
it('by id presence', () => {
23+
// Arrange
24+
const line = 'has id';
25+
const filter = new IdField().createFilterOrErrorMessage(line);
26+
expect(idField.canCreateFilterForLine(line)).toEqual(true);
27+
28+
// Act, Assert
29+
testFilter(filter, new TaskBuilder().id(''), false);
30+
testFilter(filter, new TaskBuilder().id('abcdef'), true);
31+
});
32+
33+
it('by id absence', () => {
34+
// Arrange
35+
const line = 'no id';
36+
const filter = new IdField().createFilterOrErrorMessage(line);
37+
expect(idField.canCreateFilterForLine(line)).toEqual(true);
38+
39+
// Act, Assert
40+
testFilter(filter, new TaskBuilder().id(''), true);
41+
testFilter(filter, new TaskBuilder().id('abcdef'), false);
42+
});
43+
44+
it('by id (includes)', () => {
45+
// Arrange
46+
const filter = new IdField().createFilterOrErrorMessage('id includes DEF');
47+
48+
// Assert
49+
testFilter(filter, new TaskBuilder().id(''), false);
50+
testFilter(filter, new TaskBuilder().id('abcdef'), true);
51+
});
52+
53+
it('by id (does not include)', () => {
54+
// Arrange
55+
const filter = new IdField().createFilterOrErrorMessage('id does not include def');
56+
57+
// Assert
58+
testFilter(filter, new TaskBuilder().id(''), true);
59+
testFilter(filter, new TaskBuilder().id('abcdef'), false);
60+
});
61+
62+
it('by id (regex matches)', () => {
63+
// Arrange
64+
const filter = new IdField().createFilterOrErrorMessage(String.raw`id regex matches /\d/`);
65+
66+
// Assert
67+
testFilter(filter, new TaskBuilder().id(''), false);
68+
testFilter(filter, new TaskBuilder().id('a1'), true);
69+
testFilter(filter, new TaskBuilder().id('bc'), false);
70+
});
71+
72+
it('by id (regex does not match)', () => {
73+
// Arrange
74+
const filter = new IdField().createFilterOrErrorMessage(String.raw`id regex does not match /\d/`);
75+
76+
// Assert
77+
testFilter(filter, new TaskBuilder().id(''), true);
78+
testFilter(filter, new TaskBuilder().id('a1'), false);
79+
testFilter(filter, new TaskBuilder().id('bc'), true);
80+
});
81+
});
82+
83+
describe('sorting by id', () => {
84+
it('supports Field sorting methods correctly', () => {
85+
const field = new IdField();
86+
expect(field.supportsSorting()).toEqual(true);
87+
});
88+
89+
it('sort by id', () => {
90+
// Arrange
91+
const sorter = new IdField().createNormalSorter();
92+
93+
// Assert
94+
expectTaskComparesEqual(sorter, with_id('mvplec'), with_id('mvplec'));
95+
expectTaskComparesBefore(sorter, with_id('g7317o'), with_id('rot7gb'));
96+
97+
// Beginning with numbers
98+
expectTaskComparesBefore(sorter, with_id('1'), with_id('9'));
99+
expectTaskComparesBefore(sorter, with_id('9'), with_id('11'));
100+
});
101+
102+
it('sort by id reverse', () => {
103+
// Single example just to prove reverse works.
104+
// (There's no need to repeat all the examples above)
105+
const sorter = new IdField().createReverseSorter();
106+
expectTaskComparesAfter(sorter, with_id('bbb'), with_id('ddd'));
107+
});
108+
});
109+
110+
describe('grouping by id', () => {
111+
// Only minimal tests needed, as TextField is well covered by other tests
112+
it('supports grouping methods correctly', () => {
113+
expect(idField).toSupportGroupingWithProperty('id');
114+
});
115+
116+
it('should group by id name', () => {
117+
const grouper = idField.createNormalGrouper();
118+
expect({ grouper, tasks: [with_id('')] }).groupHeadingsToBe([]);
119+
expect({ grouper, tasks: [with_id('rot7gb')] }).groupHeadingsToBe(['rot7gb']);
120+
});
121+
});

tests/Query/Query.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,17 @@ describe('Query parsing', () => {
8787
'has done date',
8888
'has due date',
8989
'has happens date',
90+
'has id',
9091
'has scheduled date',
9192
'has start date',
9293
'has tag',
9394
'has tags',
9495
'heading does not include wibble',
9596
'heading includes AND', // Verify Query doesn't confuse this with a boolean query
9697
'heading includes wibble',
98+
'id does not include abc123',
99+
'id includes abc123',
100+
'id includes AND', // Verify Query doesn't confuse this with a boolean query
97101
'is blocking',
98102
'is not blocked',
99103
'is not recurring',
@@ -102,6 +106,7 @@ describe('Query parsing', () => {
102106
'no created date',
103107
'no due date',
104108
'no happens date',
109+
'no id',
105110
'no scheduled date',
106111
'no start date',
107112
'no tag',
@@ -251,6 +256,8 @@ describe('Query parsing', () => {
251256
'sort by happens reverse',
252257
'sort by heading',
253258
'sort by heading reverse',
259+
'sort by id',
260+
'sort by id reverse',
254261
'sort by path',
255262
'sort by path reverse',
256263
'sort by priority',
@@ -341,6 +348,8 @@ describe('Query parsing', () => {
341348
'group by happens reverse',
342349
'group by heading',
343350
'group by heading reverse',
351+
'group by id',
352+
'group by id reverse',
344353
'group by path',
345354
'group by path reverse',
346355
'group by priority',

0 commit comments

Comments
 (0)