Compare commits

...

17 Commits

Author SHA1 Message Date
0060b2be60 Improve handling tags + improve combine bullets in ntfy backend 2025-06-11 13:33:53 +02:00
bff145d27f Add support for multiple reminders 2025-06-09 13:44:52 +02:00
52dd10ac10 Add support for completing recurrence tasks 2025-06-06 13:14:17 +02:00
61e78a85d8 Add support for overriding completion buttons 2025-06-04 22:13:43 +02:00
612285d91c Add support for combined notifications completion 2025-06-04 19:18:04 +02:00
b266cb0b83 Add completion date 2025-06-04 19:02:57 +02:00
421a941262 Restore some CLI options 2025-06-04 18:54:17 +02:00
42d8f0db8b Add basic support for task completion 2025-06-04 18:46:20 +02:00
46f09f2e13 Implement built-in cron worker 2025-06-04 16:51:18 +02:00
9bc036113f Add support for hypgens in task IDs 2025-03-25 22:15:22 +01:00
17fe6b86de Add support for profiles + excluding directories from lookup for tasks 2025-03-08 12:46:22 +01:00
677e68a374 Remove unnecessary console.log statement 2025-03-05 14:21:15 +01:00
c321758101 Add support for combined notifications 2025-03-05 14:16:43 +01:00
0ec71cfc8e Improve mapping tasks to notifications 2025-03-03 15:03:56 +01:00
b1c89efab9 Fix error when no backend specified 2025-02-25 16:41:49 +01:00
1fabb32fb6 Add support for default notification time (icon without specified time) 2025-02-25 15:53:15 +01:00
0b3fda4beb Implement Debug backend which prints the notification to stdout 2025-02-25 15:52:37 +01:00
30 changed files with 2148 additions and 223 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

5
.gitignore vendored
View File

@@ -145,4 +145,7 @@ dist
# End of https://www.toptal.com/developers/gitignore/api/node # End of https://www.toptal.com/developers/gitignore/api/node
src/generated src/generated
*.yaml
*.json
.direnv

View File

