iobroker.kanban 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/admin/i18n/de.json +100 -0
  4. package/admin/i18n/en.json +100 -0
  5. package/admin/i18n/es.json +100 -0
  6. package/admin/i18n/fr.json +100 -0
  7. package/admin/i18n/it.json +100 -0
  8. package/admin/i18n/nl.json +100 -0
  9. package/admin/i18n/pl.json +100 -0
  10. package/admin/i18n/pt.json +100 -0
  11. package/admin/i18n/ru.json +100 -0
  12. package/admin/i18n/uk.json +100 -0
  13. package/admin/i18n/zh-cn.json +100 -0
  14. package/admin/jsonConfig.json +668 -0
  15. package/admin/kanban.png +0 -0
  16. package/io-package.json +299 -0
  17. package/lib/cron.js +203 -0
  18. package/lib/dueat.js +77 -0
  19. package/lib/events.js +24 -0
  20. package/lib/holidays.js +167 -0
  21. package/lib/i18n-server.js +301 -0
  22. package/lib/notify.js +627 -0
  23. package/lib/scheduler.js +198 -0
  24. package/lib/server.js +799 -0
  25. package/lib/store.js +1500 -0
  26. package/main.js +382 -0
  27. package/package.json +58 -0
  28. package/www/css/app.css +1022 -0
  29. package/www/i18n/de.json +238 -0
  30. package/www/i18n/en.json +238 -0
  31. package/www/i18n/es.json +238 -0
  32. package/www/i18n/fr.json +238 -0
  33. package/www/i18n/it.json +238 -0
  34. package/www/i18n/nl.json +238 -0
  35. package/www/i18n/pl.json +238 -0
  36. package/www/i18n/pt.json +238 -0
  37. package/www/i18n/ru.json +238 -0
  38. package/www/i18n/uk.json +238 -0
  39. package/www/i18n/zh-cn.json +238 -0
  40. package/www/index.html +171 -0
  41. package/www/js/api.js +98 -0
  42. package/www/js/app.js +411 -0
  43. package/www/js/board.js +983 -0
  44. package/www/js/colorpicker.js +165 -0
  45. package/www/js/dialogs.js +1419 -0
  46. package/www/js/i18n.js +43 -0
  47. package/www/vendor/marked.min.js +69 -0
  48. package/www/vendor/purify.min.js +3 -0
  49. package/www/vendor/sortable.min.js +2 -0
