Skip to content

Commit ed8cb25

Browse files
author
Nicole Dresselhaus
committed
added Query/Filter/Grouping
1 parent ae661e7 commit ed8cb25

File tree

6 files changed

+339
-6
lines changed

6 files changed

+339
-6
lines changed

src/Query/Filter/DurationField.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { Task } from '../../Task/Task';
2+
import { Duration } from '../../Task/Duration';
3+
import { Explanation } from '../Explain/Explanation';
4+
import type { Comparator } from '../Sort/Sorter';
5+
import type { GrouperFunction } from '../Group/Grouper';
6+
import { TemplatingPluginTools } from '../../lib/TemplatingPluginTools';
7+
import { Field } from './Field';
8+
import { Filter, type FilterFunction } from './Filter';
9+
import { FilterInstructions } from './FilterInstructions';
10+
import { FilterOrErrorMessage } from './FilterOrErrorMessage';
11+
12+
export type DurationFilterFunction = (duration: Duration) => boolean;
13+
14+
export class DurationField extends Field {
15+
protected readonly filterInstructions: FilterInstructions;
16+
17+
constructor(filterInstructions: FilterInstructions | null = null) {
18+
super();
19+
if (filterInstructions !== null) {
20+
this.filterInstructions = filterInstructions;
21+
} else {
22+
this.filterInstructions = new FilterInstructions();
23+
this.filterInstructions.add(`has ${this.fieldName()}`, (task: Task) => task.duration !== Duration.None);
24+
this.filterInstructions.add(`no ${this.fieldName()}`, (task: Task) => task.duration === Duration.None);
25+
}
26+
}
27+
28+
public fieldName(): string {
29+
return 'duration';
30+
}
31+
32+
public canCreateFilterForLine(line: string): boolean {
33+
if (this.filterInstructions.canCreateFilterForLine(line)) {
34+
return true;
35+
}
36+
37+
return super.canCreateFilterForLine(line);
38+
}
39+
40+
public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
41+
// There have been multiple "bug reports", where the query had un-expanded
42+
// template text to signify the search duration.
43+
// Enough to explicitly trap any such text for duration searches:
44+
const errorText = this.checkForUnexpandedTemplateText(line);
45+
if (errorText) {
46+
return FilterOrErrorMessage.fromError(line, errorText);
47+
}
48+
49+
const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
50+
if (filterResult.isValid()) {
51+
return filterResult;
52+
}
53+
54+
const fieldNameKeywordDuration = Field.getMatch(this.filterRegExp(), line);
55+
if (fieldNameKeywordDuration === null) {
56+
return FilterOrErrorMessage.fromError(line, 'do not understand query filter (' + this.fieldName() + ')');
57+
}
58+
59+
const fieldKeyword = fieldNameKeywordDuration[2]?.toLowerCase(); // 'is', 'above', 'under'
60+
const fieldDurationString = fieldNameKeywordDuration[3]; // The remainder of the instruction
61+
62+
// Try interpreting everything after the keyword as a duration:
63+
const fieldDuration = Duration.fromText(fieldDurationString);
64+
65+
if (!fieldDuration) {
66+
return FilterOrErrorMessage.fromError(line, 'do not understand ' + this.fieldName());
67+
}
68+
69+
const filterFunction = this.buildFilterFunction(fieldKeyword, fieldDuration);
70+
71+
const explanation = DurationField.buildExplanation(
72+
this.fieldNameForExplanation(),
73+
fieldKeyword,
74+
this.filterResultIfFieldMissing(),
75+
fieldDuration,
76+
);
77+
return FilterOrErrorMessage.fromFilter(new Filter(line, filterFunction, explanation));
78+
}
79+
80+
/**
81+
* Builds function that actually filters the tasks depending on the duration
82+
* @param fieldKeyword relationship to be held with the duration 'under', 'is', 'above'
83+
* @param fieldDuration the duration to be used by the filter function
84+
* @returns the function that filters the tasks
85+
*/
86+
protected buildFilterFunction(fieldKeyword: string, fieldDuration: Duration): FilterFunction {
87+
let durationFilter: DurationFilterFunction;
88+
switch (fieldKeyword) {
89+
case 'under':
90+
durationFilter = (duration) => this.compare(duration, fieldDuration) < 0;
91+
break;
92+
case 'above':
93+
durationFilter = (duration) => this.compare(duration, fieldDuration) > 0;
94+
break;
95+
case 'is':
96+
default:
97+
durationFilter = (duration) => this.compare(duration, fieldDuration) === 0;
98+
break;
99+
}
100+
return this.getFilter(durationFilter);
101+
}
102+
103+
protected getFilter(durationFilterFunction: DurationFilterFunction): FilterFunction {
104+
return (task: Task) => {
105+
return durationFilterFunction(task.duration);
106+
};
107+
}
108+
109+
protected filterRegExp(): RegExp {
110+
return new RegExp('^duration( expectation)? (is|above|under) ?(.*)', 'i');
111+
}
112+
113+
/**
114+
* Constructs an Explanation for a duration-based filter
115+
* @param fieldName - for example, 'due'
116+
* @param fieldKeyword - one of the keywords like 'before' or 'after'
117+
* @param filterResultIfFieldMissing - whether the search matches tasks without the requested duration value
118+
* @param filterDurations - the duration range used in the filter
119+
*/
120+
public static buildExplanation(
121+
fieldName: string,
122+
fieldKeyword: string,
123+
filterResultIfFieldMissing: boolean,
124+
filterDurations: Duration,
125+
): Explanation {
126+
const fieldKeywordVerbose = fieldKeyword === 'is' ? 'is' : 'is ' + fieldKeyword;
127+
let oneLineExplanation = `${fieldName} ${fieldKeywordVerbose} ${filterDurations.toText()}`;
128+
if (filterResultIfFieldMissing) {
129+
oneLineExplanation += ` OR no ${fieldName}`;
130+
}
131+
return new Explanation(oneLineExplanation);
132+
}
133+
134+
protected fieldNameForExplanation() {
135+
return this.fieldName();
136+
}
137+
138+
/**
139+
* Determine whether a task that does not have a duration value
140+
* should be treated as a match.
141+
* @protected
142+
*/
143+
protected filterResultIfFieldMissing(): boolean {
144+
return false;
145+
}
146+
147+
public supportsSorting(): boolean {
148+
return true;
149+
}
150+
151+
public compare(a: Duration, b: Duration): number {
152+
if (a === Duration.None || b === Duration.None) {
153+
return 0;
154+
}
155+
return a.hours * 60 + a.minutes - (b.hours * 60 + b.minutes);
156+
}
157+
158+
public comparator(): Comparator {
159+
return (a: Task, b: Task) => {
160+
return this.compare(a.duration, b.duration);
161+
};
162+
}
163+
164+
public supportsGrouping(): boolean {
165+
return true;
166+
}
167+
168+
public grouper(): GrouperFunction {
169+
return (task: Task) => {
170+
const duration = task.duration;
171+
if (!duration || duration === Duration.None) {
172+
return ['No ' + this.fieldName()];
173+
}
174+
return [duration.toText()];
175+
};
176+
}
177+
178+
private checkForUnexpandedTemplateText(line: string): null | string {
179+
return new TemplatingPluginTools().findUnexpandedDateText(line);
180+
}
181+
}

