Termine gebündelt ausgeben (#1)

Siehe https://vikunja.ramsch.sx/tasks/340

Co-authored-by: Christian Fraß <roydfalk@folksprak.org>
Reviewed-on: #1
Co-authored-by: Fenris Wolf <fenris@folksprak.org>
Co-committed-by: Fenris Wolf <fenris@folksprak.org>
This commit is contained in:
fenris 2025-06-30 13:06:51 +02:00 committed by fenris
parent 9e698560fd
commit 362c1d2136
11 changed files with 1326 additions and 361 deletions

View file

@ -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": []
}
]

View file

@ -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"
]
}

View file

@ -24,6 +24,24 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
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<string>);
category_blacklist : (null | Array<string>);
title_whitelist : (null | Array<string>);
title_blacklist : (null | Array<string>);
}
}
}
)
>;
targets : Array<
(
{
kind : "email";
data : {
smtp_host : string;
smtp_port : int;
smtp_username : string;
smtp_password : string;
sender : string;
receivers : Array<string>;
hide_tags : boolean;
/**
* in hours
*/
reminders : Array<type_reminder_raw>;
}
}
|
{
kind : "telegram_bot";
data : {
bot_token : string;
chat_id : int;
hide_tags : boolean;
/**
* in hours
*/
reminders : Array<type_reminder_raw>;
}
}
)
>;
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);
}
}

124
source/helpers/test.ts Normal file
View file

@ -0,0 +1,124 @@
/*
This file is part of »munin«.
Copyright 2025 'Fenris Wolf' <fenris@folksprak.org>
»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 <http://www.gnu.org/licenses/>.
*/
namespace _munin.helpers.test
{
/**
* @todo outsource
*/
type type_testcase_raw<type_input, type_output> = {
active ?: boolean;
name ?: string;
input : type_input;
output : type_output;
};
/**
* @todo outsource
*/
export type type_testcase<type_input, type_output> = {
name : string;
input : type_input;
output : type_output;
};
/**
* @todo outsource
*/
export async function get_data<type_input, type_output>(
path : string,
{
"default_active": default_active = true,
} : {
default_active ?: boolean;
} = {
}
) : Promise<Array<type_testcase<type_input, type_output>>>
{
const content : string = await lib_plankton.file.read(path);
const testcases_raw : Array<type_testcase_raw<type_input, type_output>> = (
lib_plankton.json.decode(content) as Array<type_testcase_raw<type_input, type_output>>
);
const testcases : Array<type_testcase<type_input, type_output>> = (
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,
}
}
);
}
}

265
source/logic.ts Normal file
View file

@ -0,0 +1,265 @@
/*
This file is part of »munin«.
Copyright 2025 'Fenris Wolf' <fenris@folksprak.org>
»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 <http://www.gnu.org/licenses/>.
*/
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<void>
{
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<void>
{
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);
}
}
}
}

View file

@ -20,146 +20,7 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
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<void>
{
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<void>
{
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;
}
}

View file

@ -31,19 +31,103 @@ namespace _munin.targets.email
sender : string;
receivers : Array<string>;
hide_tags : boolean;
/**
* in hours
*/
reminders : Array<int>;
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<void>
{
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),
};
}

View file

@ -27,66 +27,100 @@ namespace _munin.targets.telegram_bot
bot_token : string;
chat_id : int;
hide_tags : boolean;
reminders : Array<int>;
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<void>
{
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),
};
}

139
source/test.ts Normal file
View file

@ -0,0 +1,139 @@
/*
This file is part of »munin«.
Copyright 2025 'Fenris Wolf' <fenris@folksprak.org>
»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 <http://www.gnu.org/licenses/>.
*/
namespace _munin.test
{
/**
* @todo outsource?
*/
function lists_equal<type_element>(
list1 : Array<type_element>,
list2 : Array<type_element>
) : boolean
{
return (
(list1.length === list2.length)
&&
list1.every(
(element, index) => (element === list2[index])
)
);
}
/**
*/
async function reminder_check(
) : Promise<void>
{
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<string>);
const testcases : Array<
_munin.helpers.test.type_testcase<
type_input,
type_output
>
> = await _munin.helpers.test.get_data<type_input, type_output>(
"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<string>(
result.map(event => event.title),
testcase.output
)
)
)
{
// success
}
else
{
_munin.helpers.test.fail(testcase.name);
}
}
}
/**
*/
export async function all(
) : Promise<void>
{
await reminder_check();
}
}

View file

@ -20,7 +20,17 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
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<int>;
reminders : Array<type_reminder>;
show : (() => string);
send : (
(
labels : type_labels,
event : type_event
events : Array<type_event>
)
=>
Promise<void>

View file

@ -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 -