Compare commits

...

8 commits

Author SHA1 Message Date
fenris 4d2b20f0b4 [mod] custom strings 2025-07-19 12:26:02 +02:00
Christian Fraß 06b220dd27 [mod] test-Daten verschoben 2025-07-01 17:36:23 +00:00
Christian Fraß ab3bfad376 [task-153] [fix] 2025-07-01 17:34:38 +00:00
Christian Fraß 3fa5d0820b [task-153] 2025-07-01 17:19:47 +00:00
Christian Fraß e366410d61 [mod] gitignore 2025-07-01 17:07:06 +00:00
fenris 1fe6b75b4e Zeichenketten auslagern (#2)
Siehe https://vikunja.ramsch.sx/tasks/343

Co-authored-by: Christian Fraß <roydfalk@folksprak.org>
Reviewed-on: #2
Co-authored-by: Fenris Wolf <fenris@folksprak.org>
Co-committed-by: Fenris Wolf <fenris@folksprak.org>
2025-07-01 19:05:51 +02:00
fenris e298a43f65 [upd] plankton 2025-06-30 13:19:55 +02:00
fenris 362c1d2136 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>
2025-06-30 13:06:51 +02:00
16 changed files with 1828 additions and 888 deletions

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
/build/ /build/
/temp/ /temp/
/conf/
/.geany /.geany

View file

@ -0,0 +1,25 @@
{
"meta": {
"identifier": "deu",
"name": "Deutsch"
},
"tree": {
"core.event.event": "Termin",
"core.event.events": "Termine",
"core.event.title.long": "Titel",
"core.event.title.short": "was",
"core.event.time.long": "Datum und Uhrzeit",
"core.event.time.short": "wann",
"core.event.location.long": "Ort",
"core.event.location.short": "wo",
"core.reminder.reminder": "Erinnerung",
"args.action.description": "auszuführende Aktion",
"args.conf_path.description": "Pfad zur Konfigurations-Datei",
"args.conf_schema.description": "nur das Konfigurations-Schema ausgeben",
"args.conf_expose.description": "die vervollständigte Konfiguration ausgeben",
"args.single_run.description": "nur einen Durchlauf ausführen",
"args.dry_run.description": "das Senden von Benachrichtigungen überspringen",
"args.help.description": "nur die Hilfe-Seite anzeigen",
"help.description": "sendet Erinnerungen zu anstehenden Terminen"
}
}

View file

@ -0,0 +1,25 @@
{
"meta": {
"identifier": "eng",
"name": "English"
},
"tree": {
"core.event.event": "event",
"core.event.events": "events",
"core.event.title.long": "title",
"core.event.title.short": "what",
"core.event.time.long": "date and time",
"core.event.time.short": "when",
"core.event.location.long": "location",
"core.event.location.short": "where",
"core.reminder.reminder": "reminder",
"args.action.description": "what to do",
"args.conf_path.description": "path to configuration file",
"args.conf_schema.description": "only print the configuration schema",
"args.conf_expose.description": "whether to expose the full configuration",
"args.single_run.description": "whether to only execute one iteration at run",
"args.dry_run.description": "whether to skip the sending of reminders (logs will be written)",
"args.help.description": "only print the help page",
"help.description": "sends reminders about upcoming events"
}
}

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,14 +12,18 @@
"email", "email",
"telegram", "telegram",
"url", "url",
"json",
"file",
"conf", "conf",
"log", "log",
"translate",
"args" "args"
] ]
} }
} }
], ],
"sources": [ "sources": [
"helpers/test.ts",
"types.ts", "types.ts",
"sources/ical_feed.ts", "sources/ical_feed.ts",
"sources/_functions.ts", "sources/_functions.ts",
@ -27,6 +31,8 @@
"targets/email.ts", "targets/email.ts",
"targets/_functions.ts", "targets/_functions.ts",
"conf.ts", "conf.ts",
"logic.ts",
"test.ts",
"main.ts" "main.ts"
] ]
} }

View file

