Add basic support for task completion

This commit is contained in:
2025-06-04 18:46:20 +02:00
parent 46f09f2e13
commit 42d8f0db8b
17 changed files with 1087 additions and 68 deletions

891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,11 @@
"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", "cron": "^4.3.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^5.1.0",
"peggy": "^4.2.0", "peggy": "^4.2.0",
"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-K06A/j2GfM0nCiFv4Pho907VWLa2pPHjtV9BgKxwdnE="; npmDepsHash = "sha256-5BsZ4Z7/YjAnOLc6IBU+O0T1p0ncDd7kwzerI5PJYQM=";
} }

View File

@@ -1,23 +1,23 @@
import { BackendSettings, ProfileConfig } from "../types/config"; import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { Task } from "../types/task"; 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, task: Task): 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 async remind(config: ProfileConfig, tasks: Task[]): Promise<void> { public async remind(config: Config, profileConfig: ProfileConfig, tasks: Task[]): Promise<void> {
const cfg = config?.backend?.[this.name] as Partial<C> | undefined; const cfg = profileConfig?.backend?.[this.name] as Partial<C> | undefined;
if (cfg?.enable !== true) { if (cfg?.enable !== true) {
return; return;
@@ -28,15 +28,15 @@ export abstract class Backend<C extends BackendSettings> {
} }
if (tasks.length === 1) { if (tasks.length === 1) {
return await this.notify(this.#validate(cfg), tasks[0]); return await this.notify(config, profileConfig, this.#validate(cfg), tasks[0]);
} }
return await this.notifyCombined(this.#validate(cfg), tasks); return await this.notifyCombined(config, profileConfig, this.#validate(cfg), tasks);
} }
protected async notifyCombined(config: C, tasks: Task[]) { protected async notifyCombined(config: Config, profileConfig: ProfileConfig, backendConfig: C, tasks: Task[]) {
for (const task of tasks) { for (const task of tasks) {
this.notify(config, task); this.notify(config, profileConfig, backendConfig, task);
} }
} }
} }

View File

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

View File

@@ -1,6 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Task, TaskDatabase } from "../types/task"; import { Task, TaskDatabase } from "../types/task";
import { ProfileConfig } from "../types/config"; import { Config, ProfileConfig } from "../types/config";
import { NtfySH } from "./ntfy"; import { NtfySH } from "./ntfy";
import { Debug } from "./debug"; import { Debug } from "./debug";
@@ -13,22 +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: ProfileConfig, db: TaskDatabase) { export async function remind(config: Config, profileConfig: ProfileConfig, db: TaskDatabase) {
const now = dayjs().format("HH:mm"); const now = dayjs().format("HH:mm");
await run(config, db, db[now]); await run(config, profileConfig, db, db[now]);
if(config?.defaultTime && config?.defaultTime === now) { if(profileConfig?.defaultTime && profileConfig?.defaultTime === now) {
run(config, db, db.default); run(config, profileConfig, db, db.default);
} }
} }
async function run(config: ProfileConfig, db: TaskDatabase, tasks?: Task[]) { async function run(config: Config, profileConfig: ProfileConfig, db: TaskDatabase, tasks?: Task[]) {
if(!tasks) { if(!tasks) {
return; return;
} }
for (const backend of backends) { for (const backend of backends) {
await backend.remind(config, tasks); await backend.remind(config, profileConfig, tasks);
} }
} }

View File

