Add support for combined notifications

This commit is contained in:
2025-03-05 14:08:38 +01:00
parent 0ec71cfc8e
commit c321758101
5 changed files with 93 additions and 23 deletions

View File

@@ -4,7 +4,7 @@ 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: C, task: Task): void;
#validate(config: Partial<C>): C { #validate(config: Partial<C>): C {
for (const field of this.requiredFields) { for (const field of this.requiredFields) {
@@ -16,13 +16,27 @@ export abstract class Backend<C extends BackendSettings> {
return config as C; return config as C;
} }
public remind(config: Config, task: Task) { public async remind(config: Config, tasks: Task[]): Promise<void> {
const cfg = config?.backend?.[this.name] as Partial<C> | undefined; const cfg = config?.backend?.[this.name] as Partial<C> | undefined;
if (cfg?.enable !== true) { if (cfg?.enable !== true) {
return return;
} }
this.notify(this.#validate(cfg), task); if (tasks.length === 0) {
return;
}
if (tasks.length === 1) {
return await this.notify(this.#validate(cfg), tasks[0]);
}
return await this.notifyCombined(this.#validate(cfg), tasks);
}
protected async notifyCombined(config: C, tasks: Task[]) {
for (const task of tasks) {
this.notify(config, task);
}
} }
} }

View File

@@ -28,15 +28,7 @@ async function run(config: Config, db: TaskDatabase, tasks?: Task[]) {
return; return;
} }
for (const task of tasks) { for (const backend of backends) {
console.info(`Dispatching a notification: [${task.label}]`) await backend.remind(config, tasks);
for (const backend of backends) {
backend.remind(config, task);
await snooze(1500);
}
} }
}
function snooze(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
} }

View File

@@ -8,7 +8,8 @@ import { jsMapper } from "../util/code";
type Config = { type Config = {
url: string; url: string;
token: string; token: string;
mapper?: string; map?: string;
combineMap?: string;
topic?: string; topic?: string;
} & BackendSettings; } & BackendSettings;
@@ -25,28 +26,74 @@ export class NtfySH extends Backend<Config> {
protected requiredFields = ['url', 'token'] as const; protected requiredFields = ['url', 'token'] as const;
protected notify(config: Config, task: Task): void { protected notify(config: Config, task: Task): void {
const token = enhancedStringConfig(config.token); const context = {
const mapper = config.mapper mapPriority,
? jsMapper<object, NtfyDTO>(config.mapper, { mapPriority }) template: defaultMapper
};
const mapper = config.map
? jsMapper<object, NtfyDTO>(config.map, context)
: defaultMapper; : defaultMapper;
const dto = mapper(task); const dto = mapper(task);
this.#doNotify(config, dto);
}
protected async notifyCombined(config: Config, tasks: Task[]): Promise<void> {
const chunks = chunkArray(tasks, 4);
const context = {
mapPriority,
template: defaultCombineMapper
};
for (const chunk of chunks) {
const mapper = config.combineMap
? jsMapper<object, NtfyDTO>(config.combineMap, context)
: defaultCombineMapper;
const dto = mapper(chunk);
await this.#doNotify(config, dto);
await snooze(2500);
}
}
async #doNotify(config: Config, dto: NtfyDTO): Promise<Response> {
const token = enhancedStringConfig(config.token);
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}; };
buildHeaders(dto, headers); buildHeaders(dto, headers);
fetch(`https://${config.url}/${config.topic || 'obsidian'}`, { return fetch(`https://${config.url}/${config.topic || 'obsidian'}`, {
method: 'POST', method: 'POST',
body: dto.text ?? "", body: dto.text ?? "",
headers headers
}) })
} }
} }
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>) { function buildHeaders(dto: NtfyDTO, headers: Record<string, string>) {
if (dto.title) { if (dto.title) {
headers.Title = dto.title; headers.Title = dto.title;
@@ -74,6 +121,23 @@ function defaultMapper(task: Task): NtfyDTO {
} }
} }
function defaultCombineMapper(tasks: Task[], bullet = "-"): NtfyDTO {
const text = tasks
.toSorted((a, b) => b.priority - a.priority)
.map(t => `${bullet} ${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
}
}
function mapPriority(priority?: TaskPriority): string { function mapPriority(priority?: TaskPriority): string {
if (!priority) { if (!priority) {
return 'default'; return 'default';

View File

@@ -1,4 +1,4 @@
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 filter = new Function('$', ...Object.keys(context), code);
return (task: I) => filter(task, ...Object.values(context)); return (task: I) => filter(task, ...Object.values(context));
} }

View File

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