Compare commits
25 Commits
67db439f84
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
0060b2be60
|
|||
|
bff145d27f
|
|||
|
52dd10ac10
|
|||
|
61e78a85d8
|
|||
|
612285d91c
|
|||
|
b266cb0b83
|
|||
|
421a941262
|
|||
|
42d8f0db8b
|
|||
|
46f09f2e13
|
|||
|
9bc036113f
|
|||
|
17fe6b86de
|
|||
|
677e68a374
|
|||
|
c321758101
|
|||
|
0ec71cfc8e
|
|||
|
b1c89efab9
|
|||
|
1fabb32fb6
|
|||
|
0b3fda4beb
|
|||
|
c86fb95d90
|
|||
|
35a0fec948
|
|||
|
be3080432e
|
|||
|
3528e65312
|
|||
|
8490e073f6
|
|||
|
298efc3345
|
|||
|
a270ee4ae5
|
|||
|
f3b68dca33
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -145,4 +145,7 @@ dist
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node
|
||||
|
||||
src/generated
|
||||
src/generated
|
||||
*.yaml
|
||||
*.json
|
||||
.direnv
|
||||
|
||||
@@ -18,5 +18,11 @@
|
||||
obsidian-tasks-reminder = pkgs.callPackage ./package.nix {};
|
||||
default = obsidian-tasks-reminder;
|
||||
};
|
||||
});
|
||||
})
|
||||
// {
|
||||
nixosModules = rec {
|
||||
obsidian-tasks-reminder = import ./module.nix self;
|
||||
default = obsidian-tasks-reminder;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ labelWhitespace = whitespace:[ \t\r]+ {
|
||||
}
|
||||
}
|
||||
|
||||
tag = "#" tag:[a-zA-Z0-9-]+ {
|
||||
tag = "#" tag:[a-zA-Z0-9-/]+ {
|
||||
return {
|
||||
span: "tag",
|
||||
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 {
|
||||
feature: "reminder",
|
||||
time
|
||||
}
|
||||
}
|
||||
|
||||
defaultTime = "_"
|
||||
|
||||
longTime = hour:[0-9]|1..2| ":" minute:[0-9]|1..2| {
|
||||
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 {
|
||||
feature: "dependency",
|
||||
type,
|
||||
|
||||
65
module.nix
Normal file
65
module.nix
Normal file
@@ -0,0 +1,65 @@
|
||||
self: {
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
system,
|
||||
...
|
||||
}: let
|
||||
cfg = config.services.obsidian-tasks-reminder;
|
||||
|
||||
appConfig = (pkgs.formats.yaml {}).generate "obsidian-tasks-reminder.config.yaml" cfg.config;
|
||||
|
||||
app = pkgs.writeShellApplication {
|
||||
name = "obsidian-tasks-reminder";
|
||||
runtimeInputs = [self.packages.${system}.default];
|
||||
text = ''
|
||||
obsidian-tasks-reminder -c "${appConfig}" "$@";
|
||||
'';
|
||||
};
|
||||
in
|
||||
with lib; {
|
||||
options.services.obsidian-tasks-reminder = {
|
||||
enable = mkEnableOption "obsidian-tasks-reminder";
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
description = "User which will be used to run the app";
|
||||
example = "root";
|
||||
default = "root";
|
||||
};
|
||||
|
||||
config = mkOption {
|
||||
type = types.attrs;
|
||||
description = "The obsidian-tasks-reminder config which will be eventually converted to yaml";
|
||||
example = {
|
||||
sources = ["/var/lib/obsidian-data"];
|
||||
query = "$. iority > MEDIUM && $.status !== 'x'";
|
||||
mapper = "{ text: $.label, title: 'Task reminder' }";
|
||||
databaseFile = "/tmp/obsidian-tasks-reminder.json";
|
||||
backend.ntfy = {
|
||||
enable = true;
|
||||
url = "ntfy.sh";
|
||||
token = "$__file:/etc/tokens/ntfy-sh.key";
|
||||
topic = "obsidian";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [app];
|
||||
|
||||
systemd.services.obsidian-tasks-reminder = {
|
||||
enable = true;
|
||||
description = "Obsidian Tasks Notifier";
|
||||
|
||||
wantedBy = ["network-online.target"];
|
||||
after = ["network-online.target"];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${app}/bin/obsidian-tasks-reminder";
|
||||
User = cfg.user;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
981
package-lock.json
generated
981
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,14 @@
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/express": "^5.0.2",
|
||||
"commander": "^13.0.0",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"peggy": "^4.2.0"
|
||||
"express": "^5.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"peggy": "^4.2.0",
|
||||
"rrule": "^2.8.1",
|
||||
"yaml": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ buildNpmPackage {
|
||||
pname = "obsidian-tasks-reminder";
|
||||
version = "0.0.1";
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-d8uZWYmroWoju976WXnCaYX+0uTLK/tc6hS/WgEHv/o=";
|
||||
npmDepsHash = "sha256-qSCP2eerP9oQcpLsEj2XE2X6vap8MMTuydzWItlAHuA=";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import { BackendConfig } from "../types/config";
|
||||
import { Notification } from "../types/notification";
|
||||
import { BackendSettings, Config, ProfileConfig } from "../types/config";
|
||||
import { Task } from "../types/task";
|
||||
|
||||
export abstract class Backend {
|
||||
constructor(config: BackendConfig) {
|
||||
export abstract class Backend<C extends BackendSettings> {
|
||||
public abstract readonly name: string;
|
||||
protected abstract requiredFields: readonly (keyof C)[];
|
||||
protected abstract notify(config: Config, profileConfig: ProfileConfig, backendConfig: C, task: Task): void;
|
||||
|
||||
#validate(backendConfig: Partial<C>): C {
|
||||
for (const field of this.requiredFields) {
|
||||
if (backendConfig[field] === undefined) {
|
||||
throw new Error(`The '${String(field)}' configuration field of'${this.name}' consumer is required`)
|
||||
}
|
||||
}
|
||||
|
||||
return backendConfig as C;
|
||||
}
|
||||
|
||||
abstract notify(notification: Notification): void;
|
||||
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;
|
||||
}
|
||||
|
||||
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
13
src/backend/debug.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,34 @@
|
||||
export { Backend } from "./base";
|
||||
export { NtfySH } from "./ntfy-sh";
|
||||
import dayjs from "dayjs";
|
||||
import { Task, TaskDatabase } from "../types/task";
|
||||
import { Config, ProfileConfig } from "../types/config";
|
||||
import { NtfySH } from "./ntfy";
|
||||
import { Debug } from "./debug";
|
||||
|
||||
const backends = [
|
||||
new Debug(),
|
||||
new NtfySH()
|
||||
];
|
||||
|
||||
/**
|
||||
* Iterates through all the database notifications for current time
|
||||
* and triggers the notification using specified backends in the config.
|
||||
*/
|
||||
export async function remind(config: Config, profileConfig: ProfileConfig, db: TaskDatabase) {
|
||||
const now = dayjs().format("HH:mm");
|
||||
|
||||
await run(config, profileConfig, db[now]);
|
||||
|
||||
if(profileConfig?.defaultTime && profileConfig?.defaultTime === now) {
|
||||
run(config, profileConfig, db._);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(config: Config, profileConfig: ProfileConfig, tasks?: Task[]) {
|
||||
if(!tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const backend of backends) {
|
||||
await backend.remind(config, profileConfig, tasks);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Notification } from "../types/notification";
|
||||
import { Backend } from "./base";
|
||||
|
||||
type Config = {
|
||||
url: string;
|
||||
token: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export class NtfySH extends Backend {
|
||||
#config: Config;
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
notify(notification: Notification): void {
|
||||
fetch(`https://${this.#config.url}/${this.#config.topic}`, {
|
||||
method: 'POST',
|
||||
body: notification.text,
|
||||
headers: {
|
||||
'Title': notification.title,
|
||||
'Authorization': `Bearer ${this.#config.token}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
197
src/backend/ntfy.ts
Normal file
197
src/backend/ntfy.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import fs from "fs";
|
||||
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";
|
||||
import { profile } from "console";
|
||||
|
||||
type NtfyConfig = {
|
||||
url: string;
|
||||
token: string;
|
||||
map?: string;
|
||||
combineMap?: string;
|
||||
topic?: string;
|
||||
completion?: {
|
||||
completeButton?: string;
|
||||
completeButton1?: string;
|
||||
completeButton2?: string;
|
||||
completeButton3?: string;
|
||||
}
|
||||
} & BackendSettings;
|
||||
|
||||
type NtfyDTO = {
|
||||
title?: string;
|
||||
text?: string;
|
||||
priority?: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export class NtfySH extends Backend<NtfyConfig> {
|
||||
public name = "ntfy";
|
||||
|
||||
protected requiredFields = ['url', 'token'] as const;
|
||||
|
||||
protected notify(config: Config, profileConfig: ProfileConfig, backendConfig: NtfyConfig, task: Task): void {
|
||||
const context = {
|
||||
mapPriority,
|
||||
template: defaultMapper
|
||||
};
|
||||
|
||||
const mapper = backendConfig.map
|
||||
? jsMapper<object, NtfyDTO>(backendConfig.map, context)
|
||||
: defaultMapper;
|
||||
|
||||
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',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
topic: backendConfig.topic || 'obsidian',
|
||||
message: dto.text ?? "",
|
||||
actions
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function mapPriority(priority?: TaskPriority): string {
|
||||
if (!priority) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return {
|
||||
[TaskPriority.LOWEST]: 'min',
|
||||
[TaskPriority.LOW]: 'low',
|
||||
[TaskPriority.NORMAL]: 'default',
|
||||
[TaskPriority.MEDIUM]: 'default',
|
||||
[TaskPriority.HIGH]: 'high',
|
||||
[TaskPriority.HIGHEST]: 'max',
|
||||
}[priority];
|
||||
}
|
||||
79
src/cli/index.ts
Normal file
79
src/cli/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { program } from "commander";
|
||||
import { CLIOptions } from "../types/cli";
|
||||
|
||||
import { loadConfig } from "../config";
|
||||
import { notify, scan, test } from "../runner";
|
||||
import { CronJob } from "cron";
|
||||
import { startServer } from "../server";
|
||||
import { Config } from "../types/config";
|
||||
|
||||
const getOptions = () => program
|
||||
.name("obsidian-tasks-reminder")
|
||||
.version("0.0.1")
|
||||
.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("-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 and exits immediately")
|
||||
.option("-p, --profile <name>", "limits the current operation only to specified profile. If missing, all profiles will be affected")
|
||||
.parse()
|
||||
.opts<CLIOptions>();
|
||||
|
||||
export const run = async () => {
|
||||
const options = getOptions();
|
||||
const config = loadConfig(options.config);
|
||||
|
||||
for (const override of options.set) {
|
||||
const [path, value] = override.split("=");
|
||||
|
||||
const segments = path.trim().split(".")
|
||||
|
||||
let current: any = config;
|
||||
segments.map(s => s.trim()).forEach((segment: string, idx) => {
|
||||
if(!current[segment]) {
|
||||
current[segment] = {};
|
||||
}
|
||||
|
||||
if(idx === segments.length - 1) {
|
||||
current[segment] = JSON.parse(value.trim());
|
||||
}
|
||||
|
||||
current = current[segment];
|
||||
});
|
||||
}
|
||||
|
||||
if (options.test) {
|
||||
await test(config, options.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.scan) {
|
||||
await scan(config, options.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.notify) {
|
||||
await notify(config, options.profile);
|
||||
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);
|
||||
}
|
||||
16
src/config/index.ts
Normal file
16
src/config/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import fs from "fs";
|
||||
import yaml from "yaml";
|
||||
import { Config } from "../types/config";
|
||||
|
||||
export function loadConfig(file: string): Config {
|
||||
const text = fs.readFileSync(file, 'utf-8');
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "fs";
|
||||
import { NotificationDatabase } from "../types/notification";
|
||||
import { TaskDatabase } from "../types/task";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
return deserializeDatabase(text);
|
||||
}
|
||||
@@ -12,6 +12,6 @@ export function loadDatabase(file: string): NotificationDatabase {
|
||||
/**
|
||||
* Deserializes database from JSON format.
|
||||
*/
|
||||
export function deserializeDatabase(json: string): NotificationDatabase {
|
||||
return JSON.parse(json) as NotificationDatabase;
|
||||
export function deserializeDatabase(json: string): TaskDatabase {
|
||||
return JSON.parse(json) as TaskDatabase;
|
||||
}
|
||||
@@ -1,34 +1,53 @@
|
||||
import fs from "fs";
|
||||
import { Task } from "../types/task";
|
||||
import { Notification, NotificationDatabase } from "../types/notification";
|
||||
import { Task, TaskDatabase } from "../types/task";
|
||||
|
||||
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.
|
||||
*/
|
||||
export function dumpDatabase(file: string, tasks: Task[], mapper: (task: Task) => Notification[]) {
|
||||
const data = serializeDatabase(tasks, mapper);
|
||||
export function dumpDatabase(file: string, tasks: Task[]) {
|
||||
const data = serializeDatabase(tasks);
|
||||
fs.writeFileSync(file, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the mapper for each task from list, groups them by time and serializes into JSON format.
|
||||
*/
|
||||
export function serializeDatabase(tasks: Task[], mapper: (task: Task) => Notification[]): string {
|
||||
const output = tasks.flatMap(wrapWithTimeFiller(mapper)).reduce((acc, n) => {
|
||||
if (n.time) {
|
||||
(acc[n.time] = (acc[n.time] || [])).push(n);
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as NotificationDatabase);
|
||||
|
||||
return JSON.stringify(output);
|
||||
export function serializeDatabase(tasks: Task[]): string {
|
||||
return JSON.stringify(createDatabase(tasks));
|
||||
}
|
||||
|
||||
function wrapWithTimeFiller(mapper: (task: Task) => Notification[]): (task: Task) => Notification[] {
|
||||
return (task: Task) => mapper(task)
|
||||
.map(notification => ({
|
||||
...notification,
|
||||
time: task.reminder ?? notification.time,
|
||||
}));
|
||||
/**
|
||||
* Applies the mapper for each task from list and groups them by time.
|
||||
*/
|
||||
export function createDatabase(tasks: Task[]): TaskDatabase {
|
||||
return tasks
|
||||
.flatMap(flatTimes)
|
||||
.reduce(pushTaskToDatabase, {} as TaskDatabase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list pairs of input task and consecutive reminder times.
|
||||
* If reminder is missing, returns empty array.
|
||||
* If reminder is empty array, returns ['_'], where '_' designates the default time.
|
||||
* @param task input task
|
||||
* @returns list pairs of input task and consecutive reminder times
|
||||
*/
|
||||
function flatTimes(task: Task): FlatReminder[] {
|
||||
if (task.reminder === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return task.reminder.length === 0
|
||||
? [{ task, time: '_' }]
|
||||
: task.reminder.map(time => ({ task, time }));
|
||||
}
|
||||
|
||||
function pushTaskToDatabase(db: TaskDatabase, { task, time }: FlatReminder): TaskDatabase {
|
||||
(db[time] = db[time] ?? []).push(task);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { run } from "./cli";
|
||||
|
||||
run();
|
||||
@@ -1,15 +1,14 @@
|
||||
import fs from "fs";
|
||||
import dayjs from "dayjs";
|
||||
import { createInterface } from "readline";
|
||||
import { parse } from "../generated/grammar/task";
|
||||
import { Task as DefaultTask } from "../model";
|
||||
import { ParseResult } from "../types/grammar";
|
||||
import { DynamicTask } from "../model";
|
||||
import { Task, TaskPriority } from "../types/task";
|
||||
import { jsMapper } from "../util/code";
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
now: dayjs(),
|
||||
LOWEST: TaskPriority.LOWEST,
|
||||
@@ -20,44 +19,38 @@ export async function loadTasks(directories: string[], query?: string): Promise<
|
||||
HIGHEST: TaskPriority.HIGHEST
|
||||
};
|
||||
|
||||
const filter = query && createFilter(query, ctx);
|
||||
const tasks = await Promise.all(directories.map(readTasksFromDirectory));
|
||||
const excludeFn = exclude ? jsMapper<string, boolean>(exclude, {}) : () => false;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a filter function for tasks using a query string and context object.
|
||||
* All context' properties will be passed as variables to the query string.
|
||||
*/
|
||||
function createFilter(query: string, context: Record<string, unknown>): (task: Task) => boolean {
|
||||
const filter = new Function('$', ...Object.keys(context), `return ${query};`);
|
||||
return (task: Task) => filter(task, ...Object.values(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all files in specific directory and returns all tasks from those files.
|
||||
*/
|
||||
async function readTasksFromDirectory(directory: string): Promise<Task[]> {
|
||||
return walk(directory, readTasksFromFile);
|
||||
function readTasksFromDirectory(excludeFn: (path: string) => boolean) {
|
||||
return async (directory: string) => walk(directory, readTasksFromFile, excludeFn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks through a specific directory recursively and invokes visitor on each file.
|
||||
* 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 = [];
|
||||
|
||||
for(const file of fs.readdirSync(directory)) {
|
||||
const path = `${directory}/${file}`;
|
||||
|
||||
if (fs.statSync(path).isDirectory()) {
|
||||
const items = await walk(path, visitor);
|
||||
list.push(...items);
|
||||
} else {
|
||||
const items = await visitor(path);
|
||||
const items = await walk(path, visitor, excludeFn);
|
||||
list.push(...items);
|
||||
} else if (path.endsWith("md") || (path.endsWith("MD"))) {
|
||||
if (!excludeFn(path)) {
|
||||
const items = await visitor(path);
|
||||
list.push(...items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,29 +69,29 @@ async function readTasksFromFile(path: string): Promise<Task[]> {
|
||||
});
|
||||
|
||||
const list: Task[] = [];
|
||||
|
||||
let lineNumber = 1;
|
||||
|
||||
for await (const line of lines) {
|
||||
const task = parseTask(line);
|
||||
try {
|
||||
const task = DynamicTask.parse(line, path, lineNumber);
|
||||
|
||||
if(task) {
|
||||
list.push(task);
|
||||
if(task) {
|
||||
list.push(task);
|
||||
}
|
||||
} catch(e: any) {
|
||||
console.warn(`Parsing error in file '${path}', for line:`);
|
||||
console.warn(line);
|
||||
if(e.location) {
|
||||
console.warn(' '.repeat(e.location.start.column + 2) + "^" + '~'.repeat(e.location.end.column - e.location.start.column - 1) + "^")
|
||||
}
|
||||
console.warn(e.message);
|
||||
console.warn("This line will be ignored. Please check the source and adjust it accordingly.");
|
||||
} finally {
|
||||
lineNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {LazyTask as Task} from "./task";
|
||||
export { DynamicTask } from "./task";
|
||||
@@ -1,22 +1,32 @@
|
||||
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 { parse } from "../generated/grammar/task";
|
||||
|
||||
export class LazyTask implements Task {
|
||||
#parsed: ParsedTask;
|
||||
#dates: ParsedTaskDate[];
|
||||
|
||||
constructor(task: ParsedTask) {
|
||||
this.#parsed = task;
|
||||
this.#dates = task.meta.filter(x => x.feature === 'date');
|
||||
export class DynamicTask implements Task {
|
||||
protected parsed: ParsedTask;
|
||||
#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 {
|
||||
return this.#parsed.status;
|
||||
return this.parsed.status;
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return this.#parsed.label
|
||||
return this.parsed.label
|
||||
.filter(x => x.span === 'word' || x.span === 'whitespace')
|
||||
.map(x => x.value)
|
||||
.join("")
|
||||
@@ -24,20 +34,20 @@ export class LazyTask implements Task {
|
||||
}
|
||||
|
||||
get fullLabel(): string {
|
||||
return this.#parsed.label
|
||||
return this.parsed.label
|
||||
.map(x => x.span === 'tag' ? `#${x.value}` : x.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
get tags(): string[] {
|
||||
return this.#parsed.label
|
||||
return this.parsed.label
|
||||
.filter(x => x.span === 'tag')
|
||||
.map(x => x.value);
|
||||
}
|
||||
|
||||
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) {
|
||||
return "normal";
|
||||
@@ -53,7 +63,7 @@ export class LazyTask implements Task {
|
||||
}
|
||||
|
||||
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) {
|
||||
return TaskPriority.NORMAL;
|
||||
@@ -99,11 +109,11 @@ export class LazyTask implements Task {
|
||||
}
|
||||
|
||||
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 {
|
||||
return this.#parsed.meta.find(x => x.feature === 'delete')?.action;
|
||||
return this.parsed.meta.find(x => x.feature === 'delete')?.action;
|
||||
}
|
||||
|
||||
get id(): string|undefined {
|
||||
@@ -111,17 +121,79 @@ export class LazyTask implements Task {
|
||||
}
|
||||
|
||||
#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[] {
|
||||
return this.#features<ParsedTaskDependency>("dependency").find(x => x.type === '⛔')?.deps || [];
|
||||
}
|
||||
|
||||
get reminder(): string|undefined {
|
||||
return this.#parsed.meta.find(x => x.feature === 'reminder')?.time;
|
||||
get reminder(): string[]|undefined {
|
||||
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 {
|
||||
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("delete", this.onDelete),
|
||||
o("id", this.id),
|
||||
o("deps", this.dependsOn.join(",")),
|
||||
o("reminder", this.reminder),
|
||||
o("deps", this.dependsOn?.join(",")),
|
||||
o("reminder", this.reminder?.join(",")),
|
||||
o("tags", this.tags.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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { NotificationDatabase } from "../types/notification";
|
||||
import { Backend } from "../backend";
|
||||
|
||||
/**
|
||||
* Iterates through all the database notifications for current time
|
||||
* and triggers the notification using specified backend.
|
||||
*/
|
||||
export async function remind(db: NotificationDatabase, backend: Backend) {
|
||||
const now = dayjs().format("HH:mm");
|
||||
const notifications = db[now] ?? [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
backend.notify(notification);
|
||||
await snooze(1500);
|
||||
}
|
||||
}
|
||||
|
||||
function snooze(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
46
src/runner/index.ts
Normal file
46
src/runner/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { loadTasks } from "../loader";
|
||||
import { createDatabase, dumpDatabase } from "../database/serializer";
|
||||
import { loadDatabase } from "../database/deserializer";
|
||||
import { remind } from "../backend";
|
||||
import { Config, ProfileConfig } from "../types/config";
|
||||
|
||||
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)) {
|
||||
console.log(time);
|
||||
|
||||
for (const task of db[time]) {
|
||||
console.log(task.toString());
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
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, profileConfig) => {
|
||||
const db = loadDatabase(profileConfig.databaseFile);
|
||||
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
22
src/server/index.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
498
src/services/recurrence.service.ts
Normal file
498
src/services/recurrence.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
81
src/services/task.service.ts
Normal file
81
src/services/task.service.ts
Normal 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()];
|
||||
}
|
||||
8
src/types/cli.ts
Normal file
8
src/types/cli.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type CLIOptions = {
|
||||
config: string;
|
||||
profile?: string;
|
||||
test: boolean;
|
||||
scan: boolean;
|
||||
notify: boolean;
|
||||
set: string[];
|
||||
};
|
||||
@@ -1 +1,41 @@
|
||||
export type BackendConfig = Record<string, unknown>;
|
||||
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[];
|
||||
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;
|
||||
};
|
||||
|
||||
export type SupportedBackends = 'ntfy.sh';
|
||||
export type BackendConfig = {
|
||||
backend: SupportedBackends;
|
||||
settings: BackendSettings;
|
||||
}
|
||||
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
|
||||
};
|
||||
@@ -54,5 +54,5 @@ export type ParsedTaskDependency = {
|
||||
|
||||
export type ParsedTaskReminder = {
|
||||
feature: 'reminder';
|
||||
time: string;
|
||||
time: string[];
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs"
|
||||
|
||||
export type Notification = {
|
||||
time?: string;
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type NotificationDatabase = Record<string, Notification[]>;
|
||||
@@ -17,8 +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,
|
||||
@@ -27,4 +30,6 @@ export enum TaskPriority {
|
||||
MEDIUM,
|
||||
HIGH,
|
||||
HIGHEST,
|
||||
};
|
||||
};
|
||||
|
||||
export type TaskDatabase = Record<string, Task[]>;
|
||||
11
src/util/code.ts
Normal file
11
src/util/code.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const standardContext = {
|
||||
dayjs
|
||||
};
|
||||
|
||||
export function jsMapper<I, O>(code: string, context: Record<string, unknown>): (task: I) => O {
|
||||
const ctx = { ...standardContext, ...context };
|
||||
const filter = new Function('$', ...Object.keys(ctx), code);
|
||||
return (task: I) => filter(task, ...Object.values(ctx));
|
||||
}
|
||||
17
src/util/config.ts
Normal file
17
src/util/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const specialOptions: Record<string, (text: string) => string> = {
|
||||
$__file: (arg: string) => readFileSync(arg, 'utf8').trim()
|
||||
};
|
||||
|
||||
export const enhancedStringConfig = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
for(const opt of Object.keys(specialOptions)) {
|
||||
if(trimmed.startsWith(`${opt}:`) && opt in specialOptions) {
|
||||
return specialOptions[opt](trimmed.slice(opt.length + 1).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2023",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
Reference in New Issue
Block a user