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 -