@@ -1,11 +1,12 @@
import fs from "fs"; import fs from "fs";
import { BackendSettings } from "../types/config"; import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { CompleteTaskDTO } from "../types/dto";
import { Backend } from "./base"; import { Backend } from "./base";
import { Task, 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 { jsMapper } from "../util/code";
type Config = { type NtfyConfig = {
url: string; url: string;
token: string; token: string;
map?: string; map?: string;
@@ -21,27 +22,27 @@ type NtfyDTO = {
icon?: string; icon?: string;
}; };
export class NtfySH extends Backend<Config> { 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, task: Task): void { protected notify(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, task: Task): void {
const context = { const context = {
mapPriority, mapPriority,
template: defaultMapper template: defaultMapper
}; };
const mapper = config.map const mapper = backendConfig.map
? jsMapper<object, NtfyDTO>(config.map, context) ? jsMapper<object, NtfyDTO>(backendConfig.map, context)
: defaultMapper; : defaultMapper;
const dto = mapper(task); const dto = mapper(task);
this.#doNotify(config, dto); this.#doNotify(config, profileConfig, backendConfig, dto, task);
} }
protected async notifyCombined(config: Config, tasks: Task[]): Promise<void> { protected async notifyCombined(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, tasks: Task[]): Promise<void> {
const chunks = chunkArray(tasks, 4); const chunks = chunkArray(tasks, 4);
const context = { const context = {
@@ -50,31 +51,55 @@ export class NtfySH extends Backend<Config> {
}; };
for (const chunk of chunks) { for (const chunk of chunks) {
const mapper = config.combineMap const mapper = backendConfig.combineMap
? jsMapper<object, NtfyDTO>(config.combineMap, context) ? jsMapper<object, NtfyDTO>(backendConfig.combineMap, context)
: defaultCombineMapper; : defaultCombineMapper;
const dto = mapper(chunk); const dto = mapper(chunk);
await this.#doNotify(config, dto); await this.#doNotify(config, profileConfig, backendConfig, dto);
await snooze(2500); await snooze(2500);
} }
} }
async #doNotify(config: Config, dto: NtfyDTO): Promise<Response> { async #doNotify(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, dto: NtfyDTO, task?: Task): Promise<Response> {
const token = enhancedStringConfig(config.token); const token = enhancedStringConfig(backendConfig.token);
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`,
}; };
const actions: object[] = [];
if (task && profileConfig.completion?.enable && config.server?.baseUrl && !task.recurrenceRule) {
const body: CompleteTaskDTO = {
task,
profile: profileConfig.name
};
actions.push({
action: "http",
label: "Mark as completed",
url: `${config.server.baseUrl}/complete`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body)
});
}
buildHeaders(dto, headers); buildHeaders(dto, headers);
return fetch(`https://${config.url}/${config.topic || 'obsidian'}`, { return fetch(`https://${backendConfig.url}`, {
method: 'POST', method: 'POST',
body: dto.text ?? "", headers,
headers body: JSON.stringify({
topic: backendConfig.topic || 'obsidian',
message: dto.text ?? "",
actions
})
}) })
} }
} }

View File