@@ -42,7 +42,7 @@ labelWhitespace = whitespace:[ \t\r]+ {
} }
} }
tag = "#" tag:[a-zA-Z0-9-]+ { tag = "#" tag:[a-zA-Z0-9-/]+ {
return { return {
span: "tag", span: "tag",
value: tag.join("") value: tag.join("")
@@ -74,13 +74,15 @@ delete = type:deleteIcon _ action:[a-zA-Z]+ {
/**************************************************************************************************************************************/ /**************************************************************************************************************************************/
reminder = reminderIcon _ time:(longTime / shortTime)? { reminder = reminderIcon _ time:(defaultTime / longTime / shortTime)|..,(_ "," _)| {
return { return {
feature: "reminder", feature: "reminder",
time time
} }
} }
defaultTime = "_"
longTime = hour:[0-9]|1..2| ":" minute:[0-9]|1..2| { longTime = hour:[0-9]|1..2| ":" minute:[0-9]|1..2| {
return `${hour.join("").padStart(2, '0')}:${minute.join("").padStart(2, '0')}`; return `${hour.join("").padStart(2, '0')}:${minute.join("").padStart(2, '0')}`;
} }
@@ -114,7 +116,7 @@ dateLiteral = year:([0-9]|4|) "-" month:([0-9]|1..2|) "-" day:([0-9]|1..2|) {
/**************************************************************************************************************************************/ /**************************************************************************************************************************************/
dependency = type:dependencyIcon _ deps:([a-zA-Z0-9]+)|..,(_ "," _)| { dependency = type:dependencyIcon _ deps:([a-zA-Z0-9-]+)|..,(_ "," _)| {
return { return {
feature: "dependency", feature: "dependency",
type, type,

View File

@@ -28,13 +28,6 @@ in
default = "root"; default = "root";
}; };
scanTimer = mkOption {
type = types.str;
description = "The systemd's timer interval when the app will be performing the scan for new tasks";
example = "*-*-* *:00";
default = "*-*-* *:10";
};
config = mkOption { config = mkOption {
type = types.attrs; type = types.attrs;
description = "The obsidian-tasks-reminder config which will be eventually converted to yaml"; description = "The obsidian-tasks-reminder config which will be eventually converted to yaml";
@@ -56,46 +49,17 @@ in
config = mkIf cfg.enable { config = mkIf cfg.enable {
environment.systemPackages = [app]; environment.systemPackages = [app];
systemd.timers.obsidian-tasks-reminder-scanner = {
description = "Scan for new Obsidian tasks";
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = cfg.scanTimer;
Unit = "obsidian-tasks-reminder-scanner.service";
};
};
systemd.timers.obsidian-tasks-reminder = {
description = "Notify about Obsidian tasks";
wantedBy = ["timers.target"];
timerConfig = {
OnCalendar = "*-*-* *:*:00";
Unit = "obsidian-tasks-reminder.service";
};
};
systemd.services.obsidian-tasks-reminder-scanner = {
description = "Scan for new Obsidian tasks";
serviceConfig = {
Type = "oneshot";
User = cfg.user;
};
script = "${app}/bin/obsidian-tasks-reminder -s";
};
systemd.services.obsidian-tasks-reminder = { systemd.services.obsidian-tasks-reminder = {
description = "Notify about Obsidian tasks"; enable = true;
description = "Obsidian Tasks Notifier";
wantedBy = ["network-online.target"];
after = ["network-online.target"];
serviceConfig = { serviceConfig = {
Type = "oneshot"; ExecStart = "${app}/bin/obsidian-tasks-reminder";
User = cfg.user; User = cfg.user;
}; };
script = "${app}/bin/obsidian-tasks-reminder -n";
}; };
}; };
} }

946
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,14 @@
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"dependencies": { "dependencies": {
"@types/express": "^5.0.2",
"commander": "^13.0.0", "commander": "^13.0.0",
"cron": "^4.3.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^5.1.0",
"moment": "^2.30.1",
"peggy": "^4.2.0", "peggy": "^4.2.0",
"rrule": "^2.8.1",
"yaml": "^2.7.0" "yaml": "^2.7.0"
} }
} }

View File

@@ -7,5 +7,5 @@ buildNpmPackage {
pname = "obsidian-tasks-reminder"; pname = "obsidian-tasks-reminder";
version = "0.0.1"; version = "0.0.1";
src = ./.; src = ./.;
npmDepsHash = "sha256-ofPAFnHbW+M0uQN/OXDLK+PQ3ls6AtD58/AOmSxn754="; npmDepsHash = "sha256-qSCP2eerP9oQcpLsEj2XE2X6vap8MMTuydzWItlAHuA=";
} }

View File

@@ -1,28 +1,42 @@
import { BackendSettings, Config } from "../types/config"; import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { Notification } from "../types/notification"; import { Task } from "../types/task";
export abstract class Backend<C extends BackendSettings> { export abstract class Backend<C extends BackendSettings> {
public abstract readonly name: string; public abstract readonly name: string;
protected abstract requiredFields: readonly (keyof C)[]; protected abstract requiredFields: readonly (keyof C)[];
protected abstract notify(config: C, notification: Notification): void; protected abstract notify(config: Config, profileConfig: ProfileConfig, backendConfig: C, task: Task): void;
#validate(config: Partial<C>): C { #validate(backendConfig: Partial<C>): C {
for (const field of this.requiredFields) { for (const field of this.requiredFields) {
if (config[field] === undefined) { if (backendConfig[field] === undefined) {
throw new Error(`The '${String(field)}' configuration field of'${this.name}' consumer is required`) throw new Error(`The '${String(field)}' configuration field of'${this.name}' consumer is required`)
} }
} }
return config as C; return backendConfig as C;
} }
public remind(config: Config, notification: Notification) { public async remind(config: Config, profileConfig: ProfileConfig, tasks: Task[]): Promise<void> {
const cfg = config.backend[this.name] as Partial<C>; const cfg = profileConfig?.backend?.[this.name] as Partial<C> | undefined;
if (cfg.enable !== true) { if (cfg?.enable !== true) {
return return;
} }
this.notify(this.#validate(cfg), notification); if (tasks.length === 0) {
return;
}
if (tasks.length === 1) {
return await this.notify(config, profileConfig, this.#validate(cfg), tasks[0]);
}
return await this.notifyCombined(config, profileConfig, this.#validate(cfg), tasks);
}
protected async notifyCombined(config: Config, profileConfig: ProfileConfig, backendConfig: C, tasks: Task[]) {
for (const task of tasks) {
this.notify(config, profileConfig, backendConfig, task);
}
} }
} }

13
src/backend/debug.ts Normal file
View File

@@ -0,0 +1,13 @@
import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { Task } from "../types/task";
import { Backend } from "./base";
export class Debug extends Backend<BackendSettings> {
public name = "debug";
protected requiredFields = [] as const;
protected notify(config: Config, profileConfig: ProfileConfig, backendConfig: BackendSettings, task: Task): void {
console.log(JSON.stringify(task, undefined, 2));
}
}

View File

@@ -1,9 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { NotificationDatabase } from "../types/notification"; import { Task, TaskDatabase } from "../types/task";
import { Config } from "../types/config"; import { Config, ProfileConfig } from "../types/config";
import { NtfySH } from "./ntfy"; import { NtfySH } from "./ntfy";
import { Debug } from "./debug";
const backends = [ const backends = [
new Debug(),
new NtfySH() new NtfySH()
]; ];
@@ -11,19 +13,22 @@ const backends = [
* Iterates through all the database notifications for current time * Iterates through all the database notifications for current time
* and triggers the notification using specified backends in the config. * and triggers the notification using specified backends in the config.
*/ */
export async function remind(config: Config, db: NotificationDatabase) { export async function remind(config: Config, profileConfig: ProfileConfig, db: TaskDatabase) {
const now = dayjs().format("HH:mm"); const now = dayjs().format("HH:mm");
const notifications = db[now] ?? [];
for (const notification of notifications) { await run(config, profileConfig, db[now]);
for (const backend of backends) {
console.info(`Dispatching a notification: [${notification.text}]`) if(profileConfig?.defaultTime && profileConfig?.defaultTime === now) {
backend.remind(config, notification); run(config, profileConfig, db._);
await snooze(1500);
}
} }
} }
function snooze(ms: number) { async function run(config: Config, profileConfig: ProfileConfig, tasks?: Task[]) {
return new Promise(resolve => setTimeout(resolve, ms)); if(!tasks) {
return;
}
for (const backend of backends) {
await backend.remind(config, profileConfig, tasks);
}
} }

View File

@@ -1,34 +1,183 @@
import fs from "fs"; import fs from "fs";
import { BackendSettings } from "../types/config"; import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { Notification } from "../types/notification"; import { CompleteTaskDTO } from "../types/dto";
import { Backend } from "./base"; import { Backend } from "./base";
import { TaskPriority } from "../types/task"; import { Task, TaskPriority } from "../types/task";
import { enhancedStringConfig } from "../util/config"; import { enhancedStringConfig } from "../util/config";
import { jsMapper } from "../util/code";
import { profile } from "console";
type Config = { type NtfyConfig = {
url: string; url: string;
token: string; token: string;
map?: string;
combineMap?: string;
topic?: string; topic?: string;
completion?: {
completeButton?: string;
completeButton1?: string;
completeButton2?: string;
completeButton3?: string;
}
} & BackendSettings; } & BackendSettings;
export class NtfySH extends Backend<Config> { type NtfyDTO = {
title?: string;
text?: string;
priority?: string;
tags?: string[];
icon?: string;
};
export class NtfySH extends Backend<NtfyConfig> {
public name = "ntfy"; public name = "ntfy";
protected requiredFields = ['url', 'token'] as const; protected requiredFields = ['url', 'token'] as const;
protected notify(config: Config, notification: Notification): void { protected notify(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, task: Task): void {
const token = enhancedStringConfig(config.token); const context = {
mapPriority,
template: defaultMapper
};
const mapper = backendConfig.map
? jsMapper<object, NtfyDTO>(backendConfig.map, context)
: defaultMapper;
fetch(`https://${config.url}/${config.topic || 'obsidian'}`, { const dto = mapper(task);
const actions = task && profileConfig.completion?.enable && config.server?.baseUrl
? [buildCompleteAction(task, profileConfig.name, config.server.baseUrl, backendConfig?.completion?.completeButton ?? "✅")]
: [];
this.#doNotify(backendConfig, dto, actions);
}
protected async notifyCombined(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, tasks: Task[]): Promise<void> {
const chunks = chunkArray(tasks, 3);
const completionLabels = [
backendConfig?.completion?.completeButton1 ?? "✅1⃣",
backendConfig?.completion?.completeButton2 ?? "✅2⃣",
backendConfig?.completion?.completeButton3 ?? "✅3⃣"
];
const context = {
mapPriority,
template: defaultCombineMapper
};
for (const chunk of chunks) {
const mapper = backendConfig.combineMap
? jsMapper<object, NtfyDTO>(backendConfig.combineMap, context)
: defaultCombineMapper;
const dto = mapper(chunk);
const actions = profileConfig.completion?.enable && config.server?.baseUrl
? chunk.map((t, i) => buildCompleteAction(t, profileConfig.name, config.server!.baseUrl!, completionLabels[i] ?? "✅"))
: [];
await this.#doNotify(backendConfig, dto, actions);
await snooze(2500);
}
}
async #doNotify(backendConfig: NtfyConfig, dto: NtfyDTO, actions: object[] = []): Promise<Response> {
const token = enhancedStringConfig(backendConfig.token);
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
buildHeaders(dto, headers);
return fetch(`https://${backendConfig.url}`, {
method: 'POST', method: 'POST',
body: notification.text, headers,
headers: { body: JSON.stringify({
'Authorization': `Bearer ${token}`, topic: backendConfig.topic || 'obsidian',
'Title': notification.title ?? "", message: dto.text ?? "",
'Priority': mapPriority(notification.priority), actions
'Tags': notification.tags?.join(",") ?? "" })
} })
}) }
}
function buildCompleteAction(task: Task, profile: string, baseUrl: string, label: string): object {
const body: CompleteTaskDTO = {
task,
profile: profile,
};
return {
action: "http",
label,
url: `${baseUrl}/complete`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body)
};
}
function snooze(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks = [];
while (array.length) {
chunks.push(array.slice(0, size));
array = array.slice(size);
}
return chunks;
}
function buildHeaders(dto: NtfyDTO, headers: Record<string, string>) {
if (dto.title) {
headers.Title = dto.title;
}
if (dto.priority) {
headers.Priority = dto.priority;
}
if (dto.tags) {
headers.Tags = dto.tags.join(",");
}
if (dto.icon) {
headers.Icon = dto.icon;
}
}
function defaultMapper(task: Task): NtfyDTO {
return {
title: "Obsidian Task Reminder",
text: task.label,
priority: mapPriority(task.priority),
tags: task.tags,
}
}
function defaultCombineMapper(tasks: Task[], bullet = (index: number) => "-"): NtfyDTO {
const text = tasks
.toSorted((a, b) => b.priority - a.priority)
.map((t, i) => `${bullet(i)} ${t.label}`)
.join("\n");
const priority = mapPriority(Math.max(...tasks.map(t => t.priority)));
const tags = tasks.flatMap(t => t.tags);
return {
title: `Obsidian Task Reminder (${tasks.length})`,
text,
priority,
tags
} }
} }

View File

@@ -3,15 +3,19 @@ import { CLIOptions } from "../types/cli";
import { loadConfig } from "../config"; import { loadConfig } from "../config";
import { notify, scan, test } from "../runner"; import { notify, scan, test } from "../runner";
import { CronJob } from "cron";
import { startServer } from "../server";
import { Config } from "../types/config";
const getOptions = () => program const getOptions = () => program
.name("obsidian-tasks-reminder") .name("obsidian-tasks-reminder")
.version("0.0.1") .version("0.0.1")
.requiredOption("-c, --config <file>", "sets the path to the YAML file with configuration") .requiredOption("-c, --config <file>", "sets the path to the YAML file with configuration")
.option("-x, --set <arg>", "overrides the config option for this specific run (arg: <key>=<name>, i.e. backend.ntfy.enable=false", (v: string, prev: string[]) => prev.concat([v]), []) .option("-x, --set <arg>", "overrides the config option for this specific run (arg: <key>=<name>, i.e. backend.ntfy.enable=false", (v: string, prev: string[]) => prev.concat([v]), [])
.option("-t, --test", "evaluates the query, applies the mapper and prints to stdout the notifications about to be trigger, without actual triggering them") .option("-t, --test", "evaluates the query, applies the mapper and prints to stdout the notifications about to be trigger, without actual triggering them")
.option("-s, --scan", "scans new tasks for future notifications and generates the database") .option("-s, --scan", "scans new tasks for future notifications and generates the database and exits immediately")
.option("-n, --notify", "reads the generated database and triggers notifications if any") .option("-n, --notify", "reads the generated database and triggers notifications if any and exits immediately")
.option("-p, --profile <name>", "limits the current operation only to specified profile. If missing, all profiles will be affected")
.parse() .parse()
.opts<CLIOptions>(); .opts<CLIOptions>();
@@ -39,17 +43,37 @@ const getOptions = () => program
} }
if (options.test) { if (options.test) {
await test(config); await test(config, options.profile);
return; return;
} }
if (options.scan) { if (options.scan) {
await scan(config); await scan(config, options.profile);
return; return;
} }
if (options.notify) { if (options.notify) {
await notify(config); await notify(config, options.profile);
return; return;
} }
await start(config, options);
}
async function start(config: Config, options: CLIOptions) {
await scan(config);
const scanJob = CronJob.from({
cronTime: config.scanCron ?? '0 0 * * * *',
onTick () { scan(config, options.profile) },
start: true,
});
const notifyJob = CronJob.from({
cronTime: config.notifyCron ?? '0 * * * * *',
onTick() { notify(config, options.profile) },
start: true,
});
await startServer(config);
} }

