Add basic support for task completion
This commit is contained in:
891
package-lock.json
generated
891
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ buildNpmPackage {
|
||||
pname = "obsidian-tasks-reminder";
|
||||
version = "0.0.1";
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-K06A/j2GfM0nCiFv4Pho907VWLa2pPHjtV9BgKxwdnE=";
|
||||
npmDepsHash = "sha256-5BsZ4Z7/YjAnOLc6IBU+O0T1p0ncDd7kwzerI5PJYQM=";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
22
src/server/index.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
38
src/services/task.service.ts
Normal file
38
src/services/task.service.ts
Normal 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"));
|
||||
}
|
||||
@@ -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
6
src/types/dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Task } from "./task";
|
||||
|
||||
export type CompleteTaskDTO = {
|
||||
profile: string;
|
||||
task: Task
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user