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.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/admin/i18n/de.json +100 -0
- package/admin/i18n/en.json +100 -0
- package/admin/i18n/es.json +100 -0
- package/admin/i18n/fr.json +100 -0
- package/admin/i18n/it.json +100 -0
- package/admin/i18n/nl.json +100 -0
- package/admin/i18n/pl.json +100 -0
- package/admin/i18n/pt.json +100 -0
- package/admin/i18n/ru.json +100 -0
- package/admin/i18n/uk.json +100 -0
- package/admin/i18n/zh-cn.json +100 -0
- package/admin/jsonConfig.json +668 -0
- package/admin/kanban.png +0 -0
- package/io-package.json +299 -0
- package/lib/cron.js +203 -0
- package/lib/dueat.js +77 -0
- package/lib/events.js +24 -0
- package/lib/holidays.js +167 -0
- package/lib/i18n-server.js +301 -0
- package/lib/notify.js +627 -0
- package/lib/scheduler.js +198 -0
- package/lib/server.js +799 -0
- package/lib/store.js +1500 -0
- package/main.js +382 -0
- package/package.json +58 -0
- package/www/css/app.css +1022 -0
- package/www/i18n/de.json +238 -0
- package/www/i18n/en.json +238 -0
- package/www/i18n/es.json +238 -0
- package/www/i18n/fr.json +238 -0
- package/www/i18n/it.json +238 -0
- package/www/i18n/nl.json +238 -0
- package/www/i18n/pl.json +238 -0
- package/www/i18n/pt.json +238 -0
- package/www/i18n/ru.json +238 -0
- package/www/i18n/uk.json +238 -0
- package/www/i18n/zh-cn.json +238 -0
- package/www/index.html +171 -0
- package/www/js/api.js +98 -0
- package/www/js/app.js +411 -0
- package/www/js/board.js +983 -0
- package/www/js/colorpicker.js +165 -0
- package/www/js/dialogs.js +1419 -0
- package/www/js/i18n.js +43 -0
- package/www/vendor/marked.min.js +69 -0
- package/www/vendor/purify.min.js +3 -0
- 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, '&')
|
|
430
|
+
.replace(/</g, '<')
|
|
431
|
+
.replace(/>/g, '>');
|
|
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, '&')
|
|
463
|
+
.replace(/"/g, '"')
|
|
464
|
+
.replace(/</g, '<')
|
|
465
|
+
.replace(/>/g, '>');
|
|
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, '&')
|
|
559
|
+
.replace(/</g, '<')
|
|
560
|
+
.replace(/>/g, '>');
|
|
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 };
|