View File

@@ -4,5 +4,13 @@ import { Config } from "../types/config";
export function loadConfig(file: string): Config { export function loadConfig(file: string): Config {
const text = fs.readFileSync(file, 'utf-8'); const text = fs.readFileSync(file, 'utf-8');
return yaml.parse(text) as Config; const cfg = yaml.parse(text) as Config;
for (const profile of Object.keys(cfg.profiles)) {
if (cfg.profiles[profile]) {
cfg.profiles[profile].name = profile;
}
}
return cfg;
} }

View File

@@ -1,10 +1,10 @@
import fs from "fs"; import fs from "fs";
import { NotificationDatabase } from "../types/notification"; import { TaskDatabase } from "../types/task";
/** /**
* Loads and deserializes database from JSON formatted file. * Loads and deserializes database from JSON formatted file.
*/ */
export function loadDatabase(file: string): NotificationDatabase { export function loadDatabase(file: string): TaskDatabase {
const text = fs.readFileSync(file).toString(); const text = fs.readFileSync(file).toString();
return deserializeDatabase(text); return deserializeDatabase(text);
} }
@@ -12,6 +12,6 @@ export function loadDatabase(file: string): NotificationDatabase {
/** /**
* Deserializes database from JSON format. * Deserializes database from JSON format.
*/ */
export function deserializeDatabase(json: string): NotificationDatabase { export function deserializeDatabase(json: string): TaskDatabase {
return JSON.parse(json) as NotificationDatabase; return JSON.parse(json) as TaskDatabase;
} }

View File

@@ -1,57 +1,53 @@
import fs from "fs"; import fs from "fs";
import { Task } from "../types/task"; import { Task, TaskDatabase } from "../types/task";
import { Notification, NotificationDatabase } from "../types/notification";
import { jsMapper } from "../util/code";
type FlatReminder = {
task: Task;
time: string;
}
/** /**
* Applies the mapper for each task from list, groups them by time and dumps the data into JSON formatted file. * Applies the mapper for each task from list, groups them by time and dumps the data into JSON formatted file.
*/ */
export function dumpDatabase(file: string, tasks: Task[], mapper?: string) { export function dumpDatabase(file: string, tasks: Task[]) {
const data = serializeDatabase(tasks, mapper); const data = serializeDatabase(tasks);
fs.writeFileSync(file, data); fs.writeFileSync(file, data);
} }
/** /**
* Applies the mapper for each task from list, groups them by time and serializes into JSON format. * Applies the mapper for each task from list, groups them by time and serializes into JSON format.
*/ */
export function serializeDatabase(tasks: Task[], mapper?: string): string { export function serializeDatabase(tasks: Task[]): string {
return JSON.stringify(createDatabase(tasks, mapper)); return JSON.stringify(createDatabase(tasks));
} }
/** /**
* Applies the mapper for each task from list and groups them by time. * Applies the mapper for each task from list and groups them by time.
*/ */
export function createDatabase(tasks: Task[], mapper?: string): NotificationDatabase { export function createDatabase(tasks: Task[]): TaskDatabase {
const transformer = mapper return tasks
? jsMapper<Task, Notification>(mapper, {}) .flatMap(flatTimes)
: defaultMapper; .reduce(pushTaskToDatabase, {} as TaskDatabase);
return tasks.map(wrapWithTimeFiller(transformer)).reduce((acc, n) => {
if (n.time) {
(acc[n.time] = (acc[n.time] || [])).push(n);
};
return acc;
}, {} as NotificationDatabase);
} }
function wrapWithTimeFiller(mapper: (task: Task) => Notification): (task: Task) => Notification { /**
return (task: Task) => { * Returns list pairs of input task and consecutive reminder times.
const notification = mapper(task); * If reminder is missing, returns empty array.
* If reminder is empty array, returns ['_'], where '_' designates the default time.
return { * @param task input task
...notification, * @returns list pairs of input task and consecutive reminder times
time: task.reminder ?? notification.time, */
} function flatTimes(task: Task): FlatReminder[] {
}; if (task.reminder === undefined) {
return [];
}
return task.reminder.length === 0
? [{ task, time: '_' }]
: task.reminder.map(time => ({ task, time }));
} }
function defaultMapper(task: Task): Notification { function pushTaskToDatabase(db: TaskDatabase, { task, time }: FlatReminder): TaskDatabase {
return { (db[time] = db[time] ?? []).push(task);
title: "Obsidian Tasks Reminder", return db;
text: task.label,
priority: task.priority,
tags: task.tags,
};
} }

