Add support for completing recurrence tasks

This commit is contained in:
2025-06-05 14:30:09 +02:00
parent 61e78a85d8
commit fc4cff0429
11 changed files with 731 additions and 55 deletions

26
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -7,5 +7,5 @@ buildNpmPackage {
pname = "obsidian-tasks-reminder";
version = "0.0.1";
src = ./.;
npmDepsHash = "sha256-5BsZ4Z7/YjAnOLc6IBU+O0T1p0ncDd7kwzerI5PJYQM=";
npmDepsHash = "sha256-qSCP2eerP9oQcpLsEj2XE2X6vap8MMTuydzWItlAHuA=";
}

View File

@@ -46,7 +46,7 @@ export class NtfySH extends Backend<NtfyConfig> {
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 ?? "✅")]
: [];

View File

@@ -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<Task[]> {
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<Task[]> {
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;
}

View File

@@ -1 +1 @@
export {LazyTask as Task} from "./task";
export { DynamicTask } from "./task";

View File

@@ -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 <T = ParsedTaskMeta>(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
};
}
}
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);
}
}

View File

@@ -1,4 +1,3 @@
import { Task } from "../model";
import { Config } from "../types/config";
import express from "express";
import { CompleteTaskDTO } from "../types/dto";
@@ -12,6 +11,7 @@ export async function startServer(config: Config) {
const { profile, task } = await req.body as CompleteTaskDTO;
complete(config, profile, task);
console.log(`Completed task [${task.fullLabel}]`);
res.json({ status: "ok" });
})

View File

@@ -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');
}
}

View File

@@ -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"));
}
}
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()];
}

View File

@@ -25,6 +25,9 @@ export type ProfileConfig = {
export type CompletionConfig = {
enable?: boolean;
status?: 'x';
nextOccurenceBelow?: boolean;
removeScheduled?: boolean;
addCreationDate?: boolean;
};
export type BackendSettings = {