package/lib/notify.js ADDED
@@ -0,0 +1,627 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Dispatcher für Benachrichtigungen: hört auf den Event-Bus und verteilt an
5
+ * - kanban.0.lastEvent (State, für Skript-Trigger)
6
+ * - E-Mail via sendTo(<emailInstance>, 'send', ...)
7
+ * - ausgehende Webhooks (HTTP POST, natives fetch, 5 s Timeout, 1 Retry)
8
+ */
9
+
10
+ const { serverT } = require('./i18n-server');
11
+ const { buildDueAt } = require('./dueat');
12
+
13
+ const EVENT_TO_CONFIG = {
14
+ cardAssigned: 'notifyAssigned',
15
+ cardDue: 'notifyDue',
16
+ cardCreated: 'notifyCreated',
17
+ cardUpdated: 'notifyUpdated',
18
+ cardMoved: 'notifyMoved',
19
+ cardDone: 'notifyDone',
20
+ cardDeleted: 'notifyDeleted',
21
+ cardRestored: 'notifyRestored',
22
+ cardPurged: 'notifyPurged',
23
+ };
24
+
25
+ // ------------------------------------------------------------ iCalendar (.ics)
26
+ const icsEsc = s =>
27
+ String(s || '')
28
+ .replace(/\\/g, '\\\\')
29
+ .replace(/;/g, '\\;')
30
+ .replace(/,/g, '\\,')
31
+ .replace(/\r?\n/g, '\\n');
32
+ const pad2 = n => String(n).padStart(2, '0');
33
+ function icsStampUTC(dt) {
34
+ return `${dt.getUTCFullYear()}${pad2(dt.getUTCMonth() + 1)}${pad2(dt.getUTCDate())}T${pad2(dt.getUTCHours())}${pad2(dt.getUTCMinutes())}${pad2(dt.getUTCSeconds())}Z`;
35
+ }
36
+ function ymd(d) {
37
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}`;
38
+ }
39
+
40
+ /**
41
+ * Wandelt eine Wanduhr-Zeit in Zeitzone `tz` nach UTC – unabhängig davon, in
42
+ * welcher Zeitzone der Node-Prozess läuft. Berücksichtigt DST, da Intl die
43
+ * Zonenregeln kennt. (Kein externes date-fns/luxon nötig.)
44
+ *
45
+ * @param y
46
+ * @param mo
47
+ * @param d
48
+ * @param hh
49
+ * @param mm
50
+ * @param tz
51
+ */
52
+ function zonedTimeToUtc(y, mo, d, hh, mm, tz) {
53
+ const guessUtc = Date.UTC(y, mo - 1, d, hh, mm, 0);
54
+ const dtf = new Intl.DateTimeFormat('en-US', {
55
+ timeZone: tz,
56
+ hourCycle: 'h23',
57
+ year: 'numeric',
58
+ month: '2-digit',
59
+ day: '2-digit',
60
+ hour: '2-digit',
61
+ minute: '2-digit',
62
+ second: '2-digit',
63
+ });
64
+ const map = {};
65
+ for (const p of dtf.formatToParts(new Date(guessUtc))) {
66
+ if (p.type !== 'literal') {
67
+ map[p.type] = Number(p.value);
68
+ }
69
+ }
70
+ const asUtc = Date.UTC(map.year, map.month - 1, map.day, map.hour, map.minute, map.second);
71
+ return new Date(guessUtc - (asUtc - guessUtc));
72
+ }
73
+
74
+ // Wochentag 1..7 (Mo..So) → iCalendar-Kürzel
75
+ const ICS_DAYS = { 1: 'MO', 2: 'TU', 3: 'WE', 4: 'TH', 5: 'FR', 6: 'SA', 7: 'SU' };
76
+
77
+ /**
78
+ * Cron-Muster → RRULE, soweit der Kalenderstandard mitkommt. Abgedeckt sind die
79
+ * Fälle, die sich eins zu eins übersetzen lassen; alles Verschachtelte (Tag UND
80
+ * Wochentag, Schrittweiten quer über Felder) bleibt ein Einzeltermin.
81
+ *
82
+ * @param expr
83
+ */
84
+ function cronRrule(expr) {
85
+ const p = String(expr || '')
86
+ .trim()
87
+ .split(/\s+/);
88
+ if (p.length !== 5) {
89
+ return '';
90
+ }
91
+ const [, , dom, mon, dow] = p;
92
+ const list = (spec, map) => {
93
+ if (!/^[\d,-]+$/.test(spec)) {
94
+ return null;
95
+ }
96
+ const out = [];
97
+ for (const part of spec.split(',')) {
98
+ if (part.includes('-')) {
99
+ const [a, b] = part.split('-').map(Number);
100
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a > b) {
101
+ return null;
102
+ }
103
+ for (let v = a; v <= b; v++) {
104
+ out.push(map ? map(v) : v);
105
+ }
106
+ } else {
107
+ const v = Number(part);
108
+ if (!Number.isInteger(v)) {
109
+ return null;
110
+ }
111
+ out.push(map ? map(v) : v);
112
+ }
113
+ }
114
+ return out.filter(v => v !== undefined && v !== null);
115
+ };
116
+ const domAll = dom === '*';
117
+ const dowAll = dow === '*';
118
+ const monAll = mon === '*';
119
+
120
+ if (domAll && dowAll && monAll) {
121
+ return 'RRULE:FREQ=DAILY';
122
+ }
123
+ if (domAll && monAll && !dowAll) {
124
+ // Cron kennt Sonntag als 0 und als 7, ICS_DAYS ist mit 1..7 (Mo..So) indiziert:
125
+ // ein blosses v % 7 macht aus beiden die 0 und liess den Sonntag herausfallen.
126
+ const d = list(dow, v => ICS_DAYS[v === 0 ? 7 : v]);
127
+ const uniq = d && [...new Set(d)]; // '0,7' nennt denselben Tag zweimal
128
+ return uniq && uniq.length ? `RRULE:FREQ=WEEKLY;BYDAY=${uniq.join(',')}` : '';
129
+ }
130
+ if (dowAll && monAll && !domAll) {
131
+ const d = list(dom);
132
+ return d && d.length ? `RRULE:FREQ=MONTHLY;BYMONTHDAY=${d.join(',')}` : '';
133
+ }
134
+ if (dowAll && !monAll && !domAll) {
135
+ const m = list(mon);
136
+ const d = list(dom);
137
+ return m && d && m.length && d.length
138
+ ? `RRULE:FREQ=YEARLY;BYMONTH=${m.join(',')};BYMONTHDAY=${d.join(',')}`
139
+ : '';
140
+ }
141
+ return '';
142
+ }
143
+
144
+ /**
145
+ * Wiederholungsregel der Karte → RRULE. Gibt '' zurück, wenn sich die Regel nicht
146
+ * abbilden lässt: „Arbeitstag im Monat" hängt an Feiertagen, die der Kalender-
147
+ * standard nicht kennt – solche Karten bleiben Einzeltermine.
148
+ *
149
+ * @param rec
150
+ */
151
+ function buildRrule(rec) {
152
+ if (!rec || !rec.type || rec.type === 'none') {
153
+ return '';
154
+ }
155
+ const days = (rec.dayOfWeek && rec.dayOfWeek.length ? rec.dayOfWeek : [1]).map(d => ICS_DAYS[d]).filter(Boolean);
156
+ switch (rec.type) {
157
+ case 'daily':
158
+ return 'RRULE:FREQ=DAILY';
159
+ case 'every_n_days':
160
+ return `RRULE:FREQ=DAILY;INTERVAL=${Math.max(1, Number(rec.interval) || 1)}`;
161
+ case 'weekly':
162
+ return days.length ? `RRULE:FREQ=WEEKLY;BYDAY=${days.join(',')}` : 'RRULE:FREQ=WEEKLY';
163
+ case 'monthly':
164
+ return `RRULE:FREQ=MONTHLY;BYMONTHDAY=${Math.min(31, Math.max(1, Number(rec.dayOfMonth) || 1))}`;
165
+ case 'monthly_weekday': {
166
+ const pos = Number(rec.ordinal) === -1 ? -1 : Math.min(4, Math.max(1, Number(rec.ordinal) || 1));
167
+ return `RRULE:FREQ=MONTHLY;BYDAY=${days[0] || 'MO'};BYSETPOS=${pos}`;
168
+ }
169
+ case 'yearly':
170
+ return `RRULE:FREQ=YEARLY;BYMONTH=${Math.min(12, Math.max(1, Number(rec.month) || 1))};BYMONTHDAY=${Math.min(31, Math.max(1, Number(rec.dayOfMonth) || 1))}`;
171
+ case 'cron':
172
+ return cronRrule(rec.cron);
173
+ default:
174
+ return ''; // workday: nicht abbildbar
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Fingerabdruck der terminrelevanten Felder. Ändert er sich, geht eine
180
+ * aktualisierte Einladung raus (gleiche UID, höhere SEQUENCE); sonst nicht.
181
+ *
182
+ * @param card
183
+ */
184
+ function icsFingerprint(card) {
185
+ return JSON.stringify([card.due || '', card.dueTime || '', card.calendarDuration || '', card.recurrence || null]);
186
+ }
187
+
188
+ /**
189
+ * VEVENT aus einer Karte. dueTime → Termin mit Uhrzeit (Dauer aus calendarDuration,
190
+ * Standard 1 h), sonst ganztägig. Mit Wiederholung entsteht ein Serientermin.
191
+ *
192
+ * @param card
193
+ * @param tz
194
+ */
195
+ function buildIcs(card, tz) {
196
+ const zone = tz || 'Europe/Berlin';
197
+ const [y, m, d] = card.due.split('-');
198
+ let dtStart, dtEnd;
199
+ if (card.dueTime) {
200
+ // Termin mit Uhrzeit: als Wanduhr-Zeit in der ermittelten Zeitzone
201
+ // interpretieren und nach UTC (…Z) umrechnen → für jeden Empfänger eindeutig.
202
+ const [hh, mm] = card.dueTime.split(':');
203
+ const startUtc = zonedTimeToUtc(Number(y), Number(m), Number(d), Number(hh), Number(mm), zone);
204
+ // Dauer der Karte (HH:MM, Standard 1 Stunde); 00:00 -> ebenfalls 1 Stunde
205
+ const dur = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(card.calendarDuration || '01:00'));
206
+ const durMin = dur ? Number(dur[1]) * 60 + Number(dur[2]) : 60;
207
+ const endUtc = new Date(startUtc.getTime() + (durMin > 0 ? durMin : 60) * 60 * 1000);
208
+ dtStart = `DTSTART:${icsStampUTC(startUtc)}`;
209
+ dtEnd = `DTEND:${icsStampUTC(endUtc)}`;
210
+ } else {
211
+ // Ganztägig: VALUE=DATE ist bewusst zeitzonenlos (Ende exklusiv = Folgetag)
212
+ const next = new Date(Number(y), Number(m) - 1, Number(d) + 1);
213
+ dtStart = `DTSTART;VALUE=DATE:${y}${m}${d}`;
214
+ dtEnd = `DTEND;VALUE=DATE:${ymd(next)}`;
215
+ }
216
+ const lines = [
217
+ 'BEGIN:VCALENDAR',
218
+ 'VERSION:2.0',
219
+ 'PRODID:-//ioBroker//Kanban//DE',
220
+ 'CALSCALE:GREGORIAN',
221
+ 'METHOD:PUBLISH',
222
+ 'BEGIN:VEVENT',
223
+ // UID bleibt über die ganze Wiederholungskette gleich, damit der Kalender
224
+ // die Folgekarten als denselben Serientermin erkennt.
225
+ `UID:${card.icsUid || card.id}@kanban.iobroker`,
226
+ `SEQUENCE:${Number(card.icsSeq) || 0}`,
227
+ `DTSTAMP:${icsStampUTC(new Date())}`,
228
+ dtStart,
229
+ dtEnd,
230
+ `SUMMARY:${icsEsc(card.title)}`,
231
+ ];
232
+ const rrule = buildRrule(card.recurrence);
233
+ if (rrule) {
234
+ lines.push(rrule);
235
+ }
236
+ if (card.description) {
237
+ lines.push(`DESCRIPTION:${icsEsc(card.description)}`);
238
+ }
239
+ if (card.location) {
240
+ lines.push(`LOCATION:${icsEsc(card.location)}`);
241
+ }
242
+ if (card.link) {
243
+ lines.push(`URL:${icsEsc(card.link)}`);
244
+ }
245
+ lines.push('END:VEVENT', 'END:VCALENDAR');
246
+ return lines.join('\r\n');
247
+ }
248
+
249
+ class Notifier {
250
+ constructor(adapter, getBaseUrl, getTimezone) {
251
+ this.adapter = adapter;
252
+ this.getBaseUrl = getBaseUrl; // () => 'http://host:8095'
253
+ this.getTimezone = getTimezone || (() => 'Europe/Berlin');
254
+ }
255
+
256
+ attach(bus) {
257
+ bus.on('event', event => {
258
+ this._dispatch(event).catch(e =>
259
+ this.adapter.log.error(`Notification failed (${event.event}): ${e.message}`),
260
+ );
261
+ });
262
+ }
263
+
264
+ async _dispatch(event) {
265
+ // Fertigen Deep-Link zur Karte anhaengen (fuer lastEvent-Skripte und ausgehende Webhooks)
266
+ if (event && event.board && event.board.id && event.card && event.card.id) {
267
+ const base = this.getBaseUrl && this.getBaseUrl();
268
+ if (base) {
269
+ event.link = `${base}/?board=${encodeURIComponent(event.board.id)}&card=${encodeURIComponent(event.card.id)}`;
270
+ }
271
+ }
272
+ // Fälligkeit inkl. Uhrzeit als kombiniertes Feld (lokaler Offset) mitgeben
273
+ if (event && event.card) {
274
+ event.dueAt = buildDueAt(event.card, this.getTimezone && this.getTimezone());
275
+ }
276
+ await this.adapter.setStateAsync('lastEvent', JSON.stringify(event), true);
277
+ await Promise.allSettled([this._sendEmails(event), this._sendWebhooks(event)]);
278
+ }
279
+
280
+ // ------------------------------------------------------------ E-Mail
281
+
282
+ _userByName(name) {
283
+ return (this.adapter.config.users || []).find(u => u.name === name);
284
+ }
285
+
286
+ // Will dieser Benutzer bei diesem Event-Typ benachrichtigt werden?
287
+ // Eigene Einstellung sticht; ist sie nicht gesetzt, greift die globale Vorgabe.
288
+ _userWants(u, flag) {
289
+ if (!flag) {
290
+ return false;
291
+ }
292
+ const v = u[flag];
293
+ if (v === undefined || v === null || v === '') {
294
+ return !!this.adapter.config[flag];
295
+ }
296
+ return !!v;
297
+ }
298
+
299
+ // Mitglieder des Boards; ohne Mitgliederliste ersatzweise die Zuständigen der Karte
300
+ /**
301
+ * Entscheidet, ob dieser Mail eine Kalender-Einladung beiliegt.
302
+ * Rückgabe: { seq } zum Versenden, sonst null.
303
+ *
304
+ * @param event
305
+ * @param card
306
+ */
307
+ _icsDecision(event, card) {
308
+ if (!card || !card.calendarInvite || !card.due) {
309
+ return null;
310
+ }
311
+ if (event.detail && event.detail.recurrence) {
312
+ return null;
313
+ } // Folgekarte einer Serie
314
+ const fp = icsFingerprint(card);
315
+ const changed = fp !== (card.icsFingerprint || '');
316
+ const firstContact = event.event === 'cardCreated' || event.event === 'cardAssigned';
317
+ if (!changed && !firstContact) {
318
+ return null;
319
+ }
320
+ // Bei geändertem Termin die Sequenz erhöhen, damit Kalender den bestehenden
321
+ // Eintrag aktualisieren statt einen zweiten anzulegen.
322
+ const seq = changed && card.icsFingerprint ? (Number(card.icsSeq) || 0) + 1 : Number(card.icsSeq) || 0;
323
+ const store = this.adapter.store;
324
+ if (store && store.markIcsSent && event.board) {
325
+ try {
326
+ store.markIcsSent(event.board.id, card.id, fp, seq);
327
+ } catch {
328
+ /* ignore */
329
+ }
330
+ }
331
+ return { seq };
332
+ }
333
+
334
+ _boardMembers(event) {
335
+ const store = this.adapter.store;
336
+ const board = store && event.board && store.boards && store.boards.get(event.board.id);
337
+ const members = board && Array.isArray(board.members) ? board.members : [];
338
+ // Nur Mitglieder zaehlen, die es als Benutzer noch gibt: nach dem Umbenennen
339
+ // einer Benutzer-ID stehen sonst tote Namen in der Liste und niemand bekommt Post.
340
+ const known = new Set((this.adapter.config.users || []).map(u => u && u.name).filter(Boolean));
341
+ const alive = members.filter(n => known.has(n));
342
+ return alive.length ? alive : (event.card && event.card.assignees) || [];
343
+ }
344
+
345
+ _recipientUsers(event) {
346
+ // Zuweisung: nur der neu Zugewiesene
347
+ // Neue Karte: alle Mitglieder des Boards, unabhängig von der Zuständigkeit
348
+ // sonst: die Zuständigen der Karte
349
+ let names;
350
+ if (event.event === 'cardAssigned') {
351
+ names = [event.detail && event.detail.assignee].filter(Boolean);
352
+ } else if (event.event === 'cardCreated') {
353
+ names = this._boardMembers(event);
354
+ } else {
355
+ names = (event.card && event.card.assignees) || [];
356
+ }
357
+ const seen = new Set();
358
+ const users = [];
359
+ for (const n of names) {
360
+ const u = this._userByName(n);
361
+ if (u && u.email && !seen.has(u.email)) {
362
+ seen.add(u.email);
363
+ users.push(u);
364
+ }
365
+ }
366
+ return users;
367
+ }
368
+
369
+ async _sendEmails(event) {
370
+ const cfg = this.adapter.config;
371
+ const flag = EVENT_TO_CONFIG[event.event];
372
+ if (!flag || !cfg.emailInstance) {
373
+ return;
374
+ }
375
+ // Automatische Massenaktionen (Aufräumen/Purge) nicht pro Karte mailen –
376
+ // sie werden über sendCleanupSummary gebündelt verschickt.
377
+ if (event.detail && event.detail.auto) {
378
+ return;
379
+ }
380
+ // Auslöser der Änderung nicht sich selbst benachrichtigen
381
+ const by = (event.detail && event.detail.by) || null;
382
+ const users = this._recipientUsers(event)
383
+ .filter(u => u.name !== by)
384
+ .filter(u => this._userWants(u, flag));
385
+ if (!users.length) {
386
+ return;
387
+ }
388
+
389
+ const lang = this.adapter._language;
390
+ const label = serverT(lang, `ev.${event.event}`);
391
+ const card = event.card || {};
392
+ const subject = `${serverT(lang, 'mail.subjectPrefix')} ${label}: ${card.title || ''}`;
393
+ const html = this._renderHtml(event, label);
394
+
395
+ // Optionaler Kalender-Anhang (.ics), wenn an der Karte aktiviert + Datum vorhanden.
396
+ // Bei Serienterminen soll die Einladung nur EINMAL kommen: neu angelegte oder
397
+ // frisch zugewiesene Karten bekommen sie, Folgekarten einer Wiederholung nicht
398
+ // (die Serie liegt beim Empfänger schon im Kalender), und eine Aktualisierung
399
+ // nur, wenn sich Fälligkeit, Uhrzeit, Dauer oder Wiederholungsregel geändert haben.
400
+ let attachments;
401
+ const icsInfo = this._icsDecision(event, card);
402
+ if (icsInfo) {
403
+ attachments = [
404
+ {
405
+ filename: 'termin.ics',
406
+ content: buildIcs({ ...card, icsSeq: icsInfo.seq }, this.getTimezone()),
407
+ contentType: 'text/calendar; charset=utf-8; method=PUBLISH',
408
+ },
409
+ ];
410
+ }
411
+
412
+ for (const u of users) {
413
+ this.adapter.sendTo(cfg.emailInstance, 'send', {
414
+ to: u.email,
415
+ from: cfg.emailFrom || undefined,
416
+ subject,
417
+ html,
418
+ ...(attachments ? { attachments } : {}),
419
+ });
420
+ }
421
+ this.adapter.log.debug(
422
+ `E-mail '${subject}' to ${users.map(u => u.email).join(', ')}${attachments ? ' (+ .ics)' : ''}`,
423
+ );
424
+ }
425
+
426
+ _renderHtml(event, label) {
427
+ const esc = s =>
428
+ String(s || '')
429
+ .replace(/&/g, '&amp;')
430
+ .replace(/</g, '&lt;')
431
+ .replace(/>/g, '&gt;');
432
+ const card = event.card || {};
433
+ const board = event.board || {};
434
+ const accent = this.adapter.config.accentColor || '#7E57C2';
435
+ const lang = this.adapter._language;
436
+ const rows = [];
437
+ rows.push([serverT(lang, 'mail.board'), esc(board.title)]);
438
+ if (event.detail && event.detail.fromColumn && event.detail.toColumn) {
439
+ rows.push([serverT(lang, 'mail.moved'), `${esc(event.detail.fromColumn)} → ${esc(event.detail.toColumn)}`]);
440
+ }
441
+ if (card.due) {
442
+ rows.push([serverT(lang, 'mail.due'), esc(card.due)]);
443
+ }
444
+ if (card.assignees && card.assignees.length) {
445
+ const names = card.assignees.map(n => {
446
+ const u = this._userByName(n);
447
+ return esc(u ? u.displayName : n);
448
+ });
449
+ rows.push([serverT(lang, 'mail.assignees'), names.join(', ')]);
450
+ }
451
+ if (event.detail && event.detail.by) {
452
+ rows.push([serverT(lang, 'mail.by'), esc(event.detail.by)]);
453
+ }
454
+ if (card.description) {
455
+ rows.push([serverT(lang, 'mail.description'), esc(card.description).slice(0, 500)]);
456
+ }
457
+
458
+ const base = this.getBaseUrl();
459
+ // Link-Ziel je Board: 'url' = feste eigene Adresse, 'edit' = Karten-Editor, sonst Board-Ansicht (Karte hervorgehoben)
460
+ const attr = s =>
461
+ String(s || '')
462
+ .replace(/&/g, '&amp;')
463
+ .replace(/"/g, '&quot;')
464
+ .replace(/</g, '&lt;')
465
+ .replace(/>/g, '&gt;');
466
+ let href = '';
467
+ if (board.linkTarget === 'url' && board.linkUrl) {
468
+ href = board.linkUrl;
469
+ } else if (base && board.id && card.id) {
470
+ const key = board.linkTarget === 'edit' ? 'card' : 'focus';
471
+ href = `${base}/?board=${encodeURIComponent(board.id)}&${key}=${encodeURIComponent(card.id)}`;
472
+ }
473
+ const link = href
474
+ ? `<p><a href="${attr(href)}" style="color:${accent}">${serverT(lang, 'mail.openCard')}</a></p>`
475
+ : '';
476
+
477
+ return `<div style="font-family:sans-serif;max-width:560px">
478
+ <h2 style="color:${accent};margin-bottom:4px">${esc(label)}</h2>
479
+ <h3 style="margin-top:0">${esc(card.title)}</h3>
480
+ <table style="border-collapse:collapse">${rows
481
+ .map(
482
+ ([k, v]) =>
483
+ `<tr><td style="padding:2px 12px 2px 0;color:#777">${k}</td><td style="padding:2px 0">${v}</td></tr>`,
484
+ )
485
+ .join('')}
486
+ </table>
487
+ ${link}
488
+ <p style="color:#999;font-size:12px">ioBroker Kanban · ${esc(event.ts)}</p>
489
+ </div>`;
490
+ }
491
+
492
+ /**
493
+ * Gebündelte E-Mail-Zusammenfassung eines Aufräumlaufs: pro Benutzer eine Mail
494
+ * mit allen betroffenen Karten (statt einer Mail je Karte). moved = in den
495
+ * Papierkorb verschoben (notifyDeleted), purged = endgültig entfernt (notifyPurged).
496
+ *
497
+ * @param results
498
+ */
499
+ async sendCleanupSummary(results) {
500
+ const cfg = this.adapter.config;
501
+ if (!cfg.emailInstance || !Array.isArray(results) || !results.length) {
502
+ return;
503
+ }
504
+ const lang = this.adapter._language;
505
+ const perUser = new Map(); // email -> { user, boards: Map<title,{moved,purged}> }
506
+ const add = (u, boardTitle, kind, card) => {
507
+ if (!u || !u.email) {
508
+ return;
509
+ }
510
+ let e = perUser.get(u.email);
511
+ if (!e) {
512
+ e = { user: u, boards: new Map() };
513
+ perUser.set(u.email, e);
514
+ }
515
+ let b = e.boards.get(boardTitle);
516
+ if (!b) {
517
+ b = { moved: [], purged: [] };
518
+ e.boards.set(boardTitle, b);
519
+ }
520
+ b[kind].push(card);
521
+ };
522
+ for (const r of results) {
523
+ const boardTitle = r.board.title;
524
+ for (const card of r.moved) {
525
+ for (const name of card.assignees || []) {
526
+ const u = this._userByName(name);
527
+ if (u && this._userWants(u, 'notifyDeleted')) {
528
+ add(u, boardTitle, 'moved', card);
529
+ }
530
+ }
531
+ }
532
+ for (const card of r.purged) {
533
+ for (const name of card.assignees || []) {
534
+ const u = this._userByName(name);
535
+ if (u && this._userWants(u, 'notifyPurged')) {
536
+ add(u, boardTitle, 'purged', card);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ const subject = `${serverT(lang, 'mail.subjectPrefix')} ${serverT(lang, 'mail.cleanupSubject')}`;
542
+ for (const { user, boards } of perUser.values()) {
543
+ this.adapter.sendTo(cfg.emailInstance, 'send', {
544
+ to: user.email,
545
+ from: cfg.emailFrom || undefined,
546
+ subject,
547
+ html: this._renderCleanupHtml(boards, lang),
548
+ });
549
+ }
550
+ if (perUser.size) {
551
+ this.adapter.log.debug(`Cleanup summary to ${perUser.size} recipient(s)`);
552
+ }
553
+ }
554
+
555
+ _renderCleanupHtml(boards, lang) {
556
+ const esc = s =>
557
+ String(s || '')
558
+ .replace(/&/g, '&amp;')
559
+ .replace(/</g, '&lt;')
560
+ .replace(/>/g, '&gt;');
561
+ const accent = this.adapter.config.accentColor || '#7E57C2';
562
+ let out = '<div style="font-family:sans-serif;max-width:560px">';
563
+ for (const [boardTitle, g] of boards) {
564
+ out += `<h3 style="color:${accent};margin-bottom:4px">${esc(boardTitle)}</h3>`;
565
+ if (g.moved.length) {
566
+ out += `<p style="margin:4px 0;color:#555">${esc(serverT(lang, 'mail.cleanupIntro'))}</p><ul>${g.moved
567
+ .map(c => `<li>${esc(c.title)}</li>`)
568
+ .join('')}</ul>`;
569
+ }
570
+ if (g.purged.length) {
571
+ out += `<p style="margin:4px 0;color:#555">${esc(serverT(lang, 'mail.purgedIntro'))}</p><ul>${g.purged
572
+ .map(c => `<li>${esc(c.title)}</li>`)
573
+ .join('')}</ul>`;
574
+ }
575
+ }
576
+ out += '<p style="color:#999;font-size:12px">ioBroker Kanban</p></div>';
577
+ return out;
578
+ }
579
+
580
+ // ------------------------------------------------------------ Webhooks ausgehend
581
+
582
+ async _sendWebhooks(event) {
583
+ const hooks = (this.adapter.config.outboundWebhooks || []).filter(
584
+ h => h && h.enabled !== false && h.url && this._matchesEvents(h.events, event.event),
585
+ );
586
+ for (const hook of hooks) {
587
+ this._post(hook, event).catch(e =>
588
+ this.adapter.log.warn(`Webhook '${hook.name || hook.url}' failed: ${e.message}`),
589
+ );
590
+ }
591
+ }
592
+
593
+ _matchesEvents(eventsCfg, type) {
594
+ const s = String(eventsCfg || '*').trim();
595
+ if (!s || s === '*') {
596
+ return true;
597
+ }
598
+ return s.split(/[\s,;]+/).includes(type);
599
+ }
600
+
601
+ async _post(hook, event, isRetry) {
602
+ const controller = new AbortController();
603
+ const timer = this.adapter.setTimeout(() => controller.abort(), 5000);
604
+ try {
605
+ const res = await fetch(hook.url, {
606
+ method: 'POST',
607
+ headers: { 'Content-Type': 'application/json' },
608
+ body: JSON.stringify(event),
609
+ signal: controller.signal,
610
+ });
611
+ if (!res.ok) {
612
+ throw new Error(`HTTP ${res.status}`);
613
+ }
614
+ this.adapter.log.debug(`Webhook '${hook.name || hook.url}' -> ${event.event} OK`);
615
+ } catch (e) {
616
+ if (!isRetry) {
617
+ await new Promise(r => this.adapter.setTimeout(r, 2000));
618
+ return this._post(hook, event, true);
619
+ }
620
+ throw e;
621
+ } finally {
622
+ this.adapter.clearTimeout(timer);
623
+ }
624
+ }
625
+ }
626
+
627
+ module.exports = { Notifier, buildIcs, buildRrule, icsFingerprint };