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"
},
"dependencies": {
"@types/express": "^5.0.2",
"commander": "^13.0.0",
"cron": "^4.3.1",
"dayjs": "^1.11.13",
"express": "^5.1.0",
"peggy": "^4.2.0",
"yaml": "^2.7.0"
}

View File

@@ -7,5 +7,5 @@ buildNpmPackage {
pname = "obsidian-tasks-reminder";
version = "0.0.1";
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";
export abstract class Backend<C extends BackendSettings> {
public abstract readonly name: string;
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) {
if (config[field] === undefined) {
if (backendConfig[field] === undefined) {
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> {
const cfg = config?.backend?.[this.name] as Partial<C> | undefined;
public async remind(config: Config, profileConfig: ProfileConfig, tasks: Task[]): Promise<void> {
const cfg = profileConfig?.backend?.[this.name] as Partial<C> | undefined;
if (cfg?.enable !== true) {
return;
@@ -28,15 +28,15 @@ export abstract class Backend<C extends BackendSettings> {
}
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) {
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 { Backend } from "./base";
type Config = BackendSettings;
export class Debug extends Backend<Config> {
export class Debug extends Backend<BackendSettings> {
public name = "debug";
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));
}
}

View File

@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { Task, TaskDatabase } from "../types/task";
import { ProfileConfig } from "../types/config";
import { Config, ProfileConfig } from "../types/config";
import { NtfySH } from "./ntfy";
import { Debug } from "./debug";
@@ -13,22 +13,22 @@ const backends = [
* Iterates through all the database notifications for current time
* 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");
await run(config, db, db[now]);
await run(config, profileConfig, db, db[now]);
if(config?.defaultTime && config?.defaultTime === now) {
run(config, db, db.default);
if(profileConfig?.defaultTime && profileConfig?.defaultTime === now) {
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) {
return;
}
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 { BackendSettings } from "../types/config";
import { BackendSettings, Config, ProfileConfig } from "../types/config";
import { CompleteTaskDTO } from "../types/dto";
import { Backend } from "./base";
import { Task, TaskPriority } from "../types/task";
import { enhancedStringConfig } from "../util/config";
import { jsMapper } from "../util/code";
type Config = {
type NtfyConfig = {
url: string;
token: string;
map?: string;
@@ -21,27 +22,27 @@ type NtfyDTO = {
icon?: string;
};
export class NtfySH extends Backend<Config> {
export class NtfySH extends Backend<NtfyConfig> {
public name = "ntfy";
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 = {
mapPriority,
template: defaultMapper
};
const mapper = config.map
? jsMapper<object, NtfyDTO>(config.map, context)
const mapper = backendConfig.map
? jsMapper<object, NtfyDTO>(backendConfig.map, context)
: defaultMapper;
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 context = {
@@ -50,31 +51,55 @@ export class NtfySH extends Backend<Config> {
};
for (const chunk of chunks) {
const mapper = config.combineMap
? jsMapper<object, NtfyDTO>(config.combineMap, context)
const mapper = backendConfig.combineMap
? jsMapper<object, NtfyDTO>(backendConfig.combineMap, context)
: defaultCombineMapper;
const dto = mapper(chunk);
await this.#doNotify(config, dto);
await this.#doNotify(config, profileConfig, backendConfig, dto);
await snooze(2500);
}
}
async #doNotify(config: Config, dto: NtfyDTO): Promise<Response> {
const token = enhancedStringConfig(config.token);
async #doNotify(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, dto: NtfyDTO, task?: Task): Promise<Response> {
const token = enhancedStringConfig(backendConfig.token);
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);
return fetch(`https://${config.url}/${config.topic || 'obsidian'}`, {
return fetch(`https://${backendConfig.url}`, {
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 { notify, scan, test } from "../runner";
import { CronJob } from "cron";
import { startServer } from "../server";
const getOptions = () => program
.name("obsidian-tasks-reminder")
@@ -11,7 +12,7 @@ const getOptions = () => program
.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("-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()
.opts<CLIOptions>();
@@ -43,15 +44,19 @@ const getOptions = () => program
return;
}
await scan(config);
const scanJob = CronJob.from({
cronTime: config.scanCron ?? '0 0 * * * *',
onTick () { scan(config) },
onTick () { scan(config, options.profile) },
start: true,
});
const notifyJob = CronJob.from({
cronTime: config.notifyCron ?? '0 * * * * *',
onTick() { notify(config) },
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 {
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[] = [];
let lineNumber = 1;
for await (const line of lines) {
try {
const task = parseTask(line);
const task = parseTask(line, path, lineNumber);
if(task) {
list.push(task);
@@ -87,6 +88,8 @@ async function readTasksFromFile(path: string): Promise<Task[]> {
}
console.warn(e.message);
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.
* 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;
if (item.type === 'task') {
return new DefaultTask(item.data);
return new DefaultTask(path, line, lineNumber, item.data);
}
return undefined;

View File

@@ -5,10 +5,17 @@ import { Task, TaskPriority } from "../types/task";
export class LazyTask implements Task {
#parsed: ParsedTask;
#dates: ParsedTaskDate[];
source: string;
sourceFile: string;
sourceLine: number;
constructor(task: ParsedTask) {
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;
}
get status(): string {
@@ -169,6 +176,9 @@ export class LazyTask implements Task {
id: this.id,
dependsOn: this.dependsOn,
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 { Config, ProfileConfig } from "../types/config";
export const test = handleProfile(async config => {
const tasks = await loadTasks(config.sources, config.query, config.exclude);
export const test = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
const db = createDatabase(tasks);
for (const time of Object.keys(db)) {
@@ -19,17 +19,17 @@ export const test = handleProfile(async config => {
}
});
export const scan = handleProfile(async config => {
const tasks = await loadTasks(config.sources, config.query, config.exclude);
dumpDatabase(config.databaseFile, tasks);
export const scan = handleProfile(async (config, profileConfig) => {
const tasks = await loadTasks(profileConfig.sources, profileConfig.query, profileConfig.exclude);
dumpDatabase(profileConfig.databaseFile, tasks);
});
export const notify = handleProfile(async config => {
const db = loadDatabase(config.databaseFile);
remind(config, db);
export const notify = handleProfile(async (config, profileConfig) => {
const db = loadDatabase(profileConfig.databaseFile);
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) => {
if (profile !== undefined) {
const cfg = config.profiles[profile];
@@ -38,9 +38,9 @@ function handleProfile(handler: (profile: ProfileConfig) => Promise<void>) {
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>;
notifyCron?: string;
scanCron?: string;
server?: ServerConfig;
};
export type ServerConfig = {
port?: number;
baseUrl?: string;
};
export type ProfileConfig = {
name: string;
enable: boolean;
sources: string[];
sources: string[];
query?: string;
exclude?: string;
defaultTime?: string;
databaseFile: string;
backend: Record<string, unknown>;
completion?: CompletionConfig;
};
export type BackendSettings = {
export type CompletionConfig = {
enable?: boolean;
status?: 'x';
};
export type BackendSettings = {
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;
id?: string;
dependsOn?: string[];
reminder?: string;
}
reminder?: string;
sourceFile: string;
sourceLine: number;
source: string;
};
export enum TaskPriority {
LOWEST,