src/Query/FilterParser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { HeadingField } from './Filter/HeadingField';
99
import { PathField } from './Filter/PathField';
1010
import { PriorityField } from './Filter/PriorityField';
1111
import { ScheduledDateField } from './Filter/ScheduledDateField';
12+
import { DurationField } from './Filter/DurationField';
1213
import { StartDateField } from './Filter/StartDateField';
1314
import { HappensDateField } from './Filter/HappensDateField';
1415
import { RecurringField } from './Filter/RecurringField';
@@ -50,6 +51,7 @@ export const fieldCreators: EndsWith<BooleanField> = [
5051
() => new CreatedDateField(),
5152
() => new StartDateField(),
5253
() => new ScheduledDateField(),
54+
() => new DurationField(),
5355
() => new DueDateField(),
5456
() => new DoneDateField(),
5557
() => new PathField(),

src/Renderer/Renderer.scss

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
:root {
22
--tasks-details-icon: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M8.59 16.58L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z'/></svg>");
3-
43
}
54

65
/* Fix indentation of wrapped task lines in Tasks search results, when in Live Preview. */
76
ul.contains-task-list .task-list-item-checkbox {
87
margin-inline-start: calc(var(--checkbox-size) * -1.5) !important;
98
}
109

11-
.plugin-tasks-query-explanation{
10+
.plugin-tasks-query-explanation {
1211
/* Prevent long explanation lines wrapping, so they are more readable,
1312
especially on small screens.
1413
@@ -53,6 +52,7 @@ ul.contains-task-list .task-list-item-checkbox {
5352
.task-done,
5453
.task-due,
5554
.task-scheduled,
55+
.task-duration,
5656
.task-start {
5757
cursor: pointer;
5858
user-select: none;
@@ -61,11 +61,12 @@ ul.contains-task-list .task-list-item-checkbox {
6161
}
6262

6363
/* Edit and postpone */
64-
.tasks-edit, .tasks-postpone {
64+
.tasks-edit,
65+
.tasks-postpone {
6566
width: 1em;
6667
height: 1em;
6768
vertical-align: middle;
68-
margin-left: .33em;
69+
margin-left: 0.33em;
6970
cursor: pointer;
7071
font-family: var(--font-interface);
7172
color: var(--text-accent);
@@ -74,7 +75,8 @@ ul.contains-task-list .task-list-item-checkbox {
7475
-webkit-touch-callout: none;
7576
}
7677

77-
a.tasks-edit, a.tasks-postpone {
78+
a.tasks-edit,
79+
a.tasks-postpone {
7880
text-decoration: none;
7981
}
8082

@@ -124,6 +126,6 @@ a.tasks-edit, a.tasks-postpone {
124126

125127
/* Workaround for issue #2073: Enabling the plugin causes blockIds to be not hidden in reading view
126128
https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2073 */
127-
.task-list-item .task-block-link{
129+
.task-list-item .task-block-link {
128130
display: none;
129131
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import { DurationField } from '../../../src/Query/Filter/DurationField';
6+
import { TaskBuilder } from '../../TestingTools/TaskBuilder';
7+
import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
8+
import { SampleTasks } from '../../TestingTools/SampleTasks';
9+
import { Duration } from '../../../src/Task/Duration';
10+
import { toBeValid, toMatchTask } from '../../CustomMatchers/CustomMatchersForFilters';
11+
12+
expect.extend({
13+
toBeValid,
14+
});
15+
16+
expect.extend({
17+
toMatchTask,
18+
});
19+
20+
describe('DurationField', () => {
21+
it('should reject duration search containing unexpanded template text', () => {
22+
// Thorough checks are done in TemplatingPluginTools tests.
23+
24+
// Arrange
25+
const instruction = 'duration under <%+ tp.get_remaining_hours_in_day_example() %>';
26+
27+
// Act
28+
const filter = new DurationField().createFilterOrErrorMessage(instruction);
29+
30+
// Assert
31+
expect(filter).not.toBeValid();
32+
expect(filter.error).toContain('Instruction contains unexpanded template text');
33+
});
34+
35+
it('should honour original case, when explaining simple filters', () => {
36+
const filter = new DurationField().createFilterOrErrorMessage('HAS DURATION');
37+
expect(filter).toHaveExplanation('HAS DURATION');
38+
});
39+
});
40+
41+
describe('explain duration queries', () => {
42+
it('should explain explicit date', () => {
43+
const filterOrMessage = new DurationField().createFilterOrErrorMessage('duration under 5h');
44+
expect(filterOrMessage).toHaveExplanation('duration is under 5h0m');
45+
});
46+
47+
it('"is" gets not duplicated', () => {
48+
const filterOrMessage = new DurationField().createFilterOrErrorMessage('duration is 5m');
49+
expect(filterOrMessage).toHaveExplanation('duration is 0h5m');
50+
});
51+
});
52+
53+
describe('sorting by duration', () => {
54+
it('supports Field sorting methods correctly', () => {
55+
const field = new DurationField();
56+
expect(field.supportsSorting()).toEqual(true);
57+
});
58+
59+
// These are minimal tests just to confirm basic behaviour is set up for this field.
60+
// Thorough testing is done in DueDateField.test.ts.
61+
62+
const duration1 = new TaskBuilder().duration(new Duration({ hours: 1, minutes: 30 })).build();
63+
const duration2 = new TaskBuilder().duration(new Duration({ hours: 3, minutes: 0 })).build();
64+
65+
it('sort by duration', () => {
66+
expectTaskComparesBefore(new DurationField().createNormalSorter(), duration1, duration2);
67+
});
68+
69+
it('sort by duration reverse', () => {
70+
expectTaskComparesAfter(new DurationField().createReverseSorter(), duration1, duration2);
71+
});
72+
});
73+
74+
describe('grouping by duration', () => {
75+
it('supports Field grouping methods correctly', () => {
76+
expect(new DurationField()).toSupportGroupingWithProperty('duration');
77+
});
78+
79+
it('group by duration', () => {
80+
// Arrange
81+
const grouper = new DurationField().createNormalGrouper();
82+
const taskWithDuration = new TaskBuilder().duration(new Duration({ hours: 1, minutes: 30 })).build();
83+
const taskWithoutDuration = new TaskBuilder().build();
84+
85+
// Assert
86+
expect({ grouper, tasks: [taskWithDuration] }).groupHeadingsToBe(['1h30m']);
87+
expect({ grouper, tasks: [taskWithoutDuration] }).groupHeadingsToBe(['No duration']);
88+
});
89+
90+
it('should sort groups for DurationField', () => {
91+
const grouper = new DurationField().createNormalGrouper();
92+
const tasks = SampleTasks.withAllRepresentativeDurations();
93+
94+
expect({ grouper, tasks }).groupHeadingsToBe([
95+
'0h5m',
96+
'0h90m',
97+
'1h0m',
98+
'3h25m',
99+
'4h90m',
100+
'96h0m',
101+
'No duration',
102+
]);
103+
});
104+
});

0 commit comments

Comments
 (0)