diff --git a/src/Query/Query.ts b/src/Query/Query.ts index 9d519476fb..e4ff014e13 100644 --- a/src/Query/Query.ts +++ b/src/Query/Query.ts @@ -31,7 +31,7 @@ export class Query implements IQuery { private _ignoreGlobalQuery: boolean = false; private readonly hideOptionsRegexp = - /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|urgency|tags)/i; + /^(hide|show) (task count|backlink|priority|created date|start date|scheduled date|done date|due date|recurrence rule|edit button|postpone button|urgency|tags)/i; private readonly shortModeRegexp = /^short/i; private readonly explainQueryRegexp = /^explain/i; private readonly ignoreGlobalQueryRegexp = /^ignore global query/i; @@ -295,6 +295,9 @@ Problem line: "${line}"`; case 'backlink': this._layoutOptions.hideBacklinks = hide; break; + case 'postpone button': + this._layoutOptions.hidePostponeButton = hide; + break; case 'priority': this._layoutOptions.hidePriority = hide; break; diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts index ed2bec8dcf..afc334952b 100644 --- a/src/QueryRenderer.ts +++ b/src/QueryRenderer.ts @@ -1,5 +1,6 @@ import type { EventRef, MarkdownPostProcessorContext } from 'obsidian'; -import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Plugin, TFile } from 'obsidian'; +import { App, Keymap, MarkdownRenderChild, MarkdownRenderer, Menu, MenuItem, Notice, Plugin, TFile } from 'obsidian'; +import type { unitOfTime } from 'moment'; import { State } from './Cache'; import { GlobalFilter } from './Config/GlobalFilter'; import { GlobalQuery } from './Config/GlobalQuery'; @@ -11,11 +12,12 @@ import { explainResults, getQueryForQueryRenderer } from './lib/QueryRendererHel import type { GroupDisplayHeading } from './Query/GroupDisplayHeading'; import type { QueryResult } from './Query/QueryResult'; import type { TaskGroups } from './Query/TaskGroups'; -import type { Task } from './Task'; +import { Task } from './Task'; import { TaskLayout } from './TaskLayout'; import { TaskLineRenderer } from './TaskLineRenderer'; import { TaskModal } from './TaskModal'; import type { TasksEvents } from './TasksEvents'; +import { TasksDate } from './Scripting/TasksDate'; export class QueryRenderer { private readonly app: App; @@ -232,8 +234,14 @@ class QueryRenderChild extends MarkdownRenderChild { const footnotes = listItem.querySelectorAll('[data-footnote-id]'); footnotes.forEach((footnote) => footnote.remove()); + const shortMode = this.query.layoutOptions.shortMode; + const extrasSpan = listItem.createSpan('task-extras'); + if (!this.query.layoutOptions.hidePostponeButton) { + this.addPostponeButton(extrasSpan, task, shortMode); + } + if (!this.query.layoutOptions.hideUrgency) { this.addUrgency(extrasSpan, task); } @@ -247,6 +255,13 @@ class QueryRenderChild extends MarkdownRenderChild { this.addEditButton(extrasSpan, task); } + // NEW + // if (!this.query.layoutOptions.hideSnoozeButton) { + // this.addUnSnoozeButton(extrasSpan, task, shortMode); + // this.addSnoozeButton1Day(extrasSpan, task, shortMode); + // this.addSnoozeButton3Days(extrasSpan, task, shortMode); + // } + taskList.appendChild(listItem); } @@ -383,6 +398,45 @@ class QueryRenderChild extends MarkdownRenderChild { } } + private addPostponeButton(listItem: HTMLElement, task: Task, shortMode: boolean) { + const button = listItem.createEl('button', { + attr: { + id: 'postpone-button', + title: 'ℹ️ Postpone the task (right-click for more options)', + }, + }); + + const classNames = shortMode ? ['internal-button', 'internal-button-short-mode'] : ['internal-button']; + button.addClasses(classNames); + const buttonText = shortMode ? ' ⏩' : ' ⏩ Postpone'; + button.setText(buttonText); + + button.addEventListener('click', () => this.getOnClickCallback(task, button, 'days')); + + /** Open a context menu on right-click. + * Give a choice of postponing for a week, month, or quarter. + */ + button.addEventListener('contextmenu', async (ev: MouseEvent) => { + const menu = new Menu(); + const commonTitle = 'Postpone for'; + + const getMenuItemCallback = (item: MenuItem, timeUnit: unitOfTime.DurationConstructor, amount = 1) => { + const amountOrArticle = amount > 1 ? amount : 'a'; + item.setTitle(`${commonTitle} ${amountOrArticle} ${timeUnit}`).onClick(() => + this.getOnClickCallback(task, button, timeUnit, amount), + ); + }; + + menu.addItem((item) => getMenuItemCallback(item, 'days', 2)); + menu.addItem((item) => getMenuItemCallback(item, 'days', 3)); + menu.addItem((item) => getMenuItemCallback(item, 'week')); + menu.addItem((item) => getMenuItemCallback(item, 'weeks', 2)); + menu.addItem((item) => getMenuItemCallback(item, 'month')); + + menu.showAtPosition({ x: ev.clientX, y: ev.clientY }); + }); + } + private addTaskCount(content: HTMLDivElement, queryResult: QueryResult) { if (!this.query.layoutOptions.hideTaskCount) { content.createDiv({ @@ -417,4 +471,41 @@ class QueryRenderChild extends MarkdownRenderChild { } return groupingRules.join(','); } + + private async getOnClickCallback( + task: Task, + button: HTMLButtonElement, + timeUnit: unitOfTime.DurationConstructor = 'days', + amount = 1, + ) { + const errorMessage = '⚠️ Postponement requires a date: due or scheduled.'; + if (!task.dueDate && !task.scheduledDate) return new Notice(errorMessage, 10000); + const scheduledDateOrNull = task.scheduledDate ? 'scheduledDate' : null; + const dateTypeToUpdate = task.dueDate ? 'dueDate' : scheduledDateOrNull; + if (dateTypeToUpdate === null) return; + + const dateToUpdate = task[dateTypeToUpdate]; + const postponedDate = new TasksDate(dateToUpdate).postpone(timeUnit, amount); + const newTasks = new Task({ ...task, [dateTypeToUpdate]: postponedDate }); + + await replaceTaskWithTasks({ + originalTask: task, + newTasks, + }); + + const postponedDateString = postponedDate?.format('DD MMM YYYY'); + this.onPostponeSuccessCallback(button, dateTypeToUpdate, postponedDateString); + } + + private onPostponeSuccessCallback( + button: HTMLButtonElement, + updatedDateType: 'dueDate' | 'scheduledDate', + postponedDateString: string, + ) { + // Disable the button to prevent update error due to the task not being reloaded yet. + button.disabled = true; + button.setAttr('title', 'You can perform this action again after reloading the file.'); + new Notice(`Task's ${updatedDateType} postponed untill ${postponedDateString}`, 5000); + this.events.triggerRequestCacheUpdate(this.render.bind(this)); + } } diff --git a/src/Scripting/TasksDate.ts b/src/Scripting/TasksDate.ts index a5df6316b9..fbb7ecc6e3 100644 --- a/src/Scripting/TasksDate.ts +++ b/src/Scripting/TasksDate.ts @@ -1,4 +1,5 @@ -import type { DurationInputArg2, Moment } from 'moment'; +import type { DurationInputArg2, Moment, unitOfTime } from 'moment'; +import { Notice } from 'obsidian'; import { TaskRegularExpressions } from '../Task'; import { PropertyCategory } from '../lib/PropertyCategory'; @@ -109,4 +110,18 @@ export class TasksDate { const unit = words[1] as DurationInputArg2; // day, days, weeks, month, year return earlier ? now.subtract(multiplier, unit) : now.add(multiplier, unit); } + + public postpone(unitOfTime: unitOfTime.DurationConstructor = 'days', amount: number = 1) { + if (!this._date) throw new Notice('Cannot postpone a null date'); + + const today = window.moment().startOf('day'); + // According to the moment.js docs, isBefore is not stable so we use !isSameOrAfter: https://momentjs.com/docs/#/query/is-before/ + const isDateBeforeToday = !this._date.isSameOrAfter(today, 'day'); + + if (isDateBeforeToday) { + return today.add(amount, unitOfTime); + } + + return this._date.add(amount, unitOfTime); + } } diff --git a/src/TaskLayout.ts b/src/TaskLayout.ts index 322ce396af..b840ff8fc9 100644 --- a/src/TaskLayout.ts +++ b/src/TaskLayout.ts @@ -3,6 +3,7 @@ * See applyOptions below when adding options here. */ export class LayoutOptions { + hidePostponeButton: boolean = false; hideTaskCount: boolean = false; hideBacklinks: boolean = false; hidePriority: boolean = false; @@ -94,6 +95,7 @@ export class TaskLayout { [this.options.hideUrgency, 'urgency'], [this.options.hideBacklinks, 'backlinks'], [this.options.hideEditButton, 'edit-button'], + [this.options.hidePostponeButton, 'postpone-button'], ]; for (const [hide, component] of componentsToGenerateClassesOnly) { this.generateHiddenClassForTaskList(hide, component); diff --git a/styles.css b/styles.css index e7a1301b8d..bcd19b5949 100644 --- a/styles.css +++ b/styles.css @@ -72,6 +72,15 @@ text-decoration: none; } +.internal-button { + background-color: transparent; + padding: 0; + font-size: var(--font-text-size); + background-color: transparent; + display: contents; + cursor: pointer; +} + .tasks-list-text { position: relative; } diff --git a/tests/TaskLayout.test.ts b/tests/TaskLayout.test.ts index 2bab99f3ba..0cef973e2b 100644 --- a/tests/TaskLayout.test.ts +++ b/tests/TaskLayout.test.ts @@ -57,6 +57,7 @@ describe('TaskLayout tests', () => { tasks-layout-hide-tags tasks-layout-hide-backlinks tasks-layout-hide-edit-button + tasks-layout-hide-postpone-button tasks-layout-short-mode" `); });