diff --git a/data/reminder_check.testdata.json b/data/reminder_check.testdata.json
new file mode 100644
index 0000000..0d93f3d
--- /dev/null
+++ b/data/reminder_check.testdata.json
@@ -0,0 +1,218 @@
+[
+ {
+ "name": "reminder_check.dueness-1",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 15,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": null
+ },
+ {
+ "name": "reminder_check.dueness-2",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 16,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": []
+ },
+ {
+ "name": "reminder_check.dueness-3",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 17,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": null
+ },
+ {
+ "name": "reminder_check.events-1",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ {
+ "title": "e1",
+ "begin": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 12,
+ "minute": 0,
+ "second": 0
+ }
+ }
+ }
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 16,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": []
+ },
+ {
+ "name": "reminder_check.events-2",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ {
+ "title": "e1",
+ "begin": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 28
+ },
+ "time": {
+ "hour": 12,
+ "minute": 0,
+ "second": 0
+ }
+ }
+ }
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 16,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": ["e1"]
+ },
+ {
+ "name": "reminder_check.events-3",
+ "input": {
+ "reminder": {
+ "frequency": "daily",
+ "offset": 16,
+ "from": 24,
+ "to": 48
+ },
+ "events": [
+ {
+ "title": "e1",
+ "begin": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 29
+ },
+ "time": {
+ "hour": 12,
+ "minute": 0,
+ "second": 0
+ }
+ }
+ }
+ ],
+ "datetime": {
+ "timezone_shift": 0,
+ "date": {
+ "year": 2025,
+ "month": 6,
+ "day": 27
+ },
+ "time": {
+ "hour": 16,
+ "minute": 30,
+ "second": 0
+ }
+ },
+ "interval": 1
+ },
+ "output": []
+ }
+]
diff --git a/ivaldi.json b/ivaldi.json
index bcedb2f..c3c033f 100644
--- a/ivaldi.json
+++ b/ivaldi.json
@@ -12,6 +12,8 @@
"email",
"telegram",
"url",
+ "json",
+ "file",
"conf",
"log",
"args"
@@ -20,6 +22,7 @@
}
],
"sources": [
+ "helpers/test.ts",
"types.ts",
"sources/ical_feed.ts",
"sources/_functions.ts",
@@ -27,6 +30,8 @@
"targets/email.ts",
"targets/_functions.ts",
"conf.ts",
+ "logic.ts",
+ "test.ts",
"main.ts"
]
}
diff --git a/source/conf.ts b/source/conf.ts
index cd440d7..fdb581f 100644
--- a/source/conf.ts
+++ b/source/conf.ts
@@ -24,6 +24,24 @@ along with »munin«. If not, see .
namespace _munin.conf
{
+ /**
+ */
+ type type_reminder_raw = {
+ frequency : (
+ "hourly"
+ |
+ "daily"
+ |
+ "weekly"
+ |
+ "monthly"
+ );
+ offset : int;
+ from : int;
+ to : int;
+ };
+
+
/**
*/
type type_conf_v1 = {
@@ -236,7 +254,72 @@ namespace _munin.conf
/**
*/
- export type type_conf = type_conf_v4;
+ type type_conf_v5 = {
+ sources : Array<
+ (
+ {
+ kind : "ical_feed";
+ data : {
+ url : string;
+ filtration : {
+ category_whitelist : (null | Array);
+ category_blacklist : (null | Array);
+ title_whitelist : (null | Array);
+ title_blacklist : (null | Array);
+ }
+ }
+ }
+ )
+ >;
+ targets : Array<
+ (
+ {
+ kind : "email";
+ data : {
+ smtp_host : string;
+ smtp_port : int;
+ smtp_username : string;
+ smtp_password : string;
+ sender : string;
+ receivers : Array;
+ hide_tags : boolean;
+ /**
+ * in hours
+ */
+ reminders : Array;
+ }
+ }
+ |
+ {
+ kind : "telegram_bot";
+ data : {
+ bot_token : string;
+ chat_id : int;
+ hide_tags : boolean;
+ /**
+ * in hours
+ */
+ reminders : Array;
+ }
+ }
+ )
+ >;
+ settings : {
+ interval : float;
+ };
+ labels : {
+ head : string;
+ title : string;
+ time : string;
+ location : string;
+ events : string;
+ };
+ };
+
+
+ /**
+ */
+ export type type_conf = type_conf_v5;
/**
@@ -396,6 +479,65 @@ namespace _munin.conf
}
+ /**
+ */
+ function convert_from_v4(
+ conf_v4 : type_conf_v4
+ ) : type_conf_v5
+ {
+ const map_reminder = hours => ({
+ "frequency": "hourly",
+ "offset": 0,
+ "from": hours,
+ "to": (hours + 1),
+ } as type_reminder_raw);
+ return {
+ "sources": conf_v4.sources,
+ "targets": conf_v4.targets.map(
+ target => {
+ switch (target.kind) {
+ case "email": {
+ return {
+ "kind": "email",
+ "data": {
+ "smtp_host": target.data.smtp_host,
+ "smtp_port": target.data.smtp_port,
+ "smtp_username": target.data.smtp_username,
+ "smtp_password": target.data.smtp_password,
+ "sender": target.data.sender,
+ "receivers": target.data.receivers,
+ "hide_tags": target.data.hide_tags,
+ "reminders": target.data.reminders.map(map_reminder),
+ },
+ };
+ break;
+ }
+ case "telegram_bot": {
+ return {
+ "kind": "telegram_bot",
+ "data": {
+ "bot_token": target.data.bot_token,
+ "chat_id": target.data.chat_id,
+ "hide_tags": target.data.hide_tags,
+ "reminders": target.data.reminders.map(map_reminder),
+ },
+ };
+ break;
+ }
+ default: {
+ // return target;
+ throw (new Error("unhandled target kind: " + String(target)));
+ break;
+ }
+ }
+ }
+ ),
+ "settings": conf_v4.settings,
+ "labels": Object.assign({"events": "Termine"}, conf_v4.labels),
+ };
+ }
+
+
/**
*/
function schema_source_kalender_digital(
@@ -546,6 +688,136 @@ namespace _munin.conf
}
+ /**
+ */
+ function schema_sources(
+ version : string
+ ) : lib_plankton.conf.type_schema
+ {
+ switch (version) {
+ case "1": {
+ return {
+ "nullable": false,
+ "type": "array",
+ "items": {
+ "nullable": false,
+ "anyOf": [
+ schema_source_kalender_digital(version),
+ ],
+ }
+ };
+ break;
+ }
+ default:
+ case "2": {
+ return {
+ "nullable": false,
+ "type": "array",
+ "items": {
+ "nullable": false,
+ "anyOf": [
+ schema_source_ical_feed(version),
+ ],
+ }
+ };
+ break;
+ }
+ }
+ }
+
+
+ /**
+ */
+ function schema_reminder(
+ version : string
+ ) : lib_plankton.conf.type_schema
+ {
+ switch (version)
+ {
+ case "1":
+ case "2":
+ case "3":
+ case "4":
+ {
+ return {
+ "type": "integer",
+ };
+ }
+ case "5":
+ default:
+ {
+ return {
+ "nullable": false,
+ "type": "object",
+ "properties": {
+ "frequency": {
+ "nullable": false,
+ "type": "string",
+ "enum": [
+ "hourly",
+ "daily",
+ "weekly",
+ "monthly",
+ ]
+ },
+ "offset": {
+ "nullable": false,
+ "type": "integer",
+ "default": 0
+ },
+ "from": {
+ "nullable": false,
+ "type": "integer"
+ },
+ "to": {
+ "nullable": false,
+ "type": "integer"
+ },
+ },
+ "additionalProperties": false,
+ "required": [
+ "frequency",
+ "from",
+ "to",
+ ]
+ };
+ break;
+ }
+ }
+ }
+
+
+ /**
+ */
+ function default_reminder(
+ version : string
+ ) : any
+ {
+ switch (version)
+ {
+ case "1":
+ case "2":
+ case "3":
+ case "4":
+ {
+ return [24];
+ }
+ case "5":
+ default:
+ {
+ return [
+ {
+ "frequency": "hourly",
+ "from": 24,
+ "to": 25
+ }
+ ];
+ break;
+ }
+ }
+ }
+
+
/**
*/
function schema_target_email(
@@ -602,11 +874,8 @@ namespace _munin.conf
"reminders": {
"nullable": false,
"type": "array",
- "items": {
- "nullable": false,
- "type": "integer"
- },
- "default": [24.0],
+ "items": schema_reminder(version),
+ "default": default_reminder(version),
},
},
"additionalProperties": false,
@@ -628,44 +897,6 @@ namespace _munin.conf
}
- /**
- */
- function schema_sources(
- version : string
- ) : lib_plankton.conf.type_schema
- {
- switch (version) {
- case "1": {
- return {
- "nullable": false,
- "type": "array",
- "items": {
- "nullable": false,
- "anyOf": [
- schema_source_kalender_digital(version),
- ],
- }
- };
- break;
- }
- default:
- case "2": {
- return {
- "nullable": false,
- "type": "array",
- "items": {
- "nullable": false,
- "anyOf": [
- schema_source_ical_feed(version),
- ],
- }
- };
- break;
- }
- }
- }
-
-
/**
*/
function schema_target_telegram_bot(
@@ -701,11 +932,8 @@ namespace _munin.conf
"reminders": {
"nullable": false,
"type": "array",
- "items": {
- "nullable": false,
- "type": "integer"
- },
- "default": [24.0],
+ "items": schema_reminder(version),
+ "default": default_reminder(version),
},
},
"additionalProperties": false,
@@ -737,13 +965,18 @@ namespace _munin.conf
"nullable": false,
"anyOf": (() => {
switch (version) {
- default: {
+ case "1":
+ case "2":
+ {
return [
schema_target_telegram_bot(version),
];
break;
}
- case "3": {
+ case "4":
+ case "5":
+ default:
+ {
return [
schema_target_email(version),
schema_target_telegram_bot(version),
@@ -812,6 +1045,11 @@ namespace _munin.conf
"type": "string",
"default": "wo"
},
+ "events": {
+ "nullable": false,
+ "type": "string",
+ "default": "Termine"
+ },
},
"additionalProperties": false,
"required": [
@@ -824,7 +1062,7 @@ namespace _munin.conf
/**
*/
export function schema(
- version : string = "4"
+ version : string = "5"
) : lib_plankton.conf.type_schema
{
switch (version) {
@@ -852,7 +1090,9 @@ namespace _munin.conf
}
case "2":
case "3":
- case "4": {
+ case "4":
+ case "5":
+ {
return {
"nullable": false,
"type": "object",
@@ -887,7 +1127,8 @@ namespace _munin.conf
"1": {"target": "2", "function": convert_from_v1},
"2": {"target": "3", "function": convert_from_v2},
"3": {"target": "4", "function": convert_from_v3},
- "4": null,
+ "4": {"target": "5", "function": convert_from_v4},
+ "5": null,
}
);
@@ -925,7 +1166,7 @@ namespace _munin.conf
}
}
*/
- return (conf_raw.content as type_conf_v4);
+ return (conf_raw.content as type_conf_v5);
}
}
diff --git a/source/helpers/test.ts b/source/helpers/test.ts
new file mode 100644
index 0000000..5c87834
--- /dev/null
+++ b/source/helpers/test.ts
@@ -0,0 +1,124 @@
+/*
+This file is part of »munin«.
+
+Copyright 2025 'Fenris Wolf'
+
+»munin« is free software: you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+»munin« is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with »munin«. If not, see .
+ */
+
+
+namespace _munin.helpers.test
+{
+
+ /**
+ * @todo outsource
+ */
+ type type_testcase_raw = {
+ active ?: boolean;
+ name ?: string;
+ input : type_input;
+ output : type_output;
+ };
+
+
+ /**
+ * @todo outsource
+ */
+ export type type_testcase = {
+ name : string;
+ input : type_input;
+ output : type_output;
+ };
+
+
+ /**
+ * @todo outsource
+ */
+ export async function get_data(
+ path : string,
+ {
+ "default_active": default_active = true,
+ } : {
+ default_active ?: boolean;
+ } = {
+ }
+ ) : Promise>>
+ {
+ const content : string = await lib_plankton.file.read(path);
+ const testcases_raw : Array> = (
+ lib_plankton.json.decode(content) as Array>
+ );
+ const testcases : Array> = (
+ testcases_raw
+ .filter(
+ testcase_raw => (testcase_raw.active ?? default_active),
+ )
+ .map(
+ (testcase_raw, index) => ({
+ "name": (
+ testcase_raw.name
+ ??
+ lib_plankton.string.coin(
+ "{{path}} | #{{index}}",
+ {
+ "path": path,
+ "index": (index + 1).toFixed(0),
+ }
+ )
+ ),
+ "input": testcase_raw.input,
+ "output": testcase_raw.output,
+ })
+ )
+ );
+ return testcases;
+ }
+
+
+ /**
+ * @todo outsource
+ */
+ export function start(
+ name : string
+ ) : void
+ {
+ lib_plankton.log._info(
+ "test.run",
+ {
+ "details": {
+ "name": name,
+ }
+ }
+ );
+ }
+
+
+ /**
+ * @todo outsource
+ */
+ export function fail(
+ name : string
+ ) : void
+ {
+ lib_plankton.log._error(
+ "test.failed",
+ {
+ "details": {
+ "name": name,
+ }
+ }
+ );
+ }
+
+}
diff --git a/source/logic.ts b/source/logic.ts
new file mode 100644
index 0000000..2f5c874
--- /dev/null
+++ b/source/logic.ts
@@ -0,0 +1,265 @@
+/*
+This file is part of »munin«.
+
+Copyright 2025 'Fenris Wolf'
+
+»munin« is free software: you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+»munin« is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with »munin«. If not, see .
+ */
+
+
+namespace _munin.logic
+{
+
+ /**
+ */
+ export function frequency_decode(
+ frequency_encoded : string
+ ) : _munin.enum_frequency
+ {
+ switch (frequency_encoded)
+ {
+ case "hourly": return _munin.enum_frequency.hourly;
+ case "daily": return _munin.enum_frequency.daily;
+ case "weekly": return _munin.enum_frequency.weekly;
+ case "monthly": return _munin.enum_frequency.monthly;
+ default: {throw new Error("unhandled");}
+ }
+ }
+
+
+ /**
+ */
+ function frequency_anchor(
+ frequency : _munin.enum_frequency,
+ {
+ "pit": pit = lib_plankton.pit.now(),
+ } : {
+ pit ?: lib_plankton.pit.type_pit,
+ } = {
+ }
+ ) : lib_plankton.pit.type_pit
+ {
+ switch (frequency)
+ {
+ case _munin.enum_frequency.hourly: return lib_plankton.pit.trunc_hour(pit);
+ case _munin.enum_frequency.daily: return lib_plankton.pit.trunc_day(pit);
+ case _munin.enum_frequency.weekly: return lib_plankton.pit.trunc_week(pit);
+ case _munin.enum_frequency.monthly: return lib_plankton.pit.trunc_month(pit);
+ default:
+ {
+ throw (new Error("unhandled frequency: " + frequency));
+ break;
+ }
+ }
+ }
+
+
+ /**
+ */
+ export function reminder_check(
+ reminder : type_reminder,
+ events_all : Array<_munin.type_event>,
+ {
+ "pit": pit = lib_plankton.pit.now(),
+ "interval": interval = 1,
+ }
+ : {
+ pit ?: lib_plankton.pit.type_pit;
+ interval ?: int;
+ }
+ = {
+ }
+ ) : (null | Array<_munin.type_event>)
+ {
+ const anchor : lib_plankton.pit.type_pit = frequency_anchor(
+ reminder.frequency,
+ {"pit": pit}
+ );
+ const dueness_window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
+ anchor,
+ (reminder.offset + 0)
+ );
+ const dueness_window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
+ anchor,
+ (reminder.offset + interval)
+ );
+ const due : boolean = lib_plankton.pit.is_between(
+ pit,
+ dueness_window_from,
+ dueness_window_to
+ );
+ if (! due)
+ {
+ return null;
+ }
+ else
+ {
+ const events_window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
+ anchor,
+ reminder.from
+ );
+ const events_window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
+ anchor,
+ reminder.to
+ );
+ const events : Array<_munin.type_event> = (
+ events_all
+ .filter(
+ (event) => lib_plankton.pit.is_between(
+ lib_plankton.pit.from_datetime(event.begin),
+ events_window_from,
+ events_window_to
+ )
+ )
+ );
+ return events;
+ }
+ }
+
+
+ /**
+ */
+ async function run_iteration(
+ conf : _munin.conf.type_conf,
+ sources : Array<_munin.type_source>,
+ targets : Array<_munin.type_target>,
+ {
+ "dry_run": dry_run = false,
+ }
+ : {
+ dry_run ?: boolean;
+ }
+ = {
+ }
+ ) : Promise
+ {
+ const now : lib_plankton.pit.type_pit = lib_plankton.pit.now();
+ const events_all : Array<_munin.type_event> = (
+ (await Promise.all(sources.map(source => source.fetch())))
+ .reduce((x, y) => x.concat(y), [])
+ );
+ for (const target of targets)
+ {
+ for (const reminder of target.reminders)
+ {
+ const events : Array<_munin.type_event> = reminder_check(
+ reminder,
+ events_all,
+ {
+ "pit": now,
+ "interval": conf.settings.interval,
+ }
+ );
+ if (events === null)
+ {
+ lib_plankton.log._info(
+ "munin.run_iteration.reminder_not_due",
+ {
+ "details": {
+ "reminder": reminder,
+ }
+ }
+ );
+ }
+ else {
+ if (events.length <= 0)
+ {
+ lib_plankton.log._info(
+ "munin.run_iteration.no_matching_events",
+ {
+ "details": {
+ "reminder": reminder,
+ }
+ }
+ );
+ }
+ else {
+ lib_plankton.log._info(
+ "munin.run_iteration.remind",
+ {
+ "details": {
+ "reminder": reminder,
+ "events": events,
+ "target": target.show(),
+ }
+ }
+ );
+ if (dry_run)
+ {
+ // do nothing
+ }
+ else
+ {
+ try
+ {
+ await target.send(conf.labels, events);
+ }
+ catch (error)
+ {
+ lib_plankton.log.error(
+ "munin.remind.error",
+ {
+ "details": {
+ "message": String(error),
+ }
+ }
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+
+ /**
+ */
+ export async function run(
+ conf : _munin.conf.type_conf,
+ {
+ "single_run": single_run = false,
+ "dry_run": dry_run = false,
+ } : {
+ single_run ?: boolean;
+ dry_run ?: boolean;
+ } = {
+ }
+ ) : Promise
+ {
+ const sources : Array<_munin.type_source> = conf.sources.map(
+ source_raw => _munin.sources.factory(source_raw)
+ );
+ const targets : Array<_munin.type_target> = conf.targets.map(
+ target_raw => _munin.targets.factory(target_raw)
+ );
+ lib_plankton.log._info(
+ "munin.run.start",
+ {
+ "details": {
+ }
+ }
+ );
+ if (single_run) {
+ await run_iteration(conf, sources, targets, {"dry_run": dry_run});
+ }
+ else {
+ while (true) {
+ await run_iteration(conf, sources, targets, {"dry_run": dry_run});
+ await lib_plankton.call.sleep(conf.settings.interval * 60 * 60);
+ }
+ }
+ }
+
+}
diff --git a/source/main.ts b/source/main.ts
index f100ac2..af737a4 100644
--- a/source/main.ts
+++ b/source/main.ts
@@ -20,146 +20,7 @@ along with »munin«. If not, see .
namespace _munin
{
-
- /**
- */
- async function run_iteration(
- conf : _munin.conf.type_conf,
- sources : Array<_munin.type_source>,
- targets : Array<_munin.type_target>,
- {
- "dry_run": dry_run = false,
- } : {
- dry_run ?: boolean;
- } = {
- }
- ) : Promise
- {
- const now : lib_plankton.pit.type_pit = lib_plankton.pit.now();
- const events : Array<_munin.type_event> = (
- (await Promise.all(sources.map(source => source.fetch())))
- .reduce((x, y) => x.concat(y), [])
- );
- for (const target of targets) {
- const window_from : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
- now,
- 0
- );
- const window_to : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
- now,
- conf.settings.interval
- );
- lib_plankton.log._info(
- "munin.run.iteration",
- {
- "details": {
- "target": target.show(),
- "window_from": lib_plankton.pit.to_date_object(window_from).toISOString(),
- "window_to": lib_plankton.pit.to_date_object(window_to).toISOString(),
- }
- }
- );
- for (const reminder_hours of target.reminders) {
- for (const event of events) {
- const event_begin : lib_plankton.pit.type_pit = lib_plankton.pit.from_datetime(
- event.begin
- );
- const reminder_time : lib_plankton.pit.type_pit = lib_plankton.pit.shift_hour(
- event_begin,
- (-reminder_hours)
- );
- lib_plankton.log._info(
- "munin.run.check_dueness",
- {
- "details": {
- "event_begin": lib_plankton.pit.to_date_object(event_begin).toISOString(),
- "reminder_hours": reminder_hours,
- "reminder_time": lib_plankton.pit.to_date_object(reminder_time).toISOString(),
- }
- }
- );
- const remind : boolean = lib_plankton.pit.is_between(
- reminder_time,
- window_from,
- window_to
- );
- if (! remind) {
- // do nothing
- }
- else {
- lib_plankton.log._info(
- "munin.remind.do",
- {
- "details": {
- "event": event,
- "target": target.show(),
- }
- }
- );
- if (dry_run) {
- // do nothing
- }
- else {
- try {
- await target.send(conf.labels, event);
- }
- catch (error) {
- lib_plankton.log.error(
- "munin.remind.error",
- {
- "details": {
- "message": String(error),
- }
- }
- );
- }
- }
- }
- }
- }
- }
- }
-
-
- /**
- */
- async function run(
- conf : _munin.conf.type_conf,
- {
- "single_run": single_run = false,
- "dry_run": dry_run = false,
- } : {
- single_run ?: boolean;
- dry_run ?: boolean;
- } = {
- }
- ) : Promise
- {
- const sources : Array<_munin.type_source> = conf.sources.map(
- source_raw => _munin.sources.factory(source_raw)
- );
- const targets : Array<_munin.type_target> = conf.targets.map(
- target_raw => _munin.targets.factory(target_raw)
- );
- lib_plankton.log._info(
- "munin.run.start",
- {
- "details": {
- }
- }
- );
- if (single_run) {
- await run_iteration(conf, sources, targets, {"dry_run": dry_run});
- }
- else {
- while (true) {
- await run_iteration(conf, sources, targets, {"dry_run": dry_run});
- await lib_plankton.call.sleep(conf.settings.interval * 60 * 60);
- }
- }
- }
-
-
+
/**
*/
export async function main(
@@ -169,16 +30,14 @@ namespace _munin
// args
const arg_handler : lib_plankton.args.class_handler = new lib_plankton.args.class_handler(
{
- /*
"action": lib_plankton.args.class_argument.positional({
"index": 0,
"type": lib_plankton.args.enum_type.string,
"mode": lib_plankton.args.enum_mode.replace,
"default": "run",
- "info": "what to do : help | run",
+ "info": "what to do : test | run",
"name": "action",
}),
- */
"conf_path": lib_plankton.args.class_argument.volatile({
"indicators_long": ["conf-path"],
"indicators_short": ["c"],
@@ -274,67 +133,77 @@ namespace _munin
);
}
else {
- // init
- const conf : _munin.conf.type_conf = await _munin.conf.load(args.conf_path);
- lib_plankton.log.set_main_logger(
- [
- {
- "kind": "filtered",
- "data": {
- "core": {
- "kind": "std",
- "data": {
- "target": "stdout",
- "format": {
- "kind": "human_readable",
- "data": {
- }
- }
- }
- },
- "predicate": [
- [
- {
- "item": {
- "kind": "level",
- "data": {
- "threshold": args.verbosity,
- }
- },
- },
- ]
- ],
- }
- },
- ]
- );
-
- // exec
- if (args.conf_expose) {
- process.stdout.write(
- lib_plankton.json.encode(
- conf,
- {
- "formatted": true,
- }
- )
- +
- "\n"
- );
- }
- switch (/*args.action*/"run") {
- default: {
+ switch (args.action) {
+ default:
+ {
throw (new Error("unhandled action: " + args.action));
break;
}
- case "run": {
- run(
+ case "test":
+ {
+ _munin.test.all();
+ break;
+ }
+ case "run":
+ {
+ // init
+ const conf : _munin.conf.type_conf = await _munin.conf.load(args.conf_path);
+ lib_plankton.log.set_main_logger(
+ [
+ {
+ "kind": "filtered",
+ "data": {
+ "core": {
+ "kind": "std",
+ "data": {
+ "target": "stdout",
+ "format": {
+ "kind": "jsonl",
+ "data": {
+ "structured": true
+ }
+ }
+ }
+ },
+ "predicate": [
+ [
+ {
+ "item": {
+ "kind": "level",
+ "data": {
+ "threshold": args.verbosity,
+ }
+ },
+ },
+ ]
+ ],
+ }
+ },
+ ]
+ );
+
+ // exec
+ if (args.conf_expose) {
+ process.stdout.write(
+ lib_plankton.json.encode(
+ conf,
+ {
+ "formatted": true,
+ }
+ )
+ +
+ "\n"
+ );
+ }
+
+ _munin.logic.run(
conf,
{
"single_run": args.single_run,
"dry_run": args.dry_run,
}
);
+
break;
}
}
diff --git a/source/targets/email.ts b/source/targets/email.ts
index b9d33fa..4e29c7c 100644
--- a/source/targets/email.ts
+++ b/source/targets/email.ts
@@ -31,19 +31,103 @@ namespace _munin.targets.email
sender : string;
receivers : Array;
hide_tags : boolean;
- /**
- * in hours
- */
- reminders : Array;
+ reminders : Array<_munin.type_reminder>;
};
+ /**
+ */
+ function summarize_event(
+ parameters : type_parameters,
+ labels : _munin.type_labels,
+ event : _munin.type_event
+ ) : string
+ {
+ return lib_plankton.string.coin(
+ "[{{head}}] {{date}} : {{macro_tags}}{{title}}",
+ {
+ "head": labels.head,
+ "date": lib_plankton.pit.date_format(
+ event.begin.date
+ ),
+ "macro_tags": (
+ (event.tags === null)
+ ?
+ ""
+ :
+ (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
+ ),
+ "title": event.title,
+ }
+ );
+ }
+
+
+ /**
+ */
+ function render_event(
+ parameters : type_parameters,
+ labels : _munin.type_labels,
+ event : _munin.type_event
+ ) : string
+ {
+ return lib_plankton.string.coin(
+ "{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}",
+ {
+ "title_label": labels.title.toUpperCase(),
+ "macro_tags": (
+ (parameters.hide_tags || (event.tags === null))
+ ?
+ ""
+ :
+ (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
+ ),
+ "title_value": event.title,
+ "time_label": labels.time.toUpperCase(),
+ "time_value": lib_plankton.pit.timespan_format(
+ event.begin,
+ event.end,
+ {
+ "adjust_to_ce": true,
+ }
+ ),
+ "macro_location": (
+ (event.location === null)
+ ?
+ ""
+ :
+ lib_plankton.string.coin(
+ "\n{{location_label}} | {{location_value}}",
+ {
+ "location_label": labels.location.toUpperCase(),
+ "location_value": event.location,
+ }
+ )
+ ),
+ "macro_description": (
+ (event.description === null)
+ ?
+ ""
+ :
+ lib_plankton.string.coin(
+ "\n\n{{description_value}}",
+ {
+ "description_value": event.description,
+ }
+ )
+ ),
+ }
+ );
+ }
+
+
+
/**
*/
async function send(
parameters : type_parameters,
labels : _munin.type_labels,
- event : _munin.type_event
+ events : Array<_munin.type_event>
) : Promise
{
await lib_plankton.email.send(
@@ -55,69 +139,24 @@ namespace _munin.targets.email
},
parameters.sender,
parameters.receivers,
- lib_plankton.string.coin(
- "[{{head}}] {{date}} : {{macro_tags}}{{title}}",
- {
- "head": labels.head,
- "date": lib_plankton.pit.date_format(
- event.begin.date
- ),
- "macro_tags": (
- (event.tags === null)
- ?
- ""
- :
- (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
- ),
- "title": event.title,
- }
+ (
+ (events.length === 1)
+ ?
+ summarize_event(parameters, labels, events[0])
+ :
+ lib_plankton.string.coin(
+ "[{{head}}] {{count}} {{events}}",
+ {
+ "head": labels.head,
+ "count": events.length.toFixed(0),
+ "events": labels.events,
+ }
+ )
),
- lib_plankton.string.coin(
- "{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}",
- {
- "title_label": labels.title.toUpperCase(),
- "macro_tags": (
- (parameters.hide_tags || (event.tags === null))
- ?
- ""
- :
- (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
- ),
- "title_value": event.title,
- "time_label": labels.time.toUpperCase(),
- "time_value": lib_plankton.pit.timespan_format(
- event.begin,
- event.end,
- {
- "adjust_to_ce": true,
- }
- ),
- "macro_location": (
- (event.location === null)
- ?
- ""
- :
- lib_plankton.string.coin(
- "\n{{location_label}} | {{location_value}}",
- {
- "location_label": labels.location.toUpperCase(),
- "location_value": event.location,
- }
- )
- ),
- "macro_description": (
- (event.description === null)
- ?
- ""
- :
- lib_plankton.string.coin(
- "\n\n{{description_value}}",
- {
- "description_value": event.description,
- }
- )
- ),
- }
+ (
+ events
+ .map(event => render_event(parameters, labels, event))
+ .join("\n\n---\n\n")
)
);
}
@@ -137,7 +176,7 @@ namespace _munin.targets.email
"receivers": parameters.receivers.join(","),
}
),
- "send": (labels, event) => send(parameters, labels, event),
+ "send": (labels, events) => send(parameters, labels, events),
};
}
diff --git a/source/targets/telegram_bot.ts b/source/targets/telegram_bot.ts
index df24da4..f46c164 100644
--- a/source/targets/telegram_bot.ts
+++ b/source/targets/telegram_bot.ts
@@ -27,66 +27,100 @@ namespace _munin.targets.telegram_bot
bot_token : string;
chat_id : int;
hide_tags : boolean;
- reminders : Array;
+ reminders : Array<_munin.type_reminder>;
};
+ /**
+ */
+ function render_event(
+ parameters : type_parameters,
+ labels : _munin.type_labels,
+ event : _munin.type_event
+ ) : string
+ {
+ return lib_plankton.string.coin(
+ "{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}",
+ {
+ "macro_tags": (
+ (parameters.hide_tags || (event.tags === null))
+ ?
+ ""
+ :
+ (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
+ ),
+ "title_label": labels.title.toUpperCase(),
+ "title_value": event.title,
+ "time_label": labels.time.toUpperCase(),
+ "time_value": lib_plankton.pit.timespan_format(
+ event.begin,
+ event.end,
+ {
+ "adjust_to_ce": true,
+ }
+ ),
+ "macro_location": (
+ (event.location === null)
+ ?
+ ""
+ :
+ lib_plankton.string.coin(
+ "\n{{location_label}} | {{location_value}}",
+ {
+ "location_label": labels.location.toUpperCase(),
+ "location_value": event.location,
+ }
+ )
+ ),
+ "macro_description": (
+ (event.description === null)
+ ?
+ ""
+ :
+ lib_plankton.string.coin(
+ "\n\n{{description_value}}",
+ {
+ "description_value": event.description,
+ }
+ )
+ ),
+ }
+ );
+ }
+
+
/**
*/
async function send(
parameters : type_parameters,
labels : _munin.type_labels,
- event : _munin.type_event
+ events : Array<_munin.type_event>
) : Promise
{
await lib_plankton.telegram.bot_call_send_message(
parameters.bot_token,
parameters.chat_id,
lib_plankton.string.coin(
- "*{{head}}*\n\n\{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}",
+ "*{{head_core}}{{head_extra}}*\n\n{{events}}",
{
- "head": labels.head,
- "macro_tags": (
- (parameters.hide_tags || (event.tags === null))
- ?
- ""
- :
- (event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
- ),
- "title_label": labels.title.toUpperCase(),
- "title_value": event.title,
- "time_label": labels.time.toUpperCase(),
- "time_value": lib_plankton.pit.timespan_format(
- event.begin,
- event.end,
- {
- "adjust_to_ce": true,
- }
- ),
- "macro_location": (
- (event.location === null)
+ "head_core": labels.head,
+ "head_extra": (
+ (events.length <= 1)
?
""
:
lib_plankton.string.coin(
- "\n{{location_label}} | {{location_value}}",
+ " ({{count}} {{events}})",
{
- "location_label": labels.location.toUpperCase(),
- "location_value": event.location,
+ "count": events.length.toFixed(0),
+ "events": labels.events,
}
)
),
- "macro_description": (
- (event.description === null)
- ?
- ""
- :
- lib_plankton.string.coin(
- "\n\n{{description_value}}",
- {
- "description_value": event.description,
- }
- )
+ "events": (
+ events
+ .map(event => render_event(parameters, labels, event))
+ .join("\n\n---\n\n")
),
}
),
@@ -111,7 +145,7 @@ namespace _munin.targets.telegram_bot
"chat_id": parameters.chat_id.toFixed(0),
}
),
- "send": (labels, event) => send(parameters, labels, event),
+ "send": (labels, events) => send(parameters, labels, events),
};
}
diff --git a/source/test.ts b/source/test.ts
new file mode 100644
index 0000000..06a79de
--- /dev/null
+++ b/source/test.ts
@@ -0,0 +1,139 @@
+/*
+This file is part of »munin«.
+
+Copyright 2025 'Fenris Wolf'
+
+»munin« is free software: you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+»munin« is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with »munin«. If not, see .
+ */
+
+
+namespace _munin.test
+{
+
+ /**
+ * @todo outsource?
+ */
+ function lists_equal(
+ list1 : Array,
+ list2 : Array
+ ) : boolean
+ {
+ return (
+ (list1.length === list2.length)
+ &&
+ list1.every(
+ (element, index) => (element === list2[index])
+ )
+ );
+ }
+
+
+ /**
+ */
+ async function reminder_check(
+ ) : Promise
+ {
+ type type_input = {
+ reminder : {
+ frequency : string;
+ offset : int;
+ from : int;
+ to : int;
+ };
+ events : Array<
+ {
+ title : string;
+ begin : lib_plankton.pit.type_datetime;
+ }
+ >;
+ datetime : lib_plankton.pit.type_datetime;
+ interval : int;
+ };
+ type type_output = (null | Array);
+ const testcases : Array<
+ _munin.helpers.test.type_testcase<
+ type_input,
+ type_output
+ >
+ > = await _munin.helpers.test.get_data(
+ "data/reminder_check.testdata.json",
+ {
+ }
+ );
+
+ for (const testcase of testcases)
+ {
+ _munin.helpers.test.start(testcase.name);
+
+ // execution
+ const result : (null | Array<_munin.type_event>) = _munin.logic.reminder_check(
+ {
+ "frequency": _munin.logic.frequency_decode(testcase.input.reminder.frequency),
+ "offset": testcase.input.reminder.offset,
+ "from": testcase.input.reminder.from,
+ "to": testcase.input.reminder.to,
+ },
+ testcase.input.events.map(
+ event_raw => ({
+ "title": event_raw.title,
+ "begin": event_raw.begin,
+ "end": null,
+ "description": null,
+ "location": null,
+ "tags": null,
+ })
+ ),
+ {
+ "pit": lib_plankton.pit.from_datetime(testcase.input.datetime),
+ "interval": testcase.input.interval,
+ }
+ );
+
+ // assertions
+ if (
+ (
+ (testcase.output === null)
+ &&
+ (result === null)
+ )
+ ||
+ (
+ (testcase.output !== null)
+ &&
+ lists_equal(
+ result.map(event => event.title),
+ testcase.output
+ )
+ )
+ )
+ {
+ // success
+ }
+ else
+ {
+ _munin.helpers.test.fail(testcase.name);
+ }
+ }
+ }
+
+
+ /**
+ */
+ export async function all(
+ ) : Promise
+ {
+ await reminder_check();
+ }
+
+}
diff --git a/source/types.ts b/source/types.ts
index fae9db8..14b2cb6 100644
--- a/source/types.ts
+++ b/source/types.ts
@@ -20,7 +20,17 @@ along with »munin«. If not, see .
namespace _munin
{
-
+
+ /**
+ */
+ export enum enum_frequency {
+ hourly = "hourly",
+ daily = "daily",
+ weekly = "weekly",
+ monthly = "monthly",
+ }
+
+
/**
*/
export type type_labels = {
@@ -28,6 +38,7 @@ namespace _munin
title : string;
time : string;
location : string;
+ events : string;
};
@@ -55,15 +66,26 @@ namespace _munin
};
+ /**
+ * @todo rename
+ */
+ export type type_reminder = {
+ frequency : enum_frequency;
+ offset : int;
+ from : int;
+ to : int;
+ };
+
+
/**
*/
export type type_target = {
- reminders : Array;
+ reminders : Array;
show : (() => string);
send : (
(
labels : type_labels,
- event : type_event
+ events : Array
)
=>
Promise
diff --git a/tools/build b/tools/build
index 155b24a..f85b346 100755
--- a/tools/build
+++ b/tools/build
@@ -1,4 +1,13 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
+## core
tools/ivaldi build
-cd build && npm install nodemailer ; cd -
+
+## data
+mkdir -p build/data
+cp -r -u -v data/* build/data/
+
+## node modules
+node_modules=""
+node_modules="${node_modules} nodemailer"
+cd build && npm install ${node_modules} ; cd -