From 8cdf4f938b22c1318f1962b03ff022613e49b616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Pluta?= Date: Thu, 5 Jun 2025 14:30:09 +0200 Subject: [PATCH] Add support for completing recurrence tasks --- package-lock.json | 26 ++ package.json | 2 + package.nix | 2 +- src/backend/ntfy.ts | 2 +- src/loader/index.ts | 19 +- src/model/index.ts | 2 +- src/model/task.ts | 171 ++++++++-- src/services/recurrence.service.ts | 498 +++++++++++++++++++++++++++++ src/services/task.service.ts | 59 +++- src/types/config.ts | 3 + 10 files changed, 730 insertions(+), 54 deletions(-) create mode 100644 src/services/recurrence.service.ts diff --git a/package-lock.json b/package-lock.json index 3df5708..a7b4a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "cron": "^4.3.1", "dayjs": "^1.11.13", "express": "^5.1.0", + "moment": "^2.30.1", "peggy": "^4.2.0", + "rrule": "^2.8.1", "yaml": "^2.7.0" }, "bin": { @@ -1403,6 +1405,15 @@ "node": ">= 0.6" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1688,6 +1699,15 @@ "node": ">= 18" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -1943,6 +1963,12 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", diff --git a/package.json b/package.json index 7863462..58c278a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "cron": "^4.3.1", "dayjs": "^1.11.13", "express": "^5.1.0", + "moment": "^2.30.1", "peggy": "^4.2.0", + "rrule": "^2.8.1", "yaml": "^2.7.0" } } diff --git a/package.nix b/package.nix index afd6698..90931b3 100644 --- a/package.nix +++ b/package.nix @@ -7,5 +7,5 @@ buildNpmPackage { pname = "obsidian-tasks-reminder"; version = "0.0.1"; src = ./.; - npmDepsHash = "sha256-5BsZ4Z7/YjAnOLc6IBU+O0T1p0ncDd7kwzerI5PJYQM="; + npmDepsHash = "sha256-aWFq1EFyz9PpKTaQaCjpxN89Zj1pvRxdku4HX4PEXbM="; } diff --git a/src/backend/ntfy.ts b/src/backend/ntfy.ts index 33f1f3a..f14200b 100644 --- a/src/backend/ntfy.ts +++ b/src/backend/ntfy.ts @@ -46,7 +46,7 @@ export class NtfySH extends Backend { const dto = mapper(task); - const actions = task && profileConfig.completion?.enable && config.server?.baseUrl && !task.recurrenceRule + const actions = task && profileConfig.completion?.enable && config.server?.baseUrl ? [buildCompleteAction(task, profileConfig.name, config.server.baseUrl, backendConfig?.completion?.completeButton ?? "✅")] : []; diff --git a/src/loader/index.ts b/src/loader/index.ts index c4b9e71..36ef4a8 100644 --- a/src/loader/index.ts +++ b/src/loader/index.ts @@ -1,9 +1,7 @@ import fs from "fs"; import dayjs from "dayjs"; import { createInterface } from "readline"; -import { parse } from "../generated/grammar/task"; -import { Task as DefaultTask } from "../model"; -import { ParseResult } from "../types/grammar"; +import { DynamicTask } from "../model"; import { Task, TaskPriority } from "../types/task"; import { jsMapper } from "../util/code"; @@ -75,7 +73,7 @@ async function readTasksFromFile(path: string): Promise { for await (const line of lines) { try { - const task = parseTask(line, path, lineNumber); + const task = DynamicTask.parse(line, path, lineNumber); if(task) { list.push(task); @@ -96,17 +94,4 @@ async function readTasksFromFile(path: string): Promise { return list; } -/** - * Converts line to task model. - * If the line does not represent task, returns undefined. - */ -function parseTask(line: string, path: string, lineNumber: number): Task|undefined { - const item = parse(line) as ParseResult; - - if (item.type === 'task') { - return new DefaultTask(path, line, lineNumber, item.data); - } - - return undefined; -} diff --git a/src/model/index.ts b/src/model/index.ts index 9ad2e49..bd2c833 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -1 +1 @@ -export {LazyTask as Task} from "./task"; \ No newline at end of file +export { DynamicTask } from "./task"; \ No newline at end of file diff --git a/src/model/task.ts b/src/model/task.ts index 2f34cd0..c1fa1a7 100644 --- a/src/model/task.ts +++ b/src/model/task.ts @@ -1,29 +1,32 @@ import dayjs, { Dayjs } from "dayjs"; -import { ParsedTask, ParsedTaskDate, ParsedTaskDependency, ParsedTaskMeta } from "../types/grammar"; +import { ParsedTask, ParsedTaskDate, ParsedTaskDependency, ParsedTaskMeta, ParsedTaskPriority, ParseResult } from "../types/grammar"; import { Task, TaskPriority } from "../types/task"; +import { parse } from "../generated/grammar/task"; -export class LazyTask implements Task { - #parsed: ParsedTask; - #dates: ParsedTaskDate[]; - source: string; - sourceFile: string; - sourceLine: number; + +export class DynamicTask implements Task { + protected parsed: ParsedTask; + #source: string; + #sourceFile: string; + #sourceLine: number; - constructor(path: string, line: string, lineNumber: number, task: ParsedTask) { - this.#parsed = task; - this.#dates = task.meta.filter(x => x.feature === 'date'); - this.source = line; - this.sourceFile = path; - this.sourceLine = lineNumber; + this.parsed = task; + this.#source = line; + this.#sourceFile = path; + this.#sourceLine = lineNumber; + } + + get #dates(): ParsedTaskDate[] { + return this.parsed.meta.filter(x => x.feature === 'date') } get status(): string { - return this.#parsed.status; + return this.parsed.status; } get label(): string { - return this.#parsed.label + return this.parsed.label .filter(x => x.span === 'word' || x.span === 'whitespace') .map(x => x.value) .join("") @@ -31,20 +34,20 @@ export class LazyTask implements Task { } get fullLabel(): string { - return this.#parsed.label + return this.parsed.label .map(x => x.span === 'tag' ? `#${x.value}` : x.value) .join("") .trim(); } get tags(): string[] { - return this.#parsed.label + return this.parsed.label .filter(x => x.span === 'tag') .map(x => x.value); } get priorityStr(): string { - const priority = this.#parsed.meta.find(x => x.feature === 'priority')?.priority; + const priority = this.parsed.meta.find(x => x.feature === 'priority')?.priority; if(!priority) { return "normal"; @@ -60,7 +63,7 @@ export class LazyTask implements Task { } get priority(): TaskPriority { - const priority = this.#parsed.meta.find(x => x.feature === 'priority')?.priority; + const priority = this.parsed.meta.find(x => x.feature === 'priority')?.priority; if(!priority) { return TaskPriority.NORMAL; @@ -106,11 +109,11 @@ export class LazyTask implements Task { } get recurrenceRule(): string|undefined { - return this.#parsed.meta.find(x => x.feature === 'recurrence')?.rule; + return this.parsed.meta.find(x => x.feature === 'recurrence')?.rule; } get onDelete(): string|undefined { - return this.#parsed.meta.find(x => x.feature === 'delete')?.action; + return this.parsed.meta.find(x => x.feature === 'delete')?.action; } get id(): string|undefined { @@ -118,7 +121,7 @@ export class LazyTask implements Task { } #features (feature: ParsedTask['meta'][0]['feature']): T[] { - return this.#parsed.meta.filter(x => x.feature === feature) as T[] + return this.parsed.meta.filter(x => x.feature === feature) as T[] } get dependsOn(): string[] { @@ -126,7 +129,7 @@ export class LazyTask implements Task { } get reminder(): string|undefined { - const feature = this.#parsed.meta.find(x => x.feature === 'reminder'); + const feature = this.parsed.meta.find(x => x.feature === 'reminder'); if (feature) { return feature.time || "default"; @@ -135,6 +138,62 @@ export class LazyTask implements Task { return undefined; } + get sourceFile(): string { + return this.#sourceFile; + } + + get sourceLine(): number { + return this.#sourceLine; + } + + get source(): string { + return this.#source; + } + + set status(value: string) { + this.parsed.status = value; + } + + set startDate(value: Dayjs|undefined) { + this.#setDate("🛫", value); + } + + set scheduledDate(value: Dayjs|undefined) { + this.#setDate("⏳", value); + } + + set dueDate(value: Dayjs|undefined) { + this.#setDate("📅", value); + } + + set completedDate(value: Dayjs|undefined) { + this.#setDate("✅", value); + } + + set cancelledDate(value: Dayjs|undefined) { + this.#setDate("❌", value); + } + + set createdDate(value: Dayjs|undefined) { + this.#setDate("➕", value); + } + + #setDate(type: ParsedTaskDate['type'], date: Dayjs|undefined) { + const index = this.parsed.meta.findIndex(m => m.feature === 'date' && m.type === type); + + if (date === undefined && index > -1) { + this.parsed.meta.splice(index, 1); + } + + else if (date !== undefined && index === -1) { + this.parsed.meta.push({ feature: 'date', type, date: date.format("YYYY-MM-DD") }); + } + + else if (date !== undefined && index > -1) { + (this.parsed.meta[index] as ParsedTaskDate).date = date.format("YYYY-MM-DD"); + } + } + toString(): string { const o = (name: string, value?: string) => value && value.length > 0 ? `${name}=${value}` : ""; @@ -149,7 +208,7 @@ export class LazyTask implements Task { o("recurrence", this.recurrenceRule), o("delete", this.onDelete), o("id", this.id), - o("deps", this.dependsOn.join(",")), + o("deps", this.dependsOn?.join(",")), o("reminder", this.reminder), o("tags", this.tags.join(",")) ]; @@ -181,4 +240,66 @@ export class LazyTask implements Task { sourceFile: this.sourceFile }; } -} \ No newline at end of file + + toMarkdown(): string { + const segments = []; + + for (const meta of this.parsed.meta) { + switch (meta.feature) { + case 'date': + segments.push([meta.type, meta.date]); + break; + + case 'priority': + segments.push([meta.priority]); + break; + + case 'reminder': + segments.push(['⏰', meta.time]); + break; + + case 'recurrence': + segments.push(['🔁', meta.rule]); + break; + + case 'dependency': + segments.push([meta.type, meta.deps.join(",")]); + break; + + case 'delete': + segments.push(['🏁', meta.action]); + break; + } + } + + const meta = segments + .filter(x => x.length > 0) + .filter(x => x.length === 1 || x[1] !== undefined) + .map(([feature, value]) => value === undefined ? feature : `${feature} ${value}`) + .join(" "); + + return `- [${this.status}] ${this.fullLabel}${meta.length > 0 ? ` ${meta}` : ''}`; + } + + + static parse(line: string, path: string, lineNumber: number): DynamicTask|undefined { + const item = parse(line) as ParseResult; + + if (item.type === 'line') { + return undefined; + } + + return new DynamicTask(path, line, lineNumber, item.data); + } + + static copy(task: Task): DynamicTask { + const item = parse(task.source) as ParseResult; + + if (item.type === 'line') { + throw new Error(`Expected task, got line: ${task.source}`); + } + + return new DynamicTask(task.sourceFile, task.source, task.sourceLine, item.data); + } +} + diff --git a/src/services/recurrence.service.ts b/src/services/recurrence.service.ts new file mode 100644 index 0000000..337a44e --- /dev/null +++ b/src/services/recurrence.service.ts @@ -0,0 +1,498 @@ +/** + * This file comes in mostly unchanged form from Obsidian Tasks plugin: + * https://github.com/obsidian-tasks-group/obsidian-tasks + * and is supposed to calculate the recurrence of the tasks in the same way as the original plugin. + */ +import moment, { type Moment } from "moment"; +import { RRule } from "rrule"; +import { Task } from "../types/task"; +import dayjs, { Dayjs } from "dayjs"; + +export function nextOccurence(task: Task, removeScheduledDate: boolean = false, today?: Moment): Task|undefined { + if (!task.recurrenceRule) { + return undefined; + } + + const occurrence = new Occurrence({ + startDate: toMoment(task.startDate), + scheduledDate: toMoment(task.scheduledDate), + dueDate: toMoment(task.dueDate) + }); + + const recurrence = Recurrence.fromText({ + recurrenceRuleText: task.recurrenceRule, + occurrence + }); + + if (!recurrence) { + return undefined; + } + + const next = recurrence.next(today, removeScheduledDate); + + if (!next) { + return undefined; + } + + return { + ...task, + scheduledDate: toDayjs(next.scheduledDate), + dueDate: toDayjs(next.dueDate), + startDate: toDayjs(next.startDate) + } +} + +function toMoment(date?: Dayjs): Moment|null { + return date ? moment(date.format("YYYY-MM-DD")) : null; +} + +function toDayjs(date: Moment|null): Dayjs|undefined { + return date ? dayjs(date.format("YYYY-MM-DD")) : undefined; +} + +// Source: https://github.com/obsidian-tasks-group/obsidian-tasks/blob/a29e7f900193571cee1b0982be21978784512fc5/src/DateTime/DateTools.ts +function compareByDate(a: Moment | null, b: Moment | null): -1 | 0 | 1 { + if (a !== null && b === null) { + return -1; + } + if (a === null && b !== null) { + return 1; + } + if (!(a !== null && b !== null)) { + return 0; + } + + if (a.isValid() && !b.isValid()) { + return 1; + } else if (!a.isValid() && b.isValid()) { + return -1; + } + + if (a.isAfter(b)) { + return 1; + } else if (a.isBefore(b)) { + return -1; + } else { + return 0; + } +} + +// Source: https://github.com/obsidian-tasks-group/obsidian-tasks/blob/a29e7f900193571cee1b0982be21978784512fc5/src/Task/Occurrence.ts +/** + * A set of dates on a single instance of {@link Recurrence}. + * + * It is responsible for calculating the set of dates for the next occurrence. + */ +class Occurrence { + public readonly startDate: Moment | null; + public readonly scheduledDate: Moment | null; + public readonly dueDate: Moment | null; + + /** + * The reference date is used to calculate future occurrences. + * + * Future occurrences will recur based on the reference date. + * The reference date is the due date, if it is given. + * Otherwise the scheduled date, if it is given. And so on. + * + * Recurrence of all dates will be kept relative to the reference date. + * For example: if the due date and the start date are given, the due date + * is the reference date. Future occurrences will have a start date with the + * same relative distance to the due date as the original task. For example + * "starts one week before it is due". + */ + public readonly referenceDate: Moment | null; + + constructor({ + startDate = null, + scheduledDate = null, + dueDate = null, + }: { + startDate?: Moment | null; + scheduledDate?: Moment | null; + dueDate?: Moment | null; + }) { + this.startDate = startDate ?? null; + this.scheduledDate = scheduledDate ?? null; + this.dueDate = dueDate ?? null; + this.referenceDate = this.getReferenceDate(); + } + + /** + * Pick the reference date for occurrence based on importance. + * Assuming due date has the highest priority, then scheduled date, + * then start date. + * + * The Moment objects are cloned. + * + * @private + */ + private getReferenceDate(): Moment | null { + if (this.dueDate) { + return moment(this.dueDate); + } + + if (this.scheduledDate) { + return moment(this.scheduledDate); + } + + if (this.startDate) { + return moment(this.startDate); + } + + return null; + } + + public isIdenticalTo(other: Occurrence): boolean { + // Compare Date fields + if (compareByDate(this.startDate, other.startDate) !== 0) { + return false; + } + if (compareByDate(this.scheduledDate, other.scheduledDate) !== 0) { + return false; + } + if (compareByDate(this.dueDate, other.dueDate) !== 0) { + return false; + } + + return true; + } + + /** + * Provides an {@link Occurrence} with the dates calculated relative to a new reference date. + * + * If the occurrence has no reference date, an empty {@link Occurrence} will be returned. + * + * @param nextReferenceDate + * @param removeScheduledDate - Optional boolean to remove the scheduled date from the next occurrence so long as a start or due date exists. + */ + public next(nextReferenceDate: Date, removeScheduledDate: boolean = false): Occurrence { + // Only if a reference date is given. A reference date will exist if at + // least one of the other dates is set. + if (this.referenceDate === null) { + return new Occurrence({ + startDate: null, + scheduledDate: null, + dueDate: null, + }); + } + + const hasStartDate = this.startDate !== null; + const hasDueDate = this.dueDate !== null; + const canRemoveScheduledDate = hasStartDate || hasDueDate; + const shouldRemoveScheduledDate = removeScheduledDate && canRemoveScheduledDate; + + const startDate = this.nextOccurrenceDate(this.startDate, nextReferenceDate); + const scheduledDate = shouldRemoveScheduledDate + ? null + : this.nextOccurrenceDate(this.scheduledDate, nextReferenceDate); + const dueDate = this.nextOccurrenceDate(this.dueDate, nextReferenceDate); + + return new Occurrence({ + startDate, + scheduledDate, + dueDate, + }); + } + + /** + * Gets next occurrence (start/scheduled/due date) keeping the relative distance + * with the reference date + * + * @param nextReferenceDate + * @param currentOccurrenceDate start/scheduled/due date + * @private + */ + private nextOccurrenceDate(currentOccurrenceDate: Moment | null, nextReferenceDate: Date): Moment | null { + if (currentOccurrenceDate === null) { + return null; + } + const originalDifference = moment.duration(currentOccurrenceDate.diff(this.referenceDate)); + + // Cloning so that original won't be manipulated: + const nextOccurrence = moment(nextReferenceDate); + // Rounding days to handle cross daylight-savings-time recurrences. + nextOccurrence.add(Math.round(originalDifference.asDays()), 'days'); + return nextOccurrence; + } +} + +// Source: https://github.com/obsidian-tasks-group/obsidian-tasks/blob/a29e7f900193571cee1b0982be21978784512fc5/src/Task/Recurrence.ts +class Recurrence { + private readonly rrule: RRule; + private readonly baseOnToday: boolean; + readonly occurrence: Occurrence; + + constructor({ rrule, baseOnToday, occurrence }: { rrule: RRule; baseOnToday: boolean; occurrence: Occurrence }) { + this.rrule = rrule; + this.baseOnToday = baseOnToday; + this.occurrence = occurrence; + } + + public static fromText({ + recurrenceRuleText, + occurrence, + }: { + recurrenceRuleText: string; + occurrence: Occurrence; + }): Recurrence | null { + try { + const match = recurrenceRuleText.match(/^([a-zA-Z0-9, !]+?)( when done)?$/i); + if (match == null) { + return null; + } + + const isolatedRuleText = match[1].trim(); + const baseOnToday = match[2] !== undefined; + + const options = RRule.parseText(isolatedRuleText); + if (options !== null) { + const referenceDate = occurrence.referenceDate; + + if (!baseOnToday && referenceDate !== null) { + options.dtstart = moment(referenceDate).startOf('day').utc(true).toDate(); + } else { + options.dtstart = moment().startOf('day').utc(true).toDate(); + } + + const rrule = new RRule(options); + return new Recurrence({ + rrule, + baseOnToday, + occurrence, + }); + } + } catch (e) { + // Could not read recurrence rule. User possibly not done typing. + // Print error message, as it is useful if a test file has not set up moment + if (e instanceof Error) { + console.log(e.message); + } + } + + return null; + } + + public toText(): string { + let text = this.rrule.toText(); + if (this.baseOnToday) { + text += ' when done'; + } + + return text; + } + + /** + * Returns the dates of the next occurrence or null if there is no next occurrence. + * + * @param today - Optional date representing the completion date. Defaults to today. + * @param removeScheduledDate - Optional boolean to remove the scheduled date from the next occurrence so long as a start or due date exists. + */ + public next(today = moment(), removeScheduledDate: boolean = false): Occurrence | null { + const nextReferenceDate = this.nextReferenceDate(today); + + if (nextReferenceDate === null) { + return null; + } + + return this.occurrence.next(nextReferenceDate, removeScheduledDate); + } + + public identicalTo(other: Recurrence) { + if (this.baseOnToday !== other.baseOnToday) { + return false; + } + + if (!this.occurrence.isIdenticalTo(other.occurrence)) { + return false; + } + + return this.toText() === other.toText(); // this also checks baseOnToday + } + + private nextReferenceDate(today: Moment): Date { + if (this.baseOnToday) { + // The next occurrence should happen based off the current date. + return this.nextReferenceDateFromToday(today.clone()).toDate(); + } else { + return this.nextReferenceDateFromOriginalReferenceDate().toDate(); + } + } + + private nextReferenceDateFromToday(today: Moment): Moment { + const ruleBasedOnToday = new RRule({ + ...this.rrule.origOptions, + dtstart: today.startOf('day').utc(true).toDate(), + }); + + return this.nextAfter(today.endOf('day'), ruleBasedOnToday); + } + + private nextReferenceDateFromOriginalReferenceDate(): Moment { + // The next occurrence should happen based on the original reference + // date if possible. Otherwise, base it on today if we do not have a + // reference date. + + // Reference date can be `undefined` to mean "today". + // Moment only accepts `undefined`, not `null`. + const after = moment(this.occurrence.referenceDate ?? undefined).endOf('day'); + + return this.nextAfter(after, this.rrule); + } + + /** + * nextAfter returns the next occurrence's date after `after`, based on the given rrule. + * + * The common case is that `rrule.after` calculates the next date and it + * can be used as is. + * + * In the special cases of monthly and yearly recurrences, there exists an + * edge case where an occurrence after the given number of months or years + * is not possible. For example: A task is due on 2022-01-31 and has a + * recurrence of `every month`. When marking the task as done, the next + * occurrence will happen on 2022-03-31. The reason being that February + * does not have 31 days, yet RRule sets `bymonthday` to `31` for lack of + * having a better alternative. + * + * In order to fix this, `after` will move into the past day by day. Each + * day, the next occurrence is checked to be after the given number of + * months or years. By moving `after` into the past day by day, it will + * eventually calculate the next occurrence based on `2022-01-28`, ending up + * in February as the user would expect. + */ + private nextAfter(after: Moment, rrule: RRule): Moment { + // We need to remove the timezone, as rrule does not regard timezones and always + // calculates in UTC. + // The timezone is added again before returning the next date. + after.utc(true); + let next = moment.utc(rrule.after(after.toDate())); + + // If this is a monthly recurrence, treat it special. + const asText = this.toText(); + const monthMatch = asText.match(/every( \d+)? month(s)?(.*)?/); + if (monthMatch !== null) { + // ... unless the rule fixes the date, such as 'every month on the 31st' + if (!asText.includes(' on ')) { + next = Recurrence.nextAfterMonths(after, next, rrule, monthMatch[1]); + } + } + + // If this is a yearly recurrence, treat it special. + const yearMatch = asText.match(/every( \d+)? year(s)?(.*)?/); + if (yearMatch !== null) { + next = Recurrence.nextAfterYears(after, next, rrule, yearMatch[1]); + } + + // Here we add the timezone again that we removed in the beginning of this method. + return Recurrence.addTimezone(next); + } + + /** + * nextAfterMonths calculates the next date after `skippingMonths` months. + * + * `skippingMonths` defaults to `1` if undefined. + */ + private static nextAfterMonths( + after: Moment, + next: Moment, + rrule: RRule, + skippingMonths: string | undefined, + ): Moment { + // Parse `skippingMonths`, if it exists. + let parsedSkippingMonths: number = 1; + if (skippingMonths !== undefined) { + parsedSkippingMonths = Number.parseInt(skippingMonths.trim(), 10); + } + + // While we skip the wrong number of months, move `after` one day into the past. + while (Recurrence.isSkippingTooManyMonths(after, next, parsedSkippingMonths)) { + // The next line alters `after` to be one day earlier. + // Then returns `next` based on that. + next = Recurrence.fromOneDayEarlier(after, rrule); + } + + return next; + } + + /** + * isSkippingTooManyMonths returns true if `next` is more than `skippingMonths` months after `after`. + */ + private static isSkippingTooManyMonths(after: Moment, next: Moment, skippingMonths: number): boolean { + let diffMonths = next.month() - after.month(); + + // Maybe some years have passed? + const diffYears = next.year() - after.year(); + diffMonths += diffYears * 12; + + return diffMonths > skippingMonths; + } + + /** + * nextAfterYears calculates the next date after `skippingYears` years. + * + * `skippingYears` defaults to `1` if undefined. + */ + private static nextAfterYears( + after: Moment, + next: Moment, + rrule: RRule, + skippingYears: string | undefined, + ): Moment { + // Parse `skippingYears`, if it exists. + let parsedSkippingYears: number = 1; + if (skippingYears !== undefined) { + parsedSkippingYears = Number.parseInt(skippingYears.trim(), 10); + } + + // While we skip the wrong number of years, move `after` one day into the past. + while (Recurrence.isSkippingTooManyYears(after, next, parsedSkippingYears)) { + // The next line alters `after` to be one day earlier. + // Then returns `next` based on that. + next = Recurrence.fromOneDayEarlier(after, rrule); + } + + return next; + } + + /** + * isSkippingTooManyYears returns true if `next` is more than `skippingYears` years after `after`. + */ + private static isSkippingTooManyYears(after: Moment, next: Moment, skippingYears: number): boolean { + const diff = next.year() - after.year(); + + return diff > skippingYears; + } + + /** + * fromOneDayEarlier returns the next occurrence after moving `after` one day into the past. + * + * WARNING: This method manipulates the given instance of `after`. + */ + private static fromOneDayEarlier(after: Moment, rrule: RRule): Moment { + after.subtract(1, 'days').endOf('day'); + + const options = rrule.origOptions; + options.dtstart = after.startOf('day').toDate(); + rrule = new RRule(options); + + return moment.utc(rrule.after(after.toDate())); + } + + private static addTimezone(date: Moment): Moment { + // Moment's local(true) method has a bug where it returns incorrect result if the input is of + // the day of the year when DST kicks in and the time of day is before DST actually kicks in + // (typically between midnight and very early morning, varying across geographies). + // We workaround the bug by setting the time of day to noon before calling local(true) + const localTimeZone = moment + .utc(date) + .set({ + hour: 12, + minute: 0, + second: 0, + millisecond: 0, + }) + .local(true); + + return localTimeZone.startOf('day'); + } +} diff --git a/src/services/task.service.ts b/src/services/task.service.ts index d4c11d7..66d8bae 100644 --- a/src/services/task.service.ts +++ b/src/services/task.service.ts @@ -1,6 +1,8 @@ import fs from "fs"; import { Task } from "../types/task"; -import { Config } from "../types/config"; +import { Config, ProfileConfig } from "../types/config"; +import { nextOccurence } from "./recurrence.service"; +import { DynamicTask } from "../model/task"; import dayjs from "dayjs"; export function complete(config: Config, profile: string, task: Task) { @@ -14,10 +16,6 @@ export function complete(config: Config, profile: string, task: Task) { return; } - if (task.recurrenceRule) { - throw new Error('Recurrence tasks are not supported for now'); - } - const data = fs.readFileSync(task.sourceFile, 'utf8').split(/\n/); const output = []; @@ -26,9 +24,8 @@ export function complete(config: Config, profile: string, task: Task) { if (task.source !== line) { throw new Error(`Cannot complete task, file has been changed since last scan. Rememberred line: ${task.source}, current line: ${line}.`); } - - const taskStr = line.replace(/^-(\s+)\[.\]/, `-$1[${profileConfig.completion?.status ?? 'x'}]`); - output.push(`${taskStr} ✅ ${dayjs().format("YYYY-MM-DD")}`); + + output.push(...completeTask(profileConfig, task)); } else { @@ -37,4 +34,48 @@ export function complete(config: Config, profile: string, task: Task) { } fs.writeFileSync(task.sourceFile, output.join("\n")); -} \ No newline at end of file +} + +function completeTask(profileConfig: ProfileConfig, task: Task): string[] { + if (task.recurrenceRule) { + return completeRecurrenceTask(profileConfig, task); + } + + return completeSimpleTask(profileConfig, task); +} + +function completeRecurrenceTask(profileConfig: ProfileConfig, task: Task): string[] { + const next = nextOccurence(DynamicTask.copy(task), !!profileConfig.completion?.removeScheduled); + + const output = task.onDelete === 'delete' ? [] : completeSimpleTask(profileConfig, task); + + if (next === undefined) { + return output; + } + + const newTask = DynamicTask.copy(task); + + newTask.startDate = next.startDate; + newTask.dueDate = next.dueDate; + newTask.scheduledDate = next.scheduledDate; + + if (profileConfig.completion?.addCreationDate) { + newTask.createdDate = dayjs(); + } + + output.unshift(newTask.toMarkdown()); + + if (profileConfig.completion?.nextOccurenceBelow) { + output.reverse(); + } + + return output; +} + +function completeSimpleTask(profileConfig: ProfileConfig, task: Task): string[] { + const newTask = DynamicTask.copy(task); + newTask.completedDate = dayjs(); + newTask.status = profileConfig.completion?.status ?? "x"; + + return [newTask.toMarkdown()]; +} diff --git a/src/types/config.ts b/src/types/config.ts index 1efbf11..db5b854 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -25,6 +25,9 @@ export type ProfileConfig = { export type CompletionConfig = { enable?: boolean; status?: 'x'; + nextOccurenceBelow?: boolean; + removeScheduled?: boolean; + addCreationDate?: boolean; }; export type BackendSettings = {