@@ -4,6 +4,7 @@ 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 { CronJob } from "cron";
import { startServer } from "../server";
const getOptions = () => program const getOptions = () => program
.name("obsidian-tasks-reminder") .name("obsidian-tasks-reminder")
@@ -11,7 +12,7 @@ const getOptions = () => program
.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("-p, --profile <name>", "(applicable only with '--test' option) limits the current operation only to specified profile. If missing, all profiles will be affected") .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>();
@@ -43,15 +44,19 @@ const getOptions = () => program
return; return;
} }
await scan(config);
const scanJob = CronJob.from({ const scanJob = CronJob.from({
cronTime: config.scanCron ?? '0 0 * * * *', cronTime: config.scanCron ?? '0 0 * * * *',
onTick () { scan(config) }, onTick () { scan(config, options.profile) },
start: true, start: true,
}); });
const notifyJob = CronJob.from({ const notifyJob = CronJob.from({
cronTime: config.notifyCron ?? '0 * * * * *', cronTime: config.notifyCron ?? '0 * * * * *',
onTick() { notify(config) }, onTick() { notify(config, options.profile) },
start: true, 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

@@ -71,10 +71,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 = parseTask(line, path, lineNumber);
if(task) { if(task) {
list.push(task); list.push(task);
@@ -87,6 +88,8 @@ 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++;
} }
} }
@@ -97,11 +100,11 @@ async function readTasksFromFile(path: string): Promise<Task[]> {
* Converts line to task model. * Converts line to task model.
* If the line does not represent task, returns undefined. * If the line does not represent task, returns undefined.
*/ */
function parseTask(line: string): Task|undefined { function parseTask(line: string, path: string, lineNumber: number): Task|undefined {
const item = parse(line) as ParseResult; const item = parse(line) as ParseResult;
if (item.type === 'task') { if (item.type === 'task') {
return new DefaultTask(item.data); return new DefaultTask(path, line, lineNumber, item.data);
} }
return undefined; return undefined;

View File

@@ -5,10 +5,17 @@ import { Task, TaskPriority } from "../types/task";
export class LazyTask implements Task { export class LazyTask implements Task {
#parsed: ParsedTask; #parsed: ParsedTask;
#dates: ParsedTaskDate[]; #dates: ParsedTaskDate[];
source: string;
sourceFile: string;
sourceLine: number;
constructor(task: ParsedTask) { constructor(path: string, line: string, lineNumber: number, task: ParsedTask) {
this.#parsed = task; this.#parsed = task;
this.#dates = task.meta.filter(x => x.feature === 'date'); this.#dates = task.meta.filter(x => x.feature === 'date');
this.source = line;
this.sourceFile = path;
this.sourceLine = lineNumber;
} }
get status(): string { get status(): string {
@@ -169,6 +176,9 @@ export class LazyTask implements Task {
id: this.id, id: this.id,
dependsOn: this.dependsOn, dependsOn: this.dependsOn,
reminder: this.reminder, reminder: this.reminder,
source: this.source,
sourceLine: this.sourceLine,
sourceFile: this.sourceFile
}; };
} }
} }

View File

@@ -4,8 +4,8 @@ import { loadDatabase } from "../database/deserializer";
import { remind } from "../backend"; import { remind } from "../backend";
import { Config, ProfileConfig } from "../types/config"; import { Config, ProfileConfig } from "../types/config";
export const test = handleProfile(async config => { export const test = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(config.sources, config.query, config.exclude); const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
const db = createDatabase(tasks); const db = createDatabase(tasks);
for (const time of Object.keys(db)) { for (const time of Object.keys(db)) {
@@ -19,17 +19,17 @@ export const test = handleProfile(async config => {
} }
}); });
export const scan = handleProfile(async config => { export const scan = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(config.sources, config.query, config.exclude); const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
dumpDatabase(config.databaseFile, tasks); dumpDatabase(profileConfig.databaseFile, tasks);
}); });
export const notify = handleProfile(async 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: (profile: ProfileConfig) => Promise<void>) { function handleProfile(handler: (config: Config, profile: ProfileConfig) => Promise<void>) {
return async (config: Config, profile?: string) => { return async (config: Config, profile?: string) => {
if (profile !== undefined) { if (profile !== undefined) {
const cfg = config.profiles[profile]; const cfg = config.profiles[profile];
@@ -38,9 +38,9 @@ function handleProfile(handler: (profile: ProfileConfig) => Promise<void>) {
throw new Error(`Undefined profile: ${profile}`); throw new Error(`Undefined profile: ${profile}`);
} }
return handler(cfg); return handler(config, cfg);
} }
return await Promise.all(Object.values(config.profiles).map(handler)); 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 { Task } from "../model";
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);
res.json({ status: "ok" });
})
server.listen(port, () => {
console.log(`Server listens on ${port} port`);
});
}

View File

@@ -0,0 +1,38 @@
import fs from "fs";
import { Task } from "../types/task";
import { Config } from "../types/config";
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;
}
if (task.recurrenceRule) {
throw new Error('Recurrence tasks are not supported for now');
}
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(line.replace(/^-(\s+)\[.\]/, `-$1[${profileConfig.completion?.status ?? 'x'}]`));
}
else {
output.push(line);
}
}
fs.writeFileSync(task.sourceFile, output.join("\n"));
}

View File

@@ -2,19 +2,32 @@ export type Config = {
profiles: Record<string, ProfileConfig>; profiles: Record<string, ProfileConfig>;
notifyCron?: string; notifyCron?: string;
scanCron?: string; scanCron?: string;
server?: ServerConfig;
};
export type ServerConfig = {
port?: number;
baseUrl?: string;
}; };
export type ProfileConfig = { export type ProfileConfig = {
name: string;
enable: boolean; enable: boolean;
sources: string[]; sources: string[];
query?: string; query?: string;
exclude?: string; exclude?: string;
defaultTime?: string; defaultTime?: string;
databaseFile: string; databaseFile: string;
backend: Record<string, unknown>; backend: Record<string, unknown>;
completion?: CompletionConfig;
}; };
export type BackendSettings = { export type CompletionConfig = {
enable?: boolean;
status?: 'x';
};
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

@@ -17,9 +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,