Skip to content

Commit df39ddb

Browse files
author
Nicole Dresselhaus
committed
initial implementation. docs completely missing. Tooltip not done yet.
1 parent b783741 commit df39ddb

File tree

43 files changed

+456
-12
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+456
-12
lines changed

src/Commands/CreateOrEditTaskParser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TaskLocation } from '../Task/TaskLocation';
88
import { getSettings } from '../Config/Settings';
99
import { GlobalFilter } from '../Config/GlobalFilter';
1010
import { Priority } from '../Task/Priority';
11+
import { Duration } from '../Task/Duration';
1112
import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';
1213

1314
function getDefaultCreatedDate() {
@@ -89,6 +90,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta
8990
createdDate,
9091
startDate: null,
9192
scheduledDate: null,
93+
duration: Duration.None,
9294
dueDate: null,
9395
doneDate: null,
9496
cancelledDate: null,
@@ -130,6 +132,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta
130132
createdDate,
131133
startDate: null,
132134
scheduledDate: null,
135+
duration: Duration.None,
133136
dueDate: null,
134137
doneDate: null,
135138
cancelledDate: null,

src/Layout/TaskLayoutOptions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export enum TaskLayoutComponent {
1515
CreatedDate = 'createdDate',
1616
StartDate = 'startDate',
1717
ScheduledDate = 'scheduledDate',
18+
Duration = 'duration',
1819
DueDate = 'dueDate',
1920
CancelledDate = 'cancelledDate',
2021
DoneDate = 'doneDate',
@@ -111,6 +112,7 @@ export function parseTaskShowHideOptions(taskLayoutOptions: TaskLayoutOptions, o
111112
['depends on', TaskLayoutComponent.DependsOn],
112113
['done date', TaskLayoutComponent.DoneDate],
113114
['due date', TaskLayoutComponent.DueDate],
115+
['duration', TaskLayoutComponent.Duration],
114116
['id', TaskLayoutComponent.Id],
115117
['on completion', TaskLayoutComponent.OnCompletion],
116118
['priority', TaskLayoutComponent.Priority],

src/Renderer/TaskFieldRenderer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export class TaskFieldHTMLData {
9797
// TS2345: Argument of type 'string[] | Moment' is not assignable to parameter of type 'Moment'.
9898
// Type 'string[]' is missing the following properties from type 'Moment': format, startOf, endOf, add, and 78 more.
9999
if (!Array.isArray(date) && date instanceof window.moment) {
100-
const attributeValue = dateToAttribute(date);
100+
const attributeValue = dateToAttribute(date as Moment);
101101
if (attributeValue) {
102102
return attributeValue;
103103
}
@@ -183,6 +183,9 @@ const taskFieldHTMLData: { [c in TaskLayoutComponent]: TaskFieldHTMLData } = {
183183
return PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
184184
}),
185185

186+
duration: new TaskFieldHTMLData('task-duration', 'taskDuration', (_component, task) => {
187+
return task.duration.toText();
188+
}),
186189
description: createFieldWithoutDataAttributes('task-description'),
187190
recurrenceRule: createFieldWithoutDataAttributes('task-recurring'),
188191
onCompletion: createFieldWithoutDataAttributes('task-onCompletion'),

src/Renderer/TaskLineRenderer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TaskLayoutComponent, type TaskLayoutOptions } from '../Layout/TaskLayou
77
import { replaceTaskWithTasks } from '../Obsidian/File';
88
import { StatusRegistry } from '../Statuses/StatusRegistry';
99
import { Task } from '../Task/Task';
10+
import { Duration } from '../Task/Duration';
1011
import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';
1112
import { StatusMenu } from '../ui/Menus/StatusMenu';
1213
import type { AllTaskDateFields } from '../DateTime/DateFieldTypes';
@@ -218,6 +219,10 @@ export class TaskLineRenderer {
218219
this.queryLayoutOptions.shortMode,
219220
component,
220221
);
222+
// skip empty duration
223+
if (component == TaskLayoutComponent.Duration && task.duration === Duration.None) {
224+
continue;
225+
}
221226
if (componentString) {
222227
// Create the text span that will hold the rendered component
223228
const span = createAndAppendElement('span', parentElement);
@@ -274,6 +279,10 @@ export class TaskLineRenderer {
274279
if (li.dataset.taskPriority === undefined) {
275280
fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Priority);
276281
}
282+
// Same logic for duration of 0h0m. It will not be rendered, but data-attribute should be present.
283+
if (li.dataset.taskDuration === undefined) {
284+
fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Duration);
285+
}
277286
}
278287

279288
/*

src/Suggestor/Suggestor.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Settings } from '../Config/Settings';
44
import { DateParser } from '../DateTime/DateParser';
55
import { doAutocomplete } from '../DateTime/DateAbbreviations';
66
import { Occurrence } from '../Task/Occurrence';
7+
import { Duration } from '../Task/Duration';
78
import { Recurrence } from '../Task/Recurrence';
89
import {
910
type DefaultTaskSerializerSymbols,
@@ -80,6 +81,9 @@ export function makeDefaultSuggestionBuilder(
8081
// add date suggestions if relevant
8182
suggestions = suggestions.concat(addDatesSuggestions(datePrefixRegex, maxGenericSuggestions, parameters));
8283

84+
// add duration suggestions if relevant
85+
suggestions = suggestions.concat(addDurationValueSuggestions(symbols.durationSymbol, parameters));
86+
8387
// add recurrence suggestions if relevant
8488
suggestions = suggestions.concat(addRecurrenceValueSuggestions(symbols.recurrenceSymbol, parameters));
8589

@@ -152,6 +156,7 @@ function addTaskPropertySuggestions(
152156
addField(genericSuggestions, line, symbols.dueDateSymbol, 'due date');
153157
addField(genericSuggestions, line, symbols.startDateSymbol, 'start date');
154158
addField(genericSuggestions, line, symbols.scheduledDateSymbol, 'scheduled date');
159+
addField(genericSuggestions, line, symbols.durationSymbol, 'duration');
155160

156161
addPrioritySuggestions(genericSuggestions, symbols, parameters);
157162
addField(genericSuggestions, line, symbols.recurrenceSymbol, 'recurring (repeat)');
@@ -272,6 +277,95 @@ function dateExtractor(symbol: string, date: string) {
272277
return { displayText, appendText };
273278
}
274279

280+
/*
281+
* If the cursor is located in a section that should be followed by a duration description, suggest options
282+
* for what to enter as a duration.
283+
* This has two parts: either generic predefined suggestions, or a single suggestion that is a parsed result
284+
* of what the user is typing.
285+
* Generic predefined suggestions, in turn, also have two options: either filtered (if the user started typing
286+
* something where a duration is expected) or unfiltered
287+
*/
288+
function addDurationValueSuggestions(durationSymbol: string, parameters: SuggestorParameters) {
289+
let genericSuggestions = ['5m', '15m', '1h', '30m', '45m', '2h', '3h'];
290+
const hourSuggestions = ['15m', '30m', '45m'];
291+
292+
const results: SuggestInfo[] = [];
293+
const durationRegex = new RegExp(`(${durationSymbol})\\s*([0-9hm]*)`, 'ug');
294+
const durationMatch = matchIfCursorInRegex(durationRegex, parameters);
295+
if (durationMatch && durationMatch.length >= 1) {
296+
const durationPrefix = durationMatch[1];
297+
let durationString = '';
298+
if (durationMatch[2]) {
299+
durationString = durationMatch[2];
300+
}
301+
if (durationString.length > 0) {
302+
// If the text matches a valid duration, suggest logical continuations
303+
let parsedduration = Duration.fromText(durationString);
304+
if (!parsedduration) {
305+
//not a valid duration string => no h/m yet. Suggest finishing with minutes or complete hour!
306+
if (parseInt(durationString, 10) > 0) {
307+
results.push({
308+
suggestionType: 'match',
309+
displayText: `${durationPrefix} ${durationString}m`,
310+
appendText: `${durationPrefix} ${durationString}m` + parameters.postfix,
311+
insertAt: durationMatch.index,
312+
insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters),
313+
});
314+
genericSuggestions = genericSuggestions.filter((s) => s != `${durationString}m`);
315+
results.push({
316+
suggestionType: 'match',
317+
displayText: `${durationPrefix} ${durationString}h`,
318+
appendText: `${durationPrefix} ${durationString}h` + parameters.postfix,
319+
insertAt: durationMatch.index,
320+
insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters),
321+
});
322+
genericSuggestions = genericSuggestions.filter((s) => s != `${durationString}h`);
323+
}
324+
// also suggest that '2' implies '2h', thus also suggesting continuations like '2h30m'
325+
parsedduration = Duration.fromText(durationString + 'h');
326+
}
327+
if (parsedduration) {
328+
// special suggestions on finished hour like '123h'
329+
const genText = (sugg: string) => `${durationPrefix} ${parsedduration!.hours}h${sugg}`;
330+
for (const suggestion of hourSuggestions.filter(
331+
// suggestion is either all suggestions or the one with matching prefix
332+
(s) => parsedduration?.minutes == 0 || s.startsWith(parsedduration!.minutes.toString(10)),
333+
)) {
334+
results.push({
335+
suggestionType: 'match',
336+
displayText: `${genText(suggestion)}`,
337+
appendText: genText(suggestion) + parameters.postfix,
338+
insertAt: durationMatch.index,
339+
insertSkip: calculateSkipValueForMatch(durationMatch[0], parameters),
340+
});
341+
}
342+
}
343+
}
344+
// Now to generic predefined suggestions.
345+
// If we get a partial match with some of the suggestions (e.g. the user started typing "3"),
346+
// we use that for matches to the generic example-list above (i.e. "3h").
347+
// Otherwise, we just display the list of suggestions, and either way, truncate them eventually to
348+
// a max number.
349+
// In the case of duration rules, the max number should be small enough to allow users to "escape"
350+
// the mode of writing a duration rule, i.e. we should leave enough space for component suggestions
351+
const maxGenericDurationSuggestions = parameters.settings.autoSuggestMaxItems / 2;
352+
const genericMatches = filterGenericSuggestions(
353+
genericSuggestions,
354+
durationString,
355+
maxGenericDurationSuggestions,
356+
true,
357+
);
358+
359+
const extractor = (durationPrefix: string, match: string) => {
360+
const displayText = `${durationPrefix} ${match}`;
361+
const appendText = `${durationPrefix} ${match}`;
362+
return { displayText, appendText };
363+
};
364+
constructSuggestions(parameters, durationMatch, genericMatches, extractor, results);
365+
}
366+
367+
return results;
368+
}
275369
/*
276370
* If the cursor is located in a section that should be followed by a date (due, start date or scheduled date),
277371
* suggest options for what to enter as a date.

src/Task/Duration.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export class Duration {
2+
readonly hours: number;
3+
readonly minutes: number;
4+
5+
constructor({ hours, minutes }: { hours: number; minutes: number }) {
6+
this.hours = hours;
7+
this.minutes = minutes;
8+
}
9+
10+
public static None: Duration = new Duration({ hours: 0, minutes: 0 });
11+
public static valueRegEx: string = '[0-9]+h[0-9]+m?|[0-9]+h|[0-9]+m?';
12+
13+
public static fromText(durationText: string): Duration | null {
14+
try {
15+
let match = durationText.match(/^([0-9]+)h$/i);
16+
if (match != null) {
17+
if (parseInt(match[1], 10) > 0) {
18+
return new Duration({ hours: parseInt(match[1], 10), minutes: 0 });
19+
}
20+
}
21+
match = durationText.match(/^([0-9]+)m$/i);
22+
if (match != null) {
23+
if (parseInt(match[1], 10) > 0) {
24+
return new Duration({ hours: 0, minutes: parseInt(match[1], 10) });
25+
}
26+
}
27+
match = durationText.match(/^([0-9]+)h([0-9]+)m?$/i);
28+
if (match != null) {
29+
if (parseInt(match[1], 10) > 0 || parseInt(match[2], 10) > 0) {
30+
return new Duration({ hours: parseInt(match[1], 10), minutes: parseInt(match[2], 10) });
31+
}
32+
}
33+
return null;
34+
} catch (e) {
35+
// Could not read recurrence rule. User possibly not done typing.
36+
// Print error message, as it is useful if a test file has not set up window.moment
37+
if (e instanceof Error) {
38+
console.log(e.message);
39+
}
40+
}
41+
42+
return null;
43+
}
44+
45+
public toText(): string {
46+
if (this.hours == 0 && this.minutes == 0) {
47+
return '';
48+
}
49+
50+
return this.hours + 'h' + this.minutes + 'm';
51+
}
52+
}

src/Task/Task.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Urgency } from './Urgency';
1616
import type { Recurrence } from './Recurrence';
1717
import type { TaskLocation } from './TaskLocation';
1818
import type { Priority } from './Priority';
19+
import { Duration } from './Duration';
1920
import { TaskRegularExpressions } from './TaskRegularExpressions';
2021
import { OnCompletion, handleOnCompletion } from './OnCompletion';
2122

@@ -49,6 +50,7 @@ export class Task extends ListItem {
4950
public readonly createdDate: Moment | null;
5051
public readonly startDate: Moment | null;
5152
public readonly scheduledDate: Moment | null;
53+
public readonly duration: Duration;
5254
public readonly dueDate: Moment | null;
5355
public readonly doneDate: Moment | null;
5456
public readonly cancelledDate: Moment | null;
@@ -78,6 +80,7 @@ export class Task extends ListItem {
7880
createdDate,
7981
startDate,
8082
scheduledDate,
83+
duration,
8184
dueDate,
8285
doneDate,
8386
cancelledDate,
@@ -101,6 +104,7 @@ export class Task extends ListItem {
101104
createdDate: moment.Moment | null;
102105
startDate: moment.Moment | null;
103106
scheduledDate: moment.Moment | null;
107+
duration: Duration;
104108
dueDate: moment.Moment | null;
105109
doneDate: moment.Moment | null;
106110
cancelledDate: moment.Moment | null;
@@ -133,6 +137,7 @@ export class Task extends ListItem {
133137
this.createdDate = createdDate;
134138
this.startDate = startDate;
135139
this.scheduledDate = scheduledDate;
140+
this.duration = duration;
136141
this.dueDate = dueDate;
137142
this.doneDate = doneDate;
138143
this.cancelledDate = cancelledDate;
@@ -654,6 +659,20 @@ export class Task extends ListItem {
654659
return new TasksDate(this.scheduledDate);
655660
}
656661

662+
/**
663+
* Return {@link hours} from {@link Duration}.
664+
*/
665+
public get durationHours(): number | string {
666+
return this.duration === Duration.None ? '' : this.duration.hours;
667+
}
668+
669+
/**
670+
* Return {@link minutes} from {@link Duration}.
671+
*/
672+
public get durationMinutes(): number | string {
673+
return this.duration === Duration.None ? '' : this.duration.minutes;
674+
}
675+
657676
/**
658677
* Return {@link startDate} as a {@link TasksDate}, so the field names in scripting docs are consistent with the existing search instruction names, and null values are easy to deal with.
659678
*/
@@ -781,6 +800,7 @@ export class Task extends ListItem {
781800
// happens more often than is ideal.
782801
let args: Array<keyof Task> = [
783802
'priority',
803+
'duration',
784804
'blockLink',
785805
'scheduledDateIsInferred',
786806
'id',
@@ -820,6 +840,11 @@ export class Task extends ListItem {
820840
if (!this.recurrenceIdenticalTo(other)) {
821841
return false;
822842
}
843+
// Compare duration. Only identical if their textual representation is identical.
844+
// 1h30m may not be semantically equal to 90m.
845+
if (this.duration.toText() != other.duration.toText()) {
846+
return false;
847+
}
823848

824849
return this.file.rawFrontmatterIdenticalTo(other.file);
825850
}

src/TaskSerializer/DataviewTaskSerializer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { TaskLayoutComponent } from '../Layout/TaskLayoutOptions';
22
import { PriorityTools } from '../lib/PriorityTools';
33
import type { Priority } from '../Task/Priority';
4+
import { Duration } from '../Task/Duration';
45
import type { Task } from '../Task/Task';
56
import { DefaultTaskSerializer, taskIdRegex, taskIdSequenceRegex } from './DefaultTaskSerializer';
67

@@ -72,6 +73,7 @@ export const DATAVIEW_SYMBOLS = {
7273
startDateSymbol: 'start::',
7374
createdDateSymbol: 'created::',
7475
scheduledDateSymbol: 'scheduled::',
76+
durationSymbol: 'duration::',
7577
dueDateSymbol: 'due::',
7678
doneDateSymbol: 'completion::',
7779
cancelledDateSymbol: 'cancelled::',
@@ -84,6 +86,7 @@ export const DATAVIEW_SYMBOLS = {
8486
startDateRegex: toInlineFieldRegex(/start:: *(\d{4}-\d{2}-\d{2})/),
8587
createdDateRegex: toInlineFieldRegex(/created:: *(\d{4}-\d{2}-\d{2})/),
8688
scheduledDateRegex: toInlineFieldRegex(/scheduled:: *(\d{4}-\d{2}-\d{2})/),
89+
durationRegex: toInlineFieldRegex(new RegExp('duration:: *(' + Duration.valueRegEx + ')')),
8790
dueDateRegex: toInlineFieldRegex(/due:: *(\d{4}-\d{2}-\d{2})/),
8891
doneDateRegex: toInlineFieldRegex(/completion:: *(\d{4}-\d{2}-\d{2})/),
8992
cancelledDateRegex: toInlineFieldRegex(/cancelled:: *(\d{4}-\d{2}-\d{2})/),

0 commit comments

Comments
 (0)