@ -1211,6 +1211,9 @@ declare namespace lib_plankton.pit {
/** /**
*/ */
function is_after(pit: type_pit, reference: type_pit): boolean; function is_after(pit: type_pit, reference: type_pit): boolean;
/**
*/
function is_equal_or_after(pit: type_pit, reference: type_pit): boolean;
/** /**
*/ */
function is_between(pit: type_pit, reference_left: type_pit, reference_right: type_pit): boolean; function is_between(pit: type_pit, reference_left: type_pit, reference_right: type_pit): boolean;
@ -2698,6 +2701,99 @@ declare namespace lib_plankton.conf {
*/ */
function load_versioned(path: string, get_schema: ((version: string) => type_schema), migrations: Record<string, type_migration<any, any>>): Promise<type_sheet<any>>; function load_versioned(path: string, get_schema: ((version: string) => type_schema), migrations: Record<string, type_migration<any, any>>): Promise<type_sheet<any>>;
} }
declare namespace lib_plankton.translate {
/**
* @author fenris
*/
type type_package_meta = {
identifier: string;
name?: string;
};
/**
* @author fenris
*/
type type_package = {
meta: type_package_meta;
tree: {
[id: string]: string;
};
};
/**
* @desc the level of verbosity, specifiying how much output the system shall provide about its actions
* @author fenris
*/
var _verbosity: int;
/**
* @desc moves a language to the top of the order, making it the primary one
* @author fenris
*/
function promote(identifier: string): void;
/**
* @desc adds a package to the sytem
* @author fenris
*/
function add(package_: type_package): void;
/**
* @desc integrates a package to the system, i.e. creates a new one if none existed so far or merges with an existing one
* @author fenris
*/
function feed(package_: type_package): void;
/**
* @desc tries to retrieve a translation for a specific package identifier
* @author fenris
*/
function fetch(identifier: string, path: string, args?: {
[id: string]: string;
}): lib_plankton.pod.type_pod<string>;
/**
* @desc retrieves a string by going through the order and trying to fetch it for the current entry
* @author fenris
* @todo rename to "get"
*/
function get_new(path: string, { "args": args, "preferred_language": preferred_language, "fallback": fallback, }?: {
args?: Record<string, string>;
preferred_language?: (null | string);
fallback?: string;
}): string;
/**
* @desc retrieves a string by going through the order and trying to fetch it for the current entry
* @author fenris
* @deprecated use "get_new"
* @todo remove
*/
function get(path: string, args?: {
[id: string]: string;
}, fallback?: string): string;
/**
* @author fenris
*/
function list(): Array<type_package_meta>;
/**
* @author fenris
* @todo get rid of this; it's currenly needed only for the cdh-internal lib_completion
*/
function paths(): Array<string>;
/**
* @author fenris
*/
function initialize({ "logprefix": logprefix, "verbosity": verbosity, "packages": packages, "order": order, "autopromote": autopromote, }?: {
logprefix?: string;
verbosity?: int;
packages?: Array<type_package>;
order?: Array<string>;
autopromote?: boolean;
}): Promise<void>;
}
declare namespace lib_plankton.translate {
/**
* @author fenris
*/
function iso_639_1_to_iso_639_2(iso6391: string): string;
/**
* @author fenris
*/
function stance(str: string): string;
}
declare namespace lib_plankton.args { declare namespace lib_plankton.args {
/** /**
*/ */

View file

@ -3648,10 +3648,16 @@ var lib_plankton;
return (pit > reference); return (pit > reference);
} }
pit_1.is_after = is_after; pit_1.is_after = is_after;
/**
*/
function is_equal_or_after(pit, reference) {
return (pit >= reference);
}
pit_1.is_equal_or_after = is_equal_or_after;
/** /**
*/ */
function is_between(pit, reference_left, reference_right) { function is_between(pit, reference_left, reference_right) {
return (is_after(pit, reference_left) return (is_equal_or_after(pit, reference_left)
&& &&
is_before(pit, reference_right)); is_before(pit, reference_right));
} }
@ -8054,6 +8060,370 @@ var lib_plankton;
})(conf = lib_plankton.conf || (lib_plankton.conf = {})); })(conf = lib_plankton.conf || (lib_plankton.conf = {}));
})(lib_plankton || (lib_plankton = {})); })(lib_plankton || (lib_plankton = {}));
/* /*
This file is part of »bacterio-plankton:translate«.
Copyright 2016-2024 'Christian Fraß, Christian Neubauer, Martin Springwald GbR'
<info@greenscale.de>
»bacterio-plankton:translate« 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.
»bacterio-plankton:translate« 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 »bacterio-plankton:translate«. If not, see <http://www.gnu.org/licenses/>.
*/
var lib_plankton;
(function (lib_plankton) {
var translate;
(function (translate) {
/**
* @desc contains the sets of strings
* @author fenris
*/
var _packages = {};
/**
* @desc specifies in which order the languages shall be queried; if getting a string from language #0 fails, the
* system tries to get it from language #1, and so on
* @author fenris
*/
var _order = [];
/**
* @desc whether to automatically promote the language of a newly added package
* @author fenris
*/
var _autopromote = false;
/**
* @desc the level of verbosity, specifiying how much output the system shall provide about its actions
* @author fenris
*/
translate._verbosity = 1;
/**
* @desc which initial string to use for log-outputs
* @author fenris
*/
var _logprefix = "[lib_translate]";
/**
* @desc moves a language to the top of the order, making it the primary one
* @author fenris
*/
function promote(identifier) {
if (Object.keys(_packages).indexOf(identifier) < 0) {
if (translate._verbosity >= 1) {
console.warn(`${_logprefix} package '${identifier}' doesn't exist yet`);
}
}
let position = _order.indexOf(identifier);
if (position >= 0) {
if (translate._verbosity >= 2) {
console.info(`${_logprefix} '${identifier}' already in order; will promote it`);
}
_order.splice(position, 1);
}
_order.unshift(identifier);
if (translate._verbosity >= 2) {
console.info(`${_logprefix} order is now ${_order.toString()}`);
}
}
translate.promote = promote;
/**
* @desc adds a package to the sytem
* @author fenris
*/
function add(package_) {
let identifier = package_.meta.identifier;
if (identifier in _packages) {
if (translate._verbosity >= 1) {
console.warn(`${_logprefix} package '${identifier}' has already been added; will overwrite`);
}
}
else {
if (translate._verbosity >= 2) {
console.log(`${_logprefix} got package '${identifier}'`);
}
}
_packages[identifier] = package_;
if (_autopromote) {
promote(identifier);
}
}
translate.add = add;
/**
* @desc integrates a package to the system, i.e. creates a new one if none existed so far or merges with an existing one
* @author fenris
*/
function feed(package_) {
let identifier = package_.meta.identifier;
if (identifier in _packages) {
lib_plankton.object.patch(_packages[identifier].tree, package_.tree, {
"deep": true,
});
}
else {
if (translate._verbosity >= 2) {
console.info(`${_logprefix} package '${identifier}' didn't exist so far; will create it now`);
}
add(package_);
}
}
translate.feed = feed;
/**
* @desc tries to retrieve a translation for a specific package identifier
* @author fenris
*/
function fetch(identifier, path, args = {}) {
if (!(identifier in _packages)) {
if (translate._verbosity >= 1) {
console.warn(`${_logprefix} no package '${identifier}'`);
}
return (lib_plankton.pod.make_empty());
}
else {
// let str : string = lib_plankton.object.path_read<string>(_packages[identifier].tree, path);
let str = _packages[identifier].tree[path];
if (str == undefined) {
if (translate._verbosity >= 1) {
console.warn(`${_logprefix} string '${path}' missing in package '${identifier}'`);
}
return (lib_plankton.pod.make_empty());
}
else {
// resolve references
{
let regexp_reference = new RegExp("#\\(([\\w\\.]*)(?:\\?(\\w+)=(\\w+)(?:&(\\w+)=(\\w+))*)?\\)");
while (true) {
let matching = regexp_reference.exec(str);
if (matching != null) {
let path_ = matching[1];
let args_ = {};
if (translate._verbosity >= 2) {
// console.info(`${_logprefix} found reference to '${path_}' with args ${JSON.stringify(args_)}`);
console.info(`${_logprefix} found reference to '${path_}'`);
}
// parse args
{
for (let index = 2; index <= matching.length - 1; index += 2) {
let id = matching[index + 0];
let value = matching[index + 1];
if (id != undefined) {
args_[id] = value;
}
}
}
// fetch referenced string
{
let result_ = fetch(identifier, path_, args_);
if (lib_plankton.pod.is_filled(result_)) {
let front = str.slice(0, matching.index);
let back = str.slice(matching.index + matching[0].length);
str = (front + lib_plankton.pod.cull(result_) + back);
}
else {
return (lib_plankton.pod.make_empty());
break;
}
}
}
else {
break;
}
}
}
// insert arguments
{
str = lib_plankton.string.coin(str, args);
}
return (lib_plankton.pod.make_filled(str));
}
}
}
translate.fetch = fetch;
/**
* @desc retrieves a string by going through the order and trying to fetch it for the current entry
* @author fenris
* @todo rename to "get"
*/
function get_new(path, { "args": args = {}, "preferred_language": preferred_language = null, "fallback": fallback = null, } = {}) {
if (fallback == null) {
fallback = `{${path}}`;
}
if (translate._verbosity >= 2) {
console.info(`${_logprefix} getting translation for string '${path}' with arguments ${JSON.stringify(args)}`);
}
let result = lib_plankton.pod.make_empty();
const order = ((preferred_language === null)
?
_order
:
([preferred_language].concat(_order.filter(x => (x !== preferred_language)))));
const found = order.some(identifier => {
if (translate._verbosity >= 2) {
console.info(`${_logprefix} trying package '${identifier}' …`);
}
const result_ = fetch(identifier, path, args);
if (lib_plankton.pod.is_filled(result_)) {
result = result_;
return true;
}
else {
return false;
}
});
if (found) {
const str = lib_plankton.pod.cull(result);
if (translate._verbosity >= 3) {
console.info(`${_logprefix} found translation: '${str}'`);
}
return str;
}
else {
const str = fallback;
if (translate._verbosity >= 1) {
console.warn(`${_logprefix} no package provides a translation for string '${path}'; will use the fallback translation '${str}'`);
}
return str;
}
}
translate.get_new = get_new;
/**
* @desc retrieves a string by going through the order and trying to fetch it for the current entry
* @author fenris
* @deprecated use "get_new"
* @todo remove
*/
function get(path, args = {}, fallback = null) {
return get_new(path, {
"args": args,
"fallback": fallback,
});
}
translate.get = get;
/**
* @author fenris
*/
function list() {
return lib_plankton.object.to_array(_packages).map(x => x.value.meta);
}
translate.list = list;
/**
* @author fenris
* @todo get rid of this; it's currenly needed only for the cdh-internal lib_completion
*/
function paths() {
return lib_plankton.object.keys(lib_plankton.object.flatten(_packages[_order[0]].tree));
}
translate.paths = paths;
/**
* @author fenris
*/
function initialize({ "logprefix": logprefix = undefined, "verbosity": verbosity = undefined, "packages": packages = [], "order": order = undefined, "autopromote": autopromote = undefined, } = {}) {
return (Promise.resolve(undefined)
// set variables
.then(_ => {
if (logprefix != undefined)
_logprefix = logprefix;
if (verbosity != undefined)
translate._verbosity = verbosity;
// _packages = {};
if (order != undefined)
_order = order;
if (autopromote != undefined)
_autopromote = autopromote;
return Promise.resolve(undefined);
})
// feed
.then(_ => {
packages.forEach(feed);
return Promise.resolve(undefined);
}));
}
translate.initialize = initialize;
})(translate = lib_plankton.translate || (lib_plankton.translate = {}));
})(lib_plankton || (lib_plankton = {}));
/*
This file is part of »bacterio-plankton:translate«.
Copyright 2016-2024 'Christian Fraß, Christian Neubauer, Martin Springwald GbR'
<info@greenscale.de>
»bacterio-plankton:translate« 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.
»bacterio-plankton:translate« 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 »bacterio-plankton:translate«. If not, see <http://www.gnu.org/licenses/>.
*/
var lib_plankton;
(function (lib_plankton) {
var translate;
(function (translate) {
/**
* @author fenris
*/
function iso_639_1_to_iso_639_2(iso6391) {
let mapping = {
"af": "afr",
"ar": "ara",
"bg": "bul",
"cs": "ces",
"da": "dan",
"de": "deu",
"el": "ell",
"en": "eng",
"eo": "epo",
"es": "esp",
"fa": "fas",
"fi": "fin",
"fr": "fra",
"hi": "hin",
"hr": "hrv",
"hu": "hun",
"is": "isl",
"it": "ita",
"ja": "jpn",
"ko": "kor",
"nb": "nob",
"nl": "nld",
"nn": "nno",
"pt": "por",
"pl": "pol",
"ro": "ron",
"ru": "rus",
"sk": "slk",
"sv": "swe",
"zh": "zho",
};
return mapping[iso6391];
}
translate.iso_639_1_to_iso_639_2 = iso_639_1_to_iso_639_2;
/**
* @author fenris
*/
function stance(str) {
let regexp = new RegExp("^translate:(.*)$");
let matching = regexp.exec(str);
if (matching != null) {
return translate.get(matching[1]);
}
else {
return str;
}
}
translate.stance = stance;
})(translate = lib_plankton.translate || (lib_plankton.translate = {}));
})(lib_plankton || (lib_plankton = {}));
/*
This file is part of »bacterio-plankton:args«. This file is part of »bacterio-plankton:args«.
Copyright 2016-2024 'Christian Fraß, Christian Neubauer, Martin Springwald GbR' Copyright 2016-2024 'Christian Fraß, Christian Neubauer, Martin Springwald GbR'

View file

@ -18,161 +18,31 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
*/ */
/**
* @todo versioning
*/
namespace _munin.conf namespace _munin.conf
{ {
/** /**
*/ */
type type_conf_v1 = { type type_reminder_raw = {
sources : Array< frequency : (
( "hourly"
{ |
kind : "kalender_digital"; "daily"
data : { |
path : string; "weekly"
filtration : { |
category_blacklist : Array<string>; "monthly"
title_blacklist : Array<string>; );
}; offset : int;
}; from : int;
} to : int;
)
>;
targets : Array<
(
{
kind : "telegram_bot";
data : {
bot_token : string;
chat_id : int;
/**
* in hours
*/
interval : Array<int>;
}
}
)
>;
frequency : float;
labels : {
head : string;
title : string;
time : string;
location : string;
};
}; };
/** /**
* @todo rename to "type_conf"
*/ */
type type_conf_v2 = { type type_conf_v6 = {
sources : Array<
(
{
kind : "ical_feed";
data : {
url : string;
filtration : {
category_blacklist : Array<string>;
title_blacklist : Array<string>;
}
}
}
)
>;
targets : Array<
(
{
kind : "telegram_bot";
data : {
bot_token : string;
chat_id : int;
/**
* in hours
*/
reminders : Array<int>;
}
}
)
>;
settings : {
interval : float;
};
labels : {
head : string;
title : string;
time : string;
location : string;
};
};
/**
*/
type type_conf_v3 = {
sources : Array<
(
{
kind : "ical_feed";
data : {
url : string;
filtration : {
category_blacklist : Array<string>;
title_blacklist : Array<string>;
}
}
}
)
>;
targets : Array<
(
{
kind : "email";
data : {
smtp_host : string;
smtp_port : int;
smtp_username : string;
smtp_password : string;
sender : string;
receivers : Array<string>;
/**
* in hours
*/
reminders : Array<int>;
}
}
|
{
kind : "telegram_bot";
data : {
bot_token : string;
chat_id : int;
/**
* in hours
*/
reminders : Array<int>;
}
}
)
>;
settings : {
interval : float;
};
labels : {
head : string;
title : string;
time : string;
location : string;
};
};
/**
*/
type type_conf_v4 = {
sources : Array< sources : Array<
( (
{ {
@ -201,10 +71,11 @@ namespace _munin.conf
sender : string; sender : string;
receivers : Array<string>; receivers : Array<string>;
hide_tags : boolean; hide_tags : boolean;
/** reminders : Array<type_reminder_raw>;
* in hours language : string;
*/ strings : {
reminders : Array<int>; notification_head : string;
};
} }
} }
| |
@ -214,10 +85,11 @@ namespace _munin.conf
bot_token : string; bot_token : string;
chat_id : int; chat_id : int;
hide_tags : boolean; hide_tags : boolean;
/** reminders : Array<type_reminder_raw>;
* in hours language : string;
*/ strings : {
reminders : Array<int>; notification_head : string;
};
} }
} }
) )
@ -225,247 +97,23 @@ namespace _munin.conf
settings : { settings : {
interval : float; interval : float;
}; };
labels : {
head : string;
title : string;
time : string;
location : string;
};
}; };
/** /**
*/ */
export type type_conf = type_conf_v4; const current_version : string = "6";
/** /**
* @todo remove
*/ */
function convert_from_v1( export type type_conf = type_conf_v6;
conf_v1 : type_conf_v1
) : type_conf_v2
{
return {
"sources": conf_v1.sources.map(
source => {
switch (source.kind) {
case "kalender_digital": {
return {
"kind": "ical_feed",
"data": {
"url": lib_plankton.url.encode(
{
"scheme": "https",
"host": "export.kalender.digital",
"username": null,
"password": null,
"port": null,
"path": ("/ics/" + source.data.path + ".ics"),
"query": "past_months=0&future_months=1",
"hash": null,
}
),
"filtration": source.data.filtration,
}
};
break;
}
default: {
// return source;
throw (new Error("unhandled source kind: " + source.kind));
break;
}
}
}
),
"targets": conf_v1.targets.map(
target => {
switch (target.kind) {
case "telegram_bot": {
return {
"kind": "telegram_bot",
"data": {
"bot_token": target.data.bot_token,
"chat_id": target.data.chat_id,
"reminders": target.data.interval,
},
};
break;
}
default: {
// return target;
throw (new Error("unhandled target kind: " + target.kind));
break;
}
}
}
),
"settings": {
"interval": conf_v1.frequency,
},
"labels": conf_v1.labels,
};
}
/**
*/
function convert_from_v2(
conf_v2 : type_conf_v2
) : type_conf_v3
{
return conf_v2;
}
/**
*/
function convert_from_v3(
conf_v3 : type_conf_v3
) : type_conf_v4
{
return {
"sources": conf_v3.sources.map(
source => {
switch (source.kind) {
case "ical_feed": {
return {
"kind": "ical_feed",
"data": {
"url": source.data.url,
"filtration": {
"category_whitelist": null,
"category_blacklist": source.data.filtration.category_blacklist,
"title_whitelist": null,
"title_blacklist": source.data.filtration.category_blacklist,
},
}
};
break;
}
default: {
// return source;
throw (new Error("unhandled source kind: " + source.kind));
break;
}
}
}
),
"targets": conf_v3.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": false,
"reminders": target.data.reminders,
},
};
break;
}
case "telegram_bot": {
return {
"kind": "telegram_bot",
"data": {
"bot_token": target.data.bot_token,
"chat_id": target.data.chat_id,
"hide_tags": false,
"reminders": target.data.reminders,
},
};
break;
}
default: {
// return target;
throw (new Error("unhandled target kind: " + String(target)));
break;
}
}
}
),
"settings": conf_v3.settings,
"labels": conf_v3.labels,
};
}
/**
*/
function schema_source_kalender_digital(
version : string
) : lib_plankton.conf.type_schema
{
return {
"type": "object",
"properties": {
"kind": {
"nullable": false,
"type": "string",
"enum": ["kalender_digital"]
},
"data": {
"nullable": false,
"type": "object",
"properties": {
"path": {
"nullable": false,
"type": "string"
},
"filtration": {
"nullable": false,
"type": "object",
"properties": {
"category_blacklist": {
"nullable": true,
"type": "array",
"items": {
"nullable": false,
"type": "string",
},
"default": [],
},
"title_blacklist": {
"nullable": true,
"type": "array",
"items": {
"nullable": false,
"type": "string",
},
"default": [],
},
},
"additionalProperties": false,
"required": [
],
"default": {}
},
},
"additionalProperties": false,
"required": [
"path",
]
}
},
"additionalProperties": false,
"required": [
"kind",
"data",
]
};
}
/** /**
*/ */
function schema_source_ical_feed( function schema_source_ical_feed(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
@ -546,10 +194,85 @@ namespace _munin.conf
} }
/**
*/
function schema_sources(
) : lib_plankton.conf.type_schema
{
return {
"nullable": false,
"type": "array",
"items": {
"nullable": false,
"anyOf": [
schema_source_ical_feed(),
],
}
};
}
/**
*/
function schema_reminder(
) : lib_plankton.conf.type_schema
{
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",
]
};
}
/**
*/
function default_reminder(
) : any
{
return [
{
"frequency": "hourly",
"from": 24,
"to": 25
}
];
}
/** /**
*/ */
function schema_target_email( function schema_target_email(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
@ -602,11 +325,28 @@ namespace _munin.conf
"reminders": { "reminders": {
"nullable": false, "nullable": false,
"type": "array", "type": "array",
"items": { "items": schema_reminder(),
"nullable": false, "default": default_reminder(),
"type": "integer" },
"language": {
"nullable": false,
"type": "string",
"default": "deu"
},
"strings": {
"nullable": false,
"type": "object",
"properties": {
"notification_head": {
"nullable": false,
"type": "string",
"default": "[{{core}}] {{extra}}"
},
}, },
"default": [24.0], "additionalProperties": false,
"required": [
],
"default": {},
}, },
}, },
"additionalProperties": false, "additionalProperties": false,
@ -628,48 +368,9 @@ 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( function schema_target_telegram_bot(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
@ -701,11 +402,28 @@ namespace _munin.conf
"reminders": { "reminders": {
"nullable": false, "nullable": false,
"type": "array", "type": "array",
"items": { "items": schema_reminder(),
"nullable": false, "default": default_reminder(),
"type": "integer" },
"language": {
"nullable": false,
"type": "string",
"default": "deu"
},
"strings": {
"nullable": false,
"type": "object",
"properties": {
"notification_head": {
"nullable": false,
"type": "string",
"default": "[{{core}}] {{extra}}"
},
}, },
"default": [24.0], "additionalProperties": false,
"required": [
],
"default": {},
}, },
}, },
"additionalProperties": false, "additionalProperties": false,
@ -727,7 +445,6 @@ namespace _munin.conf
/** /**
*/ */
function schema_targets( function schema_targets(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
@ -735,23 +452,10 @@ namespace _munin.conf
"type": "array", "type": "array",
"items": { "items": {
"nullable": false, "nullable": false,
"anyOf": (() => { "anyOf": [
switch (version) { schema_target_email(),
default: { schema_target_telegram_bot(),
return [ ]
schema_target_telegram_bot(version),
];
break;
}
case "3": {
return [
schema_target_email(version),
schema_target_telegram_bot(version),
];
break;
}
}
}) (),
} }
}; };
} }
@ -760,7 +464,6 @@ namespace _munin.conf
/** /**
*/ */
function schema_settings( function schema_settings(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
@ -784,91 +487,40 @@ namespace _munin.conf
/** /**
*/ */
function schema_labels( export function schema(
version : string
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
return { return {
"nullable": false, "nullable": false,
"type": "object", "type": "object",
"properties": { "properties": {
"head": { "sources": schema_sources(),
"nullable": false, "targets": schema_targets(),
"type": "string", "settings": schema_settings(),
"default": "Termin-Erinnerung"
},
"title": {
"nullable": false,
"type": "string",
"default": "was"
},
"time": {
"nullable": false,
"type": "string",
"default": "wann"
},
"location": {
"nullable": false,
"type": "string",
"default": "wo"
},
}, },
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"sources",
"targets",
], ],
"default": {}
}; };
} }
/** /**
*/ */
export function schema( export function schema_extended(
version : string = "4"
) : lib_plankton.conf.type_schema ) : lib_plankton.conf.type_schema
{ {
switch (version) { return {
case "1": { "type": "object",
return { "properties": {
"version": {
"nullable": false, "nullable": false,
"type": "object", "type": "string",
"properties": { "enum": [current_version],
"sources": schema_sources(version), },
"targets": schema_targets(version), "content": _munin.conf.schema(),
"frequency": {
"nullable": false,
"type": "number",
"default": 1.0,
},
"labels": schema_labels(version),
},
"additionalProperties": false,
"required": [
"sources",
"targets",
],
};
break;
}
case "2":
case "3":
case "4": {
return {
"nullable": false,
"type": "object",
"properties": {
"sources": schema_sources(version),
"targets": schema_targets(version),
"settings": schema_settings(version),
"labels": schema_labels(version),
},
"additionalProperties": false,
"required": [
"sources",
"targets",
],
};
break;
} }
} }
} }
@ -884,48 +536,17 @@ namespace _munin.conf
path, path,
schema, schema,
{ {
"1": {"target": "2", "function": convert_from_v1}, "v6": null,
"2": {"target": "3", "function": convert_from_v2},
"3": {"target": "4", "function": convert_from_v3},
"4": null,
} }
); );
if (conf_raw.version !== current_version)
/* {
switch (conf_raw.version) { throw (new Error("conf expected in version '" + current_version + "'"));
case "1": { }
const conf_v1 : type_conf_v1 = (conf_raw.content as type_conf_v1); else
const conf_v2 : type_conf_v2 = convert_from_v1(conf_v1); {
const conf_v3 : type_conf_v3 = convert_from_v2(conf_v2); return (conf_raw.content as type_conf_v6);
const conf_v4 : type_conf_v4 = convert_from_v3(conf_v3);
return conf_v4;
break;
}
case "2": {
const conf_v2 : type_conf_v2 = (conf_raw.content as type_conf_v2);
const conf_v3 : type_conf_v3 = convert_from_v2(conf_v2);
const conf_v4 : type_conf_v4 = convert_from_v3(conf_v3);
return conf_v4;
break;
}
case "3": {
const conf_v3 : type_conf_v3 = (conf_raw.content as type_conf_v3);
const conf_v4 : type_conf_v4 = convert_from_v3(conf_v3);
return conf_v4;
break;
}
case "4": {
const conf_v4 : type_conf_v4 = (conf_raw.content as type_conf_v4);
return conf_v4;
break;
}
default: {
throw (new Error("invalid version: " + conf_raw.version));
break;
}
} }
*/
return (conf_raw.content as type_conf_v4);
} }
} }

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(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,181 +20,70 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
namespace _munin 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( export async function main(
args_raw : Array<string> args_raw : Array<string>
): Promise<void> ): Promise<void>
{ {
// init
const language_codes : Array<string> = [
"deu",
"eng",
];
const packages = await Promise.all<lib_plankton.translate.type_package>(
language_codes
.map<Promise<lib_plankton.translate.type_package>>(
code => (
Promise.resolve<string>(code)
.then<string>(code => Promise.resolve(lib_plankton.string.coin("data/localization/{{code}}.json", {"code": code})))
.then<string>(path => lib_plankton.file.read(path))
.then<lib_plankton.translate.type_package>(content => Promise.resolve(lib_plankton.json.decode(content)))
)
)
);
await lib_plankton.translate.initialize(
{
"verbosity": 1,
"packages": packages,
"order": language_codes,
"autopromote": false,
}
);
// args // args
const arg_handler : lib_plankton.args.class_handler = new lib_plankton.args.class_handler( const arg_handler : lib_plankton.args.class_handler = new lib_plankton.args.class_handler(
{ {
/*
"action": lib_plankton.args.class_argument.positional({ "action": lib_plankton.args.class_argument.positional({
"index": 0, "index": 0,
"type": lib_plankton.args.enum_type.string, "type": lib_plankton.args.enum_type.string,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": "run", "default": "run",
"info": "what to do : help | run", "info": lib_plankton.string.coin(
"{{core}} : test | run",
{
"core": lib_plankton.translate.get("args.action.description"),
}
),
"name": "action", "name": "action",
}), }),
*/
"conf_path": lib_plankton.args.class_argument.volatile({ "conf_path": lib_plankton.args.class_argument.volatile({
"indicators_long": ["conf-path"], "indicators_long": ["conf-path"],
"indicators_short": ["c"], "indicators_short": ["c"],
"type": lib_plankton.args.enum_type.string, "type": lib_plankton.args.enum_type.string,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": "munin.json", "default": "munin.json",
"info": "path to configuration file", "info": lib_plankton.translate.get("args.conf_path.description"),
"name": "conf-path", "name": "conf-path",
}), }),
"conf_schema": lib_plankton.args.class_argument.volatile({ "conf_schema": lib_plankton.args.class_argument.volatile({
"indicators_long": ["conf-schema"], "indicators_long": ["conf-schema"],
"indicators_short": ["s"], "indicators_short": ["s"],
"type": lib_plankton.args.enum_type.string, "type": lib_plankton.args.enum_type.boolean,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": "", "default": false,
"info": "only print the configuration schema in a specific version (latest version via argument '_')", "info": lib_plankton.translate.get("args.conf_schema.description"),
"name": "conf-schema", "name": "conf-schema",
}), }),
"conf_expose": lib_plankton.args.class_argument.volatile({ "conf_expose": lib_plankton.args.class_argument.volatile({
@ -203,7 +92,7 @@ namespace _munin
"type": lib_plankton.args.enum_type.boolean, "type": lib_plankton.args.enum_type.boolean,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": false, "default": false,
"info": "whether to expose the full configuration", "info": lib_plankton.translate.get("args.conf_expose.description"),
"name": "conf-expose", "name": "conf-expose",
}), }),
"single_run": lib_plankton.args.class_argument.volatile({ "single_run": lib_plankton.args.class_argument.volatile({
@ -212,7 +101,7 @@ namespace _munin
"type": lib_plankton.args.enum_type.boolean, "type": lib_plankton.args.enum_type.boolean,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": false, "default": false,
"info": "whether to only execute one iteration at run", "info": lib_plankton.translate.get("args.single_run.description"),
"name": "single-run", "name": "single-run",
}), }),
"verbosity": lib_plankton.args.class_argument.volatile({ "verbosity": lib_plankton.args.class_argument.volatile({
@ -230,7 +119,7 @@ namespace _munin
"type": lib_plankton.args.enum_type.boolean, "type": lib_plankton.args.enum_type.boolean,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": false, "default": false,
"info": "whether to skip the sending of reminders (logs will be written)", "info": lib_plankton.translate.get("args.dry_run.description"),
"name": "dry-run", "name": "dry-run",
}), }),
"help": lib_plankton.args.class_argument.volatile({ "help": lib_plankton.args.class_argument.volatile({
@ -239,7 +128,7 @@ namespace _munin
"type": lib_plankton.args.enum_type.boolean, "type": lib_plankton.args.enum_type.boolean,
"mode": lib_plankton.args.enum_mode.replace, "mode": lib_plankton.args.enum_mode.replace,
"default": false, "default": false,
"info": "alias for action 'help'", "info": lib_plankton.translate.get("args.help.description"),
"name": "help", "name": "help",
}), }),
} }
@ -254,17 +143,17 @@ namespace _munin
arg_handler.generate_help( arg_handler.generate_help(
{ {
"programname": "munin", "programname": "munin",
"description": "sends reminders about upcoming events", "description": lib_plankton.translate.get("help.description"),
"executable": "node build/munin", "executable": "node build/munin",
} }
) )
); );
} }
else { else {
if (args.conf_schema !== "") { if (args.conf_schema) {
process.stdout.write( process.stdout.write(
lib_plankton.json.encode( lib_plankton.json.encode(
_munin.conf.schema((args.conf_schema === "_") ? undefined : args.conf_schema), _munin.conf.schema_extended(),
{ {
"formatted": true, "formatted": true,
} }
@ -274,67 +163,77 @@ namespace _munin
); );
} }
else { else {
// init switch (args.action) {
const conf : _munin.conf.type_conf = await _munin.conf.load(args.conf_path); default:
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: {
throw (new Error("unhandled action: " + args.action)); throw (new Error("unhandled action: " + args.action));
break; break;
} }
case "run": { case "test":
run( {
_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, conf,
{ {
"single_run": args.single_run, "single_run": args.single_run,
"dry_run": args.dry_run, "dry_run": args.dry_run,
} }
); );
break; break;
} }
} }

View file

@ -31,19 +31,126 @@ namespace _munin.targets.email
sender : string; sender : string;
receivers : Array<string>; receivers : Array<string>;
hide_tags : boolean; hide_tags : boolean;
/** reminders : Array<_munin.type_reminder>;
* in hours language : string;
*/ strings : {
reminders : Array<int>; notification_head : string;
};
}; };
/**
*/
function get_translation(
parameters : type_parameters,
path : string
) : string
{
return lib_plankton.translate.get_new(
path,
{
"preferred_language": parameters.language,
}
);
}
/**
*/
function summarize_event(
parameters : type_parameters,
event : _munin.type_event
) : string
{
return lib_plankton.string.coin(
"[{{head}}] {{date}} : {{macro_tags}}{{title}}",
{
"head": lib_plankton.string.coin(
"{{event}}-{{reminder}}",
{
"event": get_translation(parameters, "core.event.event").toLowerCase(),
"reminder": get_translation(parameters, "core.reminder.reminder").toLowerCase(),
}
),
"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,
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": get_translation(parameters, "core.event.title.short").toUpperCase(),
"macro_tags": (
(parameters.hide_tags || (event.tags === null))
?
""
:
(event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ")
),
"title_value": event.title,
"time_label": get_translation(parameters, "core.event.time.short").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": get_translation(parameters, "core.event.location.short").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( async function send(
parameters : type_parameters, parameters : type_parameters,
labels : _munin.type_labels, events : Array<_munin.type_event>
event : _munin.type_event
) : Promise<void> ) : Promise<void>
{ {
await lib_plankton.email.send( await lib_plankton.email.send(
@ -55,69 +162,35 @@ namespace _munin.targets.email
}, },
parameters.sender, parameters.sender,
parameters.receivers, parameters.receivers,
lib_plankton.string.coin( (
"[{{head}}] {{date}} : {{macro_tags}}{{title}}", (events.length === 1)
{ ?
"head": labels.head, summarize_event(parameters, events[0])
"date": lib_plankton.pit.date_format( :
event.begin.date lib_plankton.string.coin(
), parameters.strings.notification_head,
"macro_tags": ( {
(event.tags === null) "core": lib_plankton.string.coin(
? "{{event}}-{{reminder}}",
"" {
: "event": get_translation(parameters, "core.event.event").toLowerCase(),
(event.tags.map(tag => ("{" + tag + "}")).join(" ") + " ") "reminder": get_translation(parameters, "core.reminder.reminder").toLowerCase(),
), }
"title": event.title, ),
} "extra": lib_plankton.string.coin(
"{{count}} {{events}}",
{
"count": events.length.toFixed(0),
"events": get_translation(parameters, "core.event.events"),
}
),
}
)
), ),
lib_plankton.string.coin( (
"{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}", events
{ .map(event => render_event(parameters, event))
"title_label": labels.title.toUpperCase(), .join("\n\n--------\n\n")
"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,
}
)
),
}
) )
); );
} }
@ -137,7 +210,7 @@ namespace _munin.targets.email
"receivers": parameters.receivers.join(","), "receivers": parameters.receivers.join(","),
} }
), ),
"send": (labels, event) => send(parameters, labels, event), "send": (events) => send(parameters, events),
}; };
} }

