Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Commands/CreateOrEditTaskParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TaskLocation } from '../Task/TaskLocation';
import { getSettings } from '../Config/Settings';
import { GlobalFilter } from '../Config/GlobalFilter';
import { Priority } from '../Task/Priority';
import { Duration } from '../Task/Duration';
import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';

function getDefaultCreatedDate() {
Expand Down Expand Up @@ -89,6 +90,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta
createdDate,
startDate: null,
scheduledDate: null,
duration: Duration.None,
dueDate: null,
doneDate: null,
cancelledDate: null,
Expand Down Expand Up @@ -130,6 +132,7 @@ export const taskFromLine = ({ line, path }: { line: string; path: string }): Ta
createdDate,
startDate: null,
scheduledDate: null,
duration: Duration.None,
dueDate: null,
doneDate: null,
cancelledDate: null,
Expand Down
2 changes: 2 additions & 0 deletions src/Layout/TaskLayoutOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum TaskLayoutComponent {
CreatedDate = 'createdDate',
StartDate = 'startDate',
ScheduledDate = 'scheduledDate',
Duration = 'duration',
DueDate = 'dueDate',
CancelledDate = 'cancelledDate',
DoneDate = 'doneDate',
Expand Down Expand Up @@ -111,6 +112,7 @@ export function parseTaskShowHideOptions(taskLayoutOptions: TaskLayoutOptions, o
['depends on', TaskLayoutComponent.DependsOn],
['done date', TaskLayoutComponent.DoneDate],
['due date', TaskLayoutComponent.DueDate],
['duration', TaskLayoutComponent.Duration],
['id', TaskLayoutComponent.Id],
['on completion', TaskLayoutComponent.OnCompletion],
['priority', TaskLayoutComponent.Priority],
Expand Down
181 changes: 181 additions & 0 deletions src/Query/Filter/DurationField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { Task } from '../../Task/Task';
import { Duration } from '../../Task/Duration';
import { Explanation } from '../Explain/Explanation';
import type { Comparator } from '../Sort/Sorter';
import type { GrouperFunction } from '../Group/Grouper';
import { TemplatingPluginTools } from '../../lib/TemplatingPluginTools';
import { Field } from './Field';
import { Filter, type FilterFunction } from './Filter';
import { FilterInstructions } from './FilterInstructions';
import { FilterOrErrorMessage } from './FilterOrErrorMessage';

export type DurationFilterFunction = (duration: Duration) => boolean;

export class DurationField extends Field {
protected readonly filterInstructions: FilterInstructions;

constructor(filterInstructions: FilterInstructions | null = null) {
super();
if (filterInstructions !== null) {
this.filterInstructions = filterInstructions;
} else {
this.filterInstructions = new FilterInstructions();
this.filterInstructions.add(`has ${this.fieldName()}`, (task: Task) => task.duration !== Duration.None);
this.filterInstructions.add(`no ${this.fieldName()}`, (task: Task) => task.duration === Duration.None);
}
}

public fieldName(): string {
return 'duration';
}

public canCreateFilterForLine(line: string): boolean {
if (this.filterInstructions.canCreateFilterForLine(line)) {
return true;
}

return super.canCreateFilterForLine(line);
}

public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
// There have been multiple "bug reports", where the query had un-expanded
// template text to signify the search duration.
// Enough to explicitly trap any such text for duration searches:
const errorText = this.checkForUnexpandedTemplateText(line);
if (errorText) {
return FilterOrErrorMessage.fromError(line, errorText);
}

const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
if (filterResult.isValid()) {
return filterResult;
}

const fieldNameKeywordDuration = Field.getMatch(this.filterRegExp(), line);
if (fieldNameKeywordDuration === null) {
return FilterOrErrorMessage.fromError(line, 'do not understand query filter (' + this.fieldName() + ')');
}

const fieldKeyword = fieldNameKeywordDuration[2]?.toLowerCase(); // 'is', 'above', 'under'
const fieldDurationString = fieldNameKeywordDuration[3]; // The remainder of the instruction

// Try interpreting everything after the keyword as a duration:
const fieldDuration = Duration.fromText(fieldDurationString);

if (!fieldDuration) {
return FilterOrErrorMessage.fromError(line, 'do not understand ' + this.fieldName());
}

const filterFunction = this.buildFilterFunction(fieldKeyword, fieldDuration);

const explanation = DurationField.buildExplanation(
this.fieldNameForExplanation(),
fieldKeyword,
this.filterResultIfFieldMissing(),
fieldDuration,
);
return FilterOrErrorMessage.fromFilter(new Filter(line, filterFunction, explanation));
}

/**
* Builds function that actually filters the tasks depending on the duration
* @param fieldKeyword relationship to be held with the duration 'under', 'is', 'above'
* @param fieldDuration the duration to be used by the filter function
* @returns the function that filters the tasks
*/
protected buildFilterFunction(fieldKeyword: string, fieldDuration: Duration): FilterFunction {
let durationFilter: DurationFilterFunction;
switch (fieldKeyword) {
case 'under':
durationFilter = (duration) => this.compare(duration, fieldDuration) < 0;
break;
case 'above':
durationFilter = (duration) => this.compare(duration, fieldDuration) > 0;
break;
case 'is':
default:
durationFilter = (duration) => this.compare(duration, fieldDuration) === 0;
break;
}
return this.getFilter(durationFilter);
}

protected getFilter(durationFilterFunction: DurationFilterFunction): FilterFunction {
return (task: Task) => {
return durationFilterFunction(task.duration);
};
}

protected filterRegExp(): RegExp {
return new RegExp('^duration( expectation)? (is|above|under) ?(.*)', 'i');
}

/**
* Constructs an Explanation for a duration-based filter
* @param fieldName - for example, 'due'
* @param fieldKeyword - one of the keywords like 'before' or 'after'
* @param filterResultIfFieldMissing - whether the search matches tasks without the requested duration value
* @param filterDurations - the duration range used in the filter
*/
public static buildExplanation(
fieldName: string,
fieldKeyword: string,
filterResultIfFieldMissing: boolean,
filterDurations: Duration,
): Explanation {
const fieldKeywordVerbose = fieldKeyword === 'is' ? 'is' : 'is ' + fieldKeyword;
let oneLineExplanation = `${fieldName} ${fieldKeywordVerbose} ${filterDurations.toText()}`;
if (filterResultIfFieldMissing) {
oneLineExplanation += ` OR no ${fieldName}`;
}
return new Explanation(oneLineExplanation);
}

protected fieldNameForExplanation() {
return this.fieldName();
}

/**
* Determine whether a task that does not have a duration value
* should be treated as a match.
* @protected
*/
protected filterResultIfFieldMissing(): boolean {
return false;
}

public supportsSorting(): boolean {
return true;
}

public compare(a: Duration, b: Duration): number {
if (a === Duration.None || b === Duration.None) {
return 0;
}
return a.hours * 60 + a.minutes - (b.hours * 60 + b.minutes);
}

public comparator(): Comparator {
return (a: Task, b: Task) => {
return this.compare(a.duration, b.duration);
};
}

public supportsGrouping(): boolean {
return true;
}

public grouper(): GrouperFunction {
return (task: Task) => {
const duration = task.duration;
if (!duration || duration === Duration.None) {
return ['No ' + this.fieldName()];
}
return [duration.toText()];
};
}

private checkForUnexpandedTemplateText(line: string): null | string {
return new TemplatingPluginTools().findUnexpandedDateText(line);
}
}
2 changes: 2 additions & 0 deletions src/Query/FilterParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HeadingField } from './Filter/HeadingField';
import { PathField } from './Filter/PathField';
import { PriorityField } from './Filter/PriorityField';
import { ScheduledDateField } from './Filter/ScheduledDateField';
import { DurationField } from './Filter/DurationField';
import { StartDateField } from './Filter/StartDateField';
import { HappensDateField } from './Filter/HappensDateField';
import { RecurringField } from './Filter/RecurringField';
Expand Down Expand Up @@ -50,6 +51,7 @@ export const fieldCreators: EndsWith<BooleanField> = [
() => new CreatedDateField(),
() => new StartDateField(),
() => new ScheduledDateField(),
() => new DurationField(),
() => new DueDateField(),
() => new DoneDateField(),
() => new PathField(),
Expand Down
14 changes: 8 additions & 6 deletions src/Renderer/Renderer.scss
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
:root {
--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>");

}

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

.plugin-tasks-query-explanation{
.plugin-tasks-query-explanation {
/* Prevent long explanation lines wrapping, so they are more readable,
especially on small screens.

Expand Down Expand Up @@ -53,6 +52,7 @@ ul.contains-task-list .task-list-item-checkbox {
.task-done,
.task-due,
.task-scheduled,
.task-duration,
.task-start {
cursor: pointer;
user-select: none;
Expand All @@ -61,11 +61,12 @@ ul.contains-task-list .task-list-item-checkbox {
}

/* Edit and postpone */
.tasks-edit, .tasks-postpone {
.tasks-edit,
.tasks-postpone {
width: 1em;
height: 1em;
vertical-align: middle;
margin-left: .33em;
margin-left: 0.33em;
cursor: pointer;
font-family: var(--font-interface);
color: var(--text-accent);
Expand All @@ -74,7 +75,8 @@ ul.contains-task-list .task-list-item-checkbox {
-webkit-touch-callout: none;
}

a.tasks-edit, a.tasks-postpone {
a.tasks-edit,
a.tasks-postpone {
text-decoration: none;
}

Expand Down Expand Up @@ -124,6 +126,6 @@ a.tasks-edit, a.tasks-postpone {

/* Workaround for issue #2073: Enabling the plugin causes blockIds to be not hidden in reading view
https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2073 */
.task-list-item .task-block-link{
.task-list-item .task-block-link {
display: none;
}
5 changes: 4 additions & 1 deletion src/Renderer/TaskFieldRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class TaskFieldHTMLData {
// TS2345: Argument of type 'string[] | Moment' is not assignable to parameter of type 'Moment'.
// Type 'string[]' is missing the following properties from type 'Moment': format, startOf, endOf, add, and 78 more.
if (!Array.isArray(date) && date instanceof window.moment) {
const attributeValue = dateToAttribute(date);
const attributeValue = dateToAttribute(date as Moment);
if (attributeValue) {
return attributeValue;
}
Expand Down Expand Up @@ -183,6 +183,9 @@ const taskFieldHTMLData: { [c in TaskLayoutComponent]: TaskFieldHTMLData } = {
return PriorityTools.priorityNameUsingNormal(task.priority).toLocaleLowerCase();
}),

duration: new TaskFieldHTMLData('task-duration', 'taskDuration', (_component, task) => {
return task.duration.toText();
}),
description: createFieldWithoutDataAttributes('task-description'),
recurrenceRule: createFieldWithoutDataAttributes('task-recurring'),
onCompletion: createFieldWithoutDataAttributes('task-onCompletion'),
Expand Down
9 changes: 9 additions & 0 deletions src/Renderer/TaskLineRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { TaskLayoutComponent, type TaskLayoutOptions } from '../Layout/TaskLayou
import { replaceTaskWithTasks } from '../Obsidian/File';
import { StatusRegistry } from '../Statuses/StatusRegistry';
import { Task } from '../Task/Task';
import { Duration } from '../Task/Duration';
import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';
import { StatusMenu } from '../ui/Menus/StatusMenu';
import type { AllTaskDateFields } from '../DateTime/DateFieldTypes';
Expand Down Expand Up @@ -225,6 +226,10 @@ export class TaskLineRenderer {
this.queryLayoutOptions.shortMode,
component,
);
// skip empty duration
if (component == TaskLayoutComponent.Duration && task.duration === Duration.None) {
continue;
}
if (componentString) {
// Create the text span that will hold the rendered component
const span = createAndAppendElement('span', parentElement);
Expand Down Expand Up @@ -281,6 +286,10 @@ export class TaskLineRenderer {
if (li.dataset.taskPriority === undefined) {
fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Priority);
}
// Same logic for duration of 0h0m. It will not be rendered, but data-attribute should be present.
if (li.dataset.taskDuration === undefined) {
fieldRenderer.addDataAttribute(li, task, TaskLayoutComponent.Duration);
}
}

/*
Expand Down
Loading