View File

@@ -1,16 +1,14 @@
import fs from "fs"; import fs from "fs";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { createInterface } from "readline"; import { createInterface } from "readline";
import { parse } from "../generated/grammar/task"; import { DynamicTask } from "../model";
import { Task as DefaultTask } from "../model";
import { ParseResult } from "../types/grammar";
import { Task, TaskPriority } from "../types/task"; import { Task, TaskPriority } from "../types/task";
import { jsMapper } from "../util/code"; import { jsMapper } from "../util/code";
/** /**
* Returns all tasks from specified directory and filters them with optional query. * Returns all tasks from specified directory and filters them with optional query.
*/ */
export async function loadTasks(directories: string[], query?: string): Promise<Task[]> { export async function loadTasks(directories: string[], query?: string, exclude?: string): Promise<Task[]> {
const ctx = { const ctx = {
now: dayjs(), now: dayjs(),
LOWEST: TaskPriority.LOWEST, LOWEST: TaskPriority.LOWEST,
@@ -21,8 +19,9 @@ export async function loadTasks(directories: string[], query?: string): Promise<
HIGHEST: TaskPriority.HIGHEST HIGHEST: TaskPriority.HIGHEST
}; };
const filter = query && jsMapper<Task, boolean>(query, ctx); const excludeFn = exclude ? jsMapper<string, boolean>(exclude, {}) : () => false;
const tasks = await Promise.all(directories.map(readTasksFromDirectory)); const filter = query && jsMapper<Task, boolean>(query, ctx);
const tasks = await Promise.all(directories.map(readTasksFromDirectory(excludeFn)));
return tasks.flat().filter(t => filter ? filter(t) : true); return tasks.flat().filter(t => filter ? filter(t) : true);
} }
@@ -30,26 +29,28 @@ export async function loadTasks(directories: string[], query?: string): Promise<
/** /**
* Read all files in specific directory and returns all tasks from those files. * Read all files in specific directory and returns all tasks from those files.
*/ */
async function readTasksFromDirectory(directory: string): Promise<Task[]> { function readTasksFromDirectory(excludeFn: (path: string) => boolean) {
return walk(directory, readTasksFromFile); return async (directory: string) => walk(directory, readTasksFromFile, excludeFn);
} }
/** /**
* Walks through a specific directory recursively and invokes visitor on each file. * Walks through a specific directory recursively and invokes visitor on each file.
* Returns a flat list of items returned by visitors. * Returns a flat list of items returned by visitors.
*/ */
async function walk<T>(directory: string, visitor: (path: string) => Promise<T[]>): Promise<T[]> { async function walk<T>(directory: string, visitor: (path: string) => Promise<T[]>, excludeFn: (path: string) => boolean): Promise<T[]> {
const list = []; const list = [];
for(const file of fs.readdirSync(directory)) { for(const file of fs.readdirSync(directory)) {
const path = `${directory}/${file}`; const path = `${directory}/${file}`;
if (fs.statSync(path).isDirectory()) { if (fs.statSync(path).isDirectory()) {
const items = await walk(path, visitor); const items = await walk(path, visitor, excludeFn);
list.push(...items); list.push(...items);
} else if (path.endsWith("md") || (path.endsWith("MD"))) { } else if (path.endsWith("md") || (path.endsWith("MD"))) {
const items = await visitor(path); if (!excludeFn(path)) {
list.push(...items); const items = await visitor(path);
list.push(...items);
}
} }
} }
@@ -68,10 +69,11 @@ async function readTasksFromFile(path: string): Promise<Task[]> {
}); });
const list: Task[] = []; const list: Task[] = [];
let lineNumber = 1;
for await (const line of lines) { for await (const line of lines) {
try { try {
const task = parseTask(line); const task = DynamicTask.parse(line, path, lineNumber);
if(task) { if(task) {
list.push(task); list.push(task);
@@ -84,23 +86,12 @@ async function readTasksFromFile(path: string): Promise<Task[]> {
} }
console.warn(e.message); console.warn(e.message);
console.warn("This line will be ignored. Please check the source and adjust it accordingly."); console.warn("This line will be ignored. Please check the source and adjust it accordingly.");
} finally {
lineNumber++;
} }
} }
return list; return list;
} }
/**
* Converts line to task model.
* If the line does not represent task, returns undefined.
*/
function parseTask(line: string): Task|undefined {
const item = parse(line) as ParseResult;
if (item.type === 'task') {
return new DefaultTask(item.data);
}
return undefined;
}