View file

@ -27,66 +27,129 @@ namespace _munin.targets.telegram_bot
bot_token : string; bot_token : string;
chat_id : int; chat_id : int;
hide_tags : boolean; hide_tags : boolean;
reminders : Array<int>; reminders : Array<_munin.type_reminder>;
language : string;
strings : {
notification_head : string;
};
}; };
/**
*/
function get_translation(
parameters : type_parameters,
path : string
) : string
{
return lib_plankton.translate.get_new(
path,
{
"preferred_language": parameters.language,
}
);
}
/**
*/
function render_event(
parameters : type_parameters,
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": get_translation(parameters, "core.event.title.short").toUpperCase(),
"title_value": event.title,
"time_label": get_translation(parameters, "core.event.time.short").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": get_translation(parameters, "core.event.location.short").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( async function send(
parameters : type_parameters, parameters : type_parameters,
labels : _munin.type_labels, events : Array<_munin.type_event>
event : _munin.type_event
) : Promise<void> ) : Promise<void>
{ {
await lib_plankton.telegram.bot_call_send_message( await lib_plankton.telegram.bot_call_send_message(
parameters.bot_token, parameters.bot_token,
parameters.chat_id, parameters.chat_id,
lib_plankton.string.coin( lib_plankton.string.coin(
"*{{head}}*\n\n\{{title_label}} | {{macro_tags}}{{title_value}}\n{{time_label}} | {{time_value}}{{macro_location}}{{macro_description}}", "*{{head}}*\n\n{{body}}",
{ {
"head": labels.head, "head": lib_plankton.string.coin(
"macro_tags": ( parameters.strings.notification_head,
(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, "core": lib_plankton.string.coin(
"{{label_event}}-{{label_reminder}}",
{
"label_event": lib_plankton.string.capitalize(get_translation(parameters, "core.event.event")),
"label_reminder": lib_plankton.string.capitalize(get_translation(parameters, "core.reminder.reminder")),
}
),
"extra": (
(events.length <= 1)
?
""
:
lib_plankton.string.coin(
" ({{count}} {{events}})",
{
"count": events.length.toFixed(0),
"events": get_translation(parameters, "core.event.events"),
}
)
),
} }
), ),
"macro_location": ( "body": (
(event.location === null) events
? .map(event => render_event(parameters, event))
"" .join("\n\n--------\n\n")
:
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,
}
)
), ),
} }
), ),
@ -111,7 +174,7 @@ namespace _munin.targets.telegram_bot
"chat_id": parameters.chat_id.toFixed(0), "chat_id": parameters.chat_id.toFixed(0),
} }
), ),
"send": (labels, event) => send(parameters, labels, event), "send": (events) => send(parameters, 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/test/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,15 +20,15 @@ along with »munin«. If not, see <http://www.gnu.org/licenses/>.
namespace _munin namespace _munin
{ {
/** /**
*/ */
export type type_labels = { export enum enum_frequency {
head : string; hourly = "hourly",
title : string; daily = "daily",
time : string; weekly = "weekly",
location : string; monthly = "monthly",
}; }
/** /**
@ -55,16 +55,24 @@ namespace _munin
}; };
/**
* @todo rename
*/
export type type_reminder = {
frequency : enum_frequency;
offset : int;
from : int;
to : int;
};
/** /**
*/ */
export type type_target = { export type type_target = {
reminders : Array<int>; reminders : Array<type_reminder>;
show : (() => string); show : (() => string);
send : ( send : (
( (events : Array<type_event>)
labels : type_labels,
event : type_event
)
=> =>
Promise<void> Promise<void>
); );

View file

@ -1,4 +1,13 @@
#!/usr/bin/env sh #!/usr/bin/env bash
## core
tools/ivaldi build 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 -