View File

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

View File

@@ -1,22 +1,32 @@
import dayjs, { Dayjs } from "dayjs"; 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 { Task, TaskPriority } from "../types/task";
import { parse } from "../generated/grammar/task";
export class LazyTask implements Task {
#parsed: ParsedTask;
#dates: ParsedTaskDate[];
constructor(task: ParsedTask) { export class DynamicTask implements Task {
this.#parsed = task; protected parsed: ParsedTask;
this.#dates = task.meta.filter(x => x.feature === 'date'); #source: string;
#sourceFile: string;
#sourceLine: number;
constructor(path: string, line: string, lineNumber: number, task: ParsedTask) {
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 { get status(): string {
return this.#parsed.status; return this.parsed.status;
} }
get label(): string { get label(): string {
return this.#parsed.label return this.parsed.label
.filter(x => x.span === 'word' || x.span === 'whitespace') .filter(x => x.span === 'word' || x.span === 'whitespace')
.map(x => x.value) .map(x => x.value)
.join("") .join("")
@@ -24,20 +34,20 @@ export class LazyTask implements Task {
} }
get fullLabel(): string { get fullLabel(): string {
return this.#parsed.label return this.parsed.label
.map(x => x.span === 'tag' ? `#${x.value}` : x.value) .map(x => x.span === 'tag' ? `#${x.value}` : x.value)
.join("") .join("")
.trim(); .trim();
} }
get tags(): string[] { get tags(): string[] {
return this.#parsed.label return this.parsed.label
.filter(x => x.span === 'tag') .filter(x => x.span === 'tag')
.map(x => x.value); .map(x => x.value);
} }
get priorityStr(): string { 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) { if(!priority) {
return "normal"; return "normal";
@@ -53,7 +63,7 @@ export class LazyTask implements Task {
} }
get priority(): TaskPriority { 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) { if(!priority) {
return TaskPriority.NORMAL; return TaskPriority.NORMAL;
@@ -99,11 +109,11 @@ export class LazyTask implements Task {
} }
get recurrenceRule(): string|undefined { 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 { 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 { get id(): string|undefined {
@@ -111,17 +121,79 @@ export class LazyTask implements Task {
} }
#features <T = ParsedTaskMeta>(feature: ParsedTask['meta'][0]['feature']): T[] { #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[] { get dependsOn(): string[] {
return this.#features<ParsedTaskDependency>("dependency").find(x => x.type === '⛔')?.deps || []; return this.#features<ParsedTaskDependency>("dependency").find(x => x.type === '⛔')?.deps || [];
} }
get reminder(): string|undefined { get reminder(): string[]|undefined {
return this.#parsed.meta.find(x => x.feature === 'reminder')?.time; const feature = this.parsed.meta.find(x => x.feature === 'reminder');
if (feature) {
return feature.time;
}
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 { toString(): string {
const o = (name: string, value?: string) => value && value.length > 0 ? `${name}=${value}` : ""; const o = (name: string, value?: string) => value && value.length > 0 ? `${name}=${value}` : "";
@@ -136,11 +208,98 @@ export class LazyTask implements Task {
o("recurrence", this.recurrenceRule), o("recurrence", this.recurrenceRule),
o("delete", this.onDelete), o("delete", this.onDelete),
o("id", this.id), o("id", this.id),
o("deps", this.dependsOn.join(",")), o("deps", this.dependsOn?.join(",")),
o("reminder", this.reminder), o("reminder", this.reminder?.join(",")),
o("tags", this.tags.join(",")) o("tags", this.tags.join(","))
]; ];
return `- [${this.status}] ${this.label} {${items.filter(x => x.length > 0).join(", ")}}`; return `- [${this.status}] ${this.label} {${items.filter(x => x.length > 0).join(", ")}}`;
} }
}
toJSON(): Task {
return {
status: this.status,
label: this.label,
fullLabel: this.fullLabel,
tags: this.tags,
priority: this.priority,
priorityStr: this.priorityStr,
createdDate: this.createdDate,
startDate: this.startDate,
scheduledDate: this.scheduledDate,
dueDate: this.dueDate,
completedDate: this.completedDate,
cancelledDate: this.cancelledDate,
recurrenceRule: this.recurrenceRule,
onDelete: this.onDelete,
id: this.id,
dependsOn: this.dependsOn,
reminder: this.reminder,
source: this.source,
sourceLine: this.sourceLine,
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.join(",")]);
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

@@ -2,29 +2,45 @@ import { loadTasks } from "../loader";
import { createDatabase, dumpDatabase } from "../database/serializer"; import { createDatabase, dumpDatabase } from "../database/serializer";
import { loadDatabase } from "../database/deserializer"; import { loadDatabase } from "../database/deserializer";
import { remind } from "../backend"; import { remind } from "../backend";
import { Config } from "../types/config"; import { Config, ProfileConfig } from "../types/config";
export async function test(config: Config) { export const test = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(config.sources, config.query); const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
const db = createDatabase(tasks, config.mapper); const db = createDatabase(tasks);
for (const time of Object.keys(db)) { for (const time of Object.keys(db)) {
console.log(time); console.log(time);
for (const notification of db[time]) { for (const task of db[time]) {
console.log(` - title: ${notification.title}\n text: ${notification.text}\n priority: ${notification.priority}\n tags: ${notification.tags?.join(",")}`) console.log(task.toString());
} }
console.log(); console.log();
} }
} });
export async function scan(config: Config) { export const scan = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(config.sources, config.query); const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
dumpDatabase(config.databaseFile, tasks, config.mapper); dumpDatabase(profileConfig.databaseFile, tasks);
} });
export async function notify(config: Config) { export const notify = handleProfile(async (config, profileConfig) => {
const db = loadDatabase(config.databaseFile); const db = loadDatabase(profileConfig.databaseFile);
remind(config, db); remind(config, profileConfig, db);
});
function handleProfile(handler: (config: Config, profile: ProfileConfig) => Promise<void>) {
return async (config: Config, profile?: string) => {
if (profile !== undefined) {
const cfg = config.profiles[profile];
if (cfg === undefined) {
throw new Error(`Undefined profile: ${profile}`);
}
return handler(config, cfg);
}
return await Promise.all(Object.values(config.profiles).map(c => handler(config, c)));
};
} }

22
src/server/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Config } from "../types/config";
import express from "express";
import { CompleteTaskDTO } from "../types/dto";
import { complete } from "../services/task.service";
export async function startServer(config: Config) {
const server = express();
const port = config?.server?.port ?? 8080;
server.post('/complete', express.json(), async (req, res) => {
const { profile, task } = await req.body as CompleteTaskDTO;
complete(config, profile, task);
console.log(`Completed task [${task.fullLabel}]`);
res.json({ status: "ok" });
})
server.listen(port, () => {
console.log(`Server listens on ${port} port`);
});
}

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

@@ -0,0 +1,81 @@
import fs from "fs";
import { Task } from "../types/task";
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) {
const profileConfig = config.profiles[profile];
if (profileConfig === undefined) {
throw new Error(`Unknown profile ${profile}`);
}
if (!profileConfig.completion?.enable) {
return;
}
const data = fs.readFileSync(task.sourceFile, 'utf8').split(/\n/);
const output = [];
for (const [number0, line] of data.entries()) {
if (task.sourceLine === number0 + 1) {
if (task.source !== line) {
throw new Error(`Cannot complete task, file has been changed since last scan. Rememberred line: ${task.source}, current line: ${line}.`);
}
output.push(...completeTask(profileConfig, task));
}
else {
output.push(line);
}
}
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

@@ -1,5 +1,6 @@
export type CLIOptions = { export type CLIOptions = {
config: string; config: string;
profile?: string;
test: boolean; test: boolean;
scan: boolean; scan: boolean;
notify: boolean; notify: boolean;

View File

@@ -1,12 +1,36 @@
export type Config = { export type Config = {
sources: string[]; profiles: Record<string, ProfileConfig>;
query?: string; notifyCron?: string;
mapper?: string; scanCron?: string;
databaseFile: string; server?: ServerConfig;
backend: Record<string, unknown>;
}; };
export type BackendSettings = { export type ServerConfig = {
port?: number;
baseUrl?: string;
};
export type ProfileConfig = {
name: string;
enable: boolean;
sources: string[];
query?: string;
exclude?: string;
defaultTime?: string;
databaseFile: string;
backend: Record<string, unknown>;
completion?: CompletionConfig;
};
export type CompletionConfig = {
enable?: boolean;
status?: 'x';
nextOccurenceBelow?: boolean;
removeScheduled?: boolean;
addCreationDate?: boolean;
};
export type BackendSettings = {
enable?: boolean; enable?: boolean;
}; };

6
src/types/dto.ts Normal file
View File

@@ -0,0 +1,6 @@
import { Task } from "./task";
export type CompleteTaskDTO = {
profile: string;
task: Task
};

View File

@@ -54,5 +54,5 @@ export type ParsedTaskDependency = {
export type ParsedTaskReminder = { export type ParsedTaskReminder = {
feature: 'reminder'; feature: 'reminder';
time: string; time: string[];
} }

View File

@@ -1,11 +0,0 @@
import { TaskPriority } from "./task";
export type Notification = {
text: string;
time?: string;
title?: string;
priority?: TaskPriority;
tags?: string[];
};
export type NotificationDatabase = Record<string, Notification[]>;

View File

@@ -17,8 +17,11 @@ export type Task = {
onDelete?: string; onDelete?: string;
id?: string; id?: string;
dependsOn?: string[]; dependsOn?: string[];
reminder?: string; reminder?: string[];
} sourceFile: string;
sourceLine: number;
source: string;
};
export enum TaskPriority { export enum TaskPriority {
LOWEST, LOWEST,
@@ -27,4 +30,6 @@ export enum TaskPriority {
MEDIUM, MEDIUM,
HIGH, HIGH,
HIGHEST, HIGHEST,
}; };
export type TaskDatabase = Record<string, Task[]>;

View File

@@ -1,4 +1,11 @@
import dayjs from "dayjs";
const standardContext = {
dayjs
};
export function jsMapper<I, O>(code: string, context: Record<string, unknown>): (task: I) => O { export function jsMapper<I, O>(code: string, context: Record<string, unknown>): (task: I) => O {
const filter = new Function('$', ...Object.keys(context), `return ${code};`); const ctx = { ...standardContext, ...context };
return (task: I) => filter(task, ...Object.values(context)); const filter = new Function('$', ...Object.keys(ctx), code);
return (task: I) => filter(task, ...Object.values(ctx));
} }

View File

@@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2023",
"module": "commonjs", "module": "commonjs",
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",