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/store.js ADDED
@@ -0,0 +1,1500 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Datenhaltung: alle Boards im RAM, Primärspeicher ist je Board der JSON-State
5
+ * kanban.0.boards.<boardId>.data. Jede Mutation erhöht board.rev und schreibt
6
+ * debounced (500 ms) in die States. Spiegel-States (cardCount, overdueCount,
7
+ * users.<name>.*) werden bei jeder Mutation und vom Scheduler aktualisiert.
8
+ */
9
+
10
+ const holidays = require('./holidays');
11
+ const { parseCron, cronMatchesDate, validateCron } = require('./cron');
12
+ const { serverT } = require('./i18n-server');
13
+
14
+ const CARD_FIELDS = [
15
+ 'title',
16
+ 'description',
17
+ 'assignees',
18
+ 'due',
19
+ 'dueTime',
20
+ 'labels',
21
+ 'color',
22
+ 'priority',
23
+ 'checklist',
24
+ 'link',
25
+ 'location',
26
+ 'calendarInvite',
27
+ 'calendarDuration',
28
+ 'recurrence',
29
+ ];
30
+
31
+ // Papierkorb: Karten bleiben nach dem Löschen so viele Tage im Papierkorb,
32
+ // bevor sie endgültig entfernt werden. Global fix (nicht pro Board einstellbar).
33
+ const TRASH_RETENTION_DAYS = 30;
34
+
35
+ // Standard-Aufräumeinstellung je Board (aus Erledigt in den Papierkorb).
36
+ function defaultCleanup() {
37
+ return { mode: 'off', days: 90, count: 100 };
38
+ }
39
+ function normalizeCleanup(cl) {
40
+ cl = cl && typeof cl === 'object' ? cl : {};
41
+ return {
42
+ mode: ['off', 'age', 'count'].includes(cl.mode) ? cl.mode : 'off',
43
+ days: Number(cl.days) > 0 ? Math.floor(Number(cl.days)) : 90,
44
+ count: Number(cl.count) > 0 ? Math.floor(Number(cl.count)) : 100,
45
+ };
46
+ }
47
+
48
+ // ---------------------------------------------------------------- Wiederholung
49
+ // recurrence = { type, dayOfWeek:[1..7], dayOfMonth:1..31, month:1..12,
50
+ // interval:N, startDate:'YYYY-MM-DD',
51
+ // ordinal:1..5|-1, workdayPos:'first'|'last'|'nth'|'nth_last', n:N,
52
+ // cron:'0 8 * * 1-5' }
53
+ // Analog zur Lovelace-ToDo-Karte, erweitert um:
54
+ // - monthly_weekday: n-ter (oder letzter) Wochentag im Monat (z.B. 2. Dienstag)
55
+ // - workday: erster/letzter/n-ter Arbeitstag im Monat (Feiertage via holidays.js)
56
+ // - cron: fuenffeldriger Cron-Ausdruck als Muster (siehe cron.js); Minute und
57
+ // Stunde setzen die Uhrzeit der Folgekarte
58
+ // Beim Erledigen einer wiederkehrenden Karte wird eine frische Karte mit dem
59
+ // naechsten Faelligkeitsdatum erzeugt.
60
+ function isoWeekday(d) {
61
+ return d.getDay() === 0 ? 7 : d.getDay();
62
+ }
63
+ function daysInMonth(d) {
64
+ return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
65
+ }
66
+ function parseDateStr(s) {
67
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s || ''));
68
+ if (!m) {
69
+ return null;
70
+ }
71
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12, 0, 0, 0);
72
+ }
73
+ function fmtDateStr(d) {
74
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
75
+ }
76
+ function recMatches(rec, d) {
77
+ switch (rec.type) {
78
+ case 'daily':
79
+ return true;
80
+ case 'weekly': {
81
+ const days = rec.dayOfWeek && rec.dayOfWeek.length ? rec.dayOfWeek : [1];
82
+ return days.includes(isoWeekday(d));
83
+ }
84
+ case 'monthly': {
85
+ const dom = rec.dayOfMonth || 1;
86
+ return d.getDate() === Math.min(dom, daysInMonth(d)); // 31. -> letzter Tag kurzer Monate
87
+ }
88
+ case 'yearly':
89
+ return (
90
+ d.getMonth() + 1 === (rec.month || 1) && d.getDate() === Math.min(rec.dayOfMonth || 1, daysInMonth(d))
91
+ );
92
+ case 'every_n_days': {
93
+ const n = Math.max(1, Number(rec.interval) || 1);
94
+ const start = parseDateStr(rec.startDate) || new Date();
95
+ const diff = Math.round((d - start) / 86400000);
96
+ return diff >= 0 && diff % n === 0;
97
+ }
98
+ case 'monthly_weekday': {
99
+ // n-ter (oder letzter) Wochentag im Monat, z.B. 2. Dienstag / letzter Freitag
100
+ const wd = rec.dayOfWeek && rec.dayOfWeek.length ? rec.dayOfWeek[0] : 1;
101
+ if (isoWeekday(d) !== wd) {
102
+ return false;
103
+ }
104
+ const ordinal = Number(rec.ordinal) || 1;
105
+ if (ordinal === -1) {
106
+ return d.getDate() + 7 > daysInMonth(d);
107
+ } // letzter dieses Wochentags
108
+ return Math.ceil(d.getDate() / 7) === ordinal;
109
+ }
110
+ case 'cron': {
111
+ // Muster statt Zeitplan: nur die Datumsfelder entscheiden, welcher Tag passt
112
+ try {
113
+ return cronMatchesDate(parseCron(rec.cron), d);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+ case 'workday': {
119
+ // erster/letzter/n-ter Arbeitstag im Monat (Wochenende + Feiertage übersprungen)
120
+ if (!holidays.isWorkday(d)) {
121
+ return false;
122
+ }
123
+ const pos = rec.workdayPos || 'first';
124
+ const n = Math.max(1, Number(rec.n) || 1);
125
+ if (pos === 'first') {
126
+ return workdayIndex(d, true) === 1;
127
+ }
128
+ if (pos === 'last') {
129
+ return workdayIndex(d, false) === 1;
130
+ }
131
+ if (pos === 'nth') {
132
+ return workdayIndex(d, true) === n;
133
+ }
134
+ if (pos === 'nth_last') {
135
+ return workdayIndex(d, false) === n;
136
+ }
137
+ return false;
138
+ }
139
+ default:
140
+ return false;
141
+ }
142
+ }
143
+ // Position des Arbeitstags d im Monat: fromStart -> von vorn (1=erster),
144
+ // sonst von hinten (1=letzter). Zählt nur Arbeitstage.
145
+ function workdayIndex(d, fromStart) {
146
+ const dim = daysInMonth(d);
147
+ const day = d.getDate();
148
+ let idx = 0;
149
+ if (fromStart) {
150
+ for (let x = 1; x <= day; x++) {
151
+ const c = new Date(d.getFullYear(), d.getMonth(), x, 12, 0, 0, 0);
152
+ if (holidays.isWorkday(c)) {
153
+ idx++;
154
+ }
155
+ }
156
+ } else {
157
+ for (let x = dim; x >= day; x--) {
158
+ const c = new Date(d.getFullYear(), d.getMonth(), x, 12, 0, 0, 0);
159
+ if (holidays.isWorkday(c)) {
160
+ idx++;
161
+ }
162
+ }
163
+ }
164
+ return idx;
165
+ }
166
+ /**
167
+ * Nächstes Fälligkeitsdatum STRIKT nach afterStr (bzw. heute). '' wenn keins.
168
+ *
169
+ * @param rec
170
+ * @param afterStr
171
+ */
172
+ function nextDue(rec, afterStr) {
173
+ if (!rec || !rec.type || rec.type === 'none') {
174
+ return '';
175
+ }
176
+ const base = parseDateStr(afterStr) || new Date();
177
+ for (let i = 1; i <= 800; i++) {
178
+ const d = new Date(base.getTime());
179
+ d.setDate(d.getDate() + i);
180
+ if (recMatches(rec, d)) {
181
+ return fmtDateStr(d);
182
+ }
183
+ }
184
+ return '';
185
+ }
186
+ /**
187
+ * Erstes Fälligkeitsdatum AB fromStr (heute inklusive). '' wenn keins.
188
+ * Für neu angelegte wiederkehrende Karten ohne manuelles Datum.
189
+ *
190
+ * @param rec
191
+ * @param fromStr
192
+ */
193
+ function firstDue(rec, fromStr) {
194
+ if (!rec || !rec.type || rec.type === 'none') {
195
+ return '';
196
+ }
197
+ const base = parseDateStr(fromStr) || new Date();
198
+ for (let i = 0; i <= 800; i++) {
199
+ const d = new Date(base.getTime());
200
+ d.setDate(d.getDate() + i);
201
+ if (recMatches(rec, d)) {
202
+ return fmtDateStr(d);
203
+ }
204
+ }
205
+ return '';
206
+ }
207
+ /**
208
+ * Die naechsten n Termine eines Cron-Musters ab heute (fuer die Vorschau im Editor).
209
+ *
210
+ * @param expr
211
+ * @param n
212
+ */
213
+ function nextCronDates(expr, n) {
214
+ const rec = { type: 'cron', cron: expr };
215
+ const out = [];
216
+ let cursor = todayStr();
217
+ for (let i = 0; i < n; i++) {
218
+ const d = i === 0 ? firstDue(rec, cursor) : nextDue(rec, cursor);
219
+ if (!d) {
220
+ break;
221
+ }
222
+ out.push(d);
223
+ cursor = d;
224
+ }
225
+ return out;
226
+ }
227
+
228
+ const PERSIST_DEBOUNCE_MS = 500;
229
+
230
+ function slugify(text) {
231
+ const s = String(text || '')
232
+ .toLowerCase()
233
+ .replace(/ä/g, 'ae')
234
+ .replace(/ö/g, 'oe')
235
+ .replace(/ü/g, 'ue')
236
+ .replace(/ß/g, 'ss')
237
+ .replace(/[^a-z0-9]+/g, '-')
238
+ .replace(/^-+|-+$/g, '');
239
+ return s || 'board';
240
+ }
241
+
242
+ function genId(prefix) {
243
+ return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
244
+ }
245
+
246
+ function todayStr() {
247
+ const d = new Date();
248
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
249
+ }
250
+
251
+ // Nur valides ISO-Datum (YYYY-MM-DD) bzw. Uhrzeit (HH:MM) übernehmen, sonst ''.
252
+ // Schützt Overdue-Vergleich (String-Vergleich) und die ICS-Erzeugung vor krummen Eingaben aus der API.
253
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
254
+ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
255
+ function cleanDate(s) {
256
+ s = String(s == null ? '' : s);
257
+ return DATE_RE.test(s) ? s : '';
258
+ }
259
+ function cleanTime(s) {
260
+ s = String(s == null ? '' : s);
261
+ return TIME_RE.test(s) ? s : '';
262
+ }
263
+
264
+ /**
265
+ * Feldwerte aus API/Webhook prüfen, statt sie stillschweigend zu verbiegen.
266
+ * Leere Werte bleiben erlaubt (= Feld nicht gesetzt), Unsinn fliegt mit einer
267
+ * Fehlermeldung zurück — sonst merkt das aufrufende System nichts davon.
268
+ *
269
+ * @param v
270
+ * @param field
271
+ */
272
+ function assertDate(v, field) {
273
+ if (v === undefined || v === null || v === '') {
274
+ return '';
275
+ }
276
+ const s = String(v);
277
+ if (!DATE_RE.test(s)) {
278
+ throw new Error(`${field} muss im Format YYYY-MM-DD vorliegen, nicht '${s}'`);
279
+ }
280
+ return s;
281
+ }
282
+ function assertTime(v, field) {
283
+ if (v === undefined || v === null || v === '') {
284
+ return '';
285
+ }
286
+ const s = String(v);
287
+ if (!TIME_RE.test(s)) {
288
+ throw new Error(`${field} muss im Format HH:MM vorliegen, nicht '${s}'`);
289
+ }
290
+ return s;
291
+ }
292
+ /**
293
+ * Wiederholung prüfen und normalisieren. Bei 'cron' muss der Ausdruck gültig sein;
294
+ * seine Uhrzeit gewinnt gegen ein manuell gesetztes dueTime, damit Karte und Muster
295
+ * nicht auseinanderlaufen. Gibt die erzwungene Uhrzeit zurück ('' = keine).
296
+ *
297
+ * @param rec
298
+ */
299
+ function assertRecurrence(rec) {
300
+ if (!rec || !rec.type || rec.type === 'none') {
301
+ return '';
302
+ }
303
+ if (rec.type !== 'cron') {
304
+ return '';
305
+ }
306
+ const check = validateCron(rec.cron);
307
+ if (!check.ok) {
308
+ throw new Error(`Cron-Ausdruck ungültig: ${check.error}`);
309
+ }
310
+ return check.time;
311
+ }
312
+ function assertPriority(v) {
313
+ if (v === undefined || v === null || v === '') {
314
+ return 0;
315
+ }
316
+ const n = Number(v);
317
+ if (!Number.isInteger(n) || n < 0 || n > 2) {
318
+ throw new Error(`priority kennt nur 0, 1 oder 2, nicht '${v}'`);
319
+ }
320
+ return n;
321
+ }
322
+
323
+ class Store {
324
+ /**
325
+ * @param adapter ioBroker-Adapter
326
+ * @param bus EventBus aus events.js
327
+ */
328
+ constructor(adapter, bus) {
329
+ this.adapter = adapter;
330
+ this.bus = bus;
331
+ this.boards = new Map(); // boardId -> Board-Objekt
332
+ this._persistTimers = new Map(); // boardId -> Timeout
333
+ this._migrated = new Set(); // Boards, die beim Laden angepasst wurden (Papierkorb-Spalte)
334
+ this._knownUserStates = new Set();
335
+ this.onChange = null; // (boardId, rev) => void — für WS-Broadcast
336
+ }
337
+
338
+ // ------------------------------------------------------------ Laden
339
+
340
+ async load() {
341
+ const states = await this.adapter.getStatesAsync('boards.*.data');
342
+ for (const [fullId, state] of Object.entries(states || {})) {
343
+ if (!state || !state.val) {
344
+ continue;
345
+ }
346
+ try {
347
+ const board = JSON.parse(state.val);
348
+ if (board && board.id) {
349
+ this.boards.set(board.id, this._normalizeBoard(board));
350
+ }
351
+ } catch (e) {
352
+ this.adapter.log.warn(`Board state ${fullId} does not contain valid JSON: ${e.message}`);
353
+ }
354
+ }
355
+ this.adapter.log.info(`${this.boards.size} board(s) loaded`);
356
+ // Beim Laden angepasste Boards (Papierkorb-Spalte ergänzt/umbenannt) einmal
357
+ // schreiben, damit die Änderung nicht nur im Arbeitsspeicher steht.
358
+ if (this._migrated.size) {
359
+ this.adapter.log.info(`Trash column added to ${this._migrated.size} board(s)`);
360
+ for (const id of this._migrated) {
361
+ this._schedulePersist(id);
362
+ }
363
+ this._migrated.clear();
364
+ }
365
+ await this.updateMirrors();
366
+ }
367
+
368
+ _normalizeBoard(board) {
369
+ board.columns = Array.isArray(board.columns) ? board.columns : [];
370
+ board.cards = Array.isArray(board.cards) ? board.cards : [];
371
+ board.labels = Array.isArray(board.labels) ? board.labels : [];
372
+ board.members = Array.isArray(board.members) ? board.members : [];
373
+ board.linkTarget = board.linkTarget || 'board';
374
+ board.linkUrl = board.linkUrl || '';
375
+ board.cleanup = normalizeCleanup(board.cleanup);
376
+ board.rev = Number(board.rev) || 0;
377
+ for (const c of board.cards) {
378
+ c.assignees = Array.isArray(c.assignees) ? c.assignees : [];
379
+ c.labels = Array.isArray(c.labels) ? c.labels : [];
380
+ c.checklist = Array.isArray(c.checklist) ? c.checklist : [];
381
+ if (typeof c.trashedAt === 'undefined') {
382
+ c.trashedAt = null;
383
+ }
384
+ }
385
+ this._ensureTrashColumn(board); // Migration: Papierkorb-Systemspalte sicherstellen
386
+ return board;
387
+ }
388
+
389
+ // ------------------------------------------------------------ Papierkorb-Systemspalte
390
+
391
+ _trashColumn(board) {
392
+ return board.columns.find(c => c && c.isTrash) || null;
393
+ }
394
+
395
+ /**
396
+ * Ist das die ID der Papierkorb-Systemspalte? Bewusst ueber die Identitaet der
397
+ * Systemspalte statt ueber die erste Spalte mit dieser ID: Gibt es (aus alten
398
+ * Daten) eine zweite Spalte gleichen Namens, antwortete die Suche sonst anders
399
+ * als _trashColumn, und geloeschte Karten galten weiter als aktiv.
400
+ *
401
+ * @param board
402
+ * @param columnId
403
+ */
404
+ _isTrashColumn(board, columnId) {
405
+ const tc = this._trashColumn(board);
406
+ return !!(tc && columnId === tc.id);
407
+ }
408
+
409
+ /**
410
+ * Stellt sicher, dass es genau eine Papierkorb-Spalte gibt und sie ganz rechts steht.
411
+ *
412
+ * @param board
413
+ */
414
+ _ensureTrashColumn(board) {
415
+ const title = serverT(this.adapter._language || 'en', 'col.trash');
416
+ let tc = board.columns.find(c => c && c.isTrash);
417
+ let changed = false;
418
+ if (!tc) {
419
+ let id = 'trash';
420
+ if (board.columns.some(c => c.id === id)) {
421
+ id = genId('col');
422
+ }
423
+ tc = { id, title, wipLimit: 0, isDone: false, isTrash: true };
424
+ board.columns.push(tc);
425
+ changed = true;
426
+ } else if (board.columns[board.columns.length - 1] !== tc) {
427
+ // Papierkorb immer als letzte Spalte
428
+ board.columns = board.columns.filter(c => c !== tc).concat(tc);
429
+ changed = true;
430
+ }
431
+ // Systemspalte ist nicht umbenennbar: Titel folgt immer der Adaptersprache
432
+ if (tc.title !== title) {
433
+ tc.title = title;
434
+ changed = true;
435
+ }
436
+ // Altbestand heilen: Eine zweite Spalte mit der ID des Papierkorbs konnte
437
+ // frueher ueber die API entstehen und legte ihn lahm (Wiederherstellen ohne
438
+ // Wirkung, Karten weiter in Zaehlern und Erinnerungen). Neue Spalten koennen
439
+ // die ID nicht mehr belegen, vorhandene bekommen hier eine eigene.
440
+ for (const col of board.columns) {
441
+ if (col !== tc && col && col.id === tc.id) {
442
+ col.id = genId('col');
443
+ changed = true;
444
+ this.adapter.log.warn(
445
+ `Board '${board.id}': a second column used the trash column id '${tc.id}' ` +
446
+ `and was renamed to '${col.id}'. Check that its cards are in the right column.`,
447
+ );
448
+ }
449
+ }
450
+ if (changed && board.id) {
451
+ this._migrated.add(board.id);
452
+ }
453
+ return tc;
454
+ }
455
+
456
+ // ------------------------------------------------------------ Hilfen
457
+
458
+ listBoards() {
459
+ return [...this.boards.values()].map(b => ({
460
+ id: b.id,
461
+ title: b.title,
462
+ rev: b.rev,
463
+ cardCount: b.cards.length,
464
+ members: Array.isArray(b.members) ? b.members : [],
465
+ }));
466
+ }
467
+
468
+ getBoard(id) {
469
+ return this.boards.get(id) || null;
470
+ }
471
+
472
+ _requireBoard(id) {
473
+ const b = this.boards.get(id);
474
+ if (!b) {
475
+ throw new Error(`Board '${id}' existiert nicht`);
476
+ }
477
+ return b;
478
+ }
479
+
480
+ _boardRef(b) {
481
+ return { id: b.id, title: b.title, linkTarget: b.linkTarget || 'board', linkUrl: b.linkUrl || '' };
482
+ }
483
+
484
+ _isDoneColumn(board, columnId) {
485
+ const col = board.columns.find(c => c.id === columnId);
486
+ return !!(col && col.isDone);
487
+ }
488
+
489
+ /**
490
+ * Mutation ausführen: rev++, persistieren, Spiegel-States, onChange-Callback.
491
+ *
492
+ * @param board
493
+ * @param fn
494
+ */
495
+ _mutate(board, fn) {
496
+ const result = fn(board);
497
+ board.rev++;
498
+ this._schedulePersist(board.id);
499
+ this.updateMirrors().catch(e => this.adapter.log.error(`Mirror states: ${e.message}`));
500
+ if (this.onChange) {
501
+ this.onChange(board.id, board.rev);
502
+ }
503
+ return result;
504
+ }
505
+
506
+ // ------------------------------------------------------------ Board-CRUD
507
+
508
+ async createBoard({ id, title }) {
509
+ if (!title) {
510
+ throw new Error('title fehlt');
511
+ }
512
+ let boardId = slugify(id || title);
513
+ let n = 2;
514
+ while (this.boards.has(boardId)) {
515
+ boardId = `${slugify(id || title)}-${n++}`;
516
+ }
517
+ const board = {
518
+ id: boardId,
519
+ title: String(title),
520
+ rev: 1,
521
+ columns: [
522
+ { id: 'todo', title: serverT(this.adapter._language, 'col.todo'), wipLimit: 0, isDone: false },
523
+ { id: 'doing', title: serverT(this.adapter._language, 'col.doing'), wipLimit: 0, isDone: false },
524
+ { id: 'done', title: serverT(this.adapter._language, 'col.done'), wipLimit: 0, isDone: true },
525
+ {
526
+ id: 'trash',
527
+ title: serverT(this.adapter._language, 'col.trash'),
528
+ wipLimit: 0,
529
+ isDone: false,
530
+ isTrash: true,
531
+ },
532
+ ],
533
+ cards: [],
534
+ labels: [],
535
+ members: [],
536
+ linkTarget: 'board',
537
+ linkUrl: '',
538
+ cleanup: defaultCleanup(),
539
+ };
540
+ this.boards.set(boardId, board);
541
+ await this._ensureBoardObjects(boardId, board.title);
542
+ this._schedulePersist(boardId);
543
+ if (this.onChange) {
544
+ this.onChange(boardId, board.rev);
545
+ }
546
+ return board;
547
+ }
548
+
549
+ /**
550
+ * Titel/Spalten/Labels ändern (partiell).
551
+ *
552
+ * @param boardId
553
+ * @param patch
554
+ */
555
+ updateBoard(boardId, patch) {
556
+ const board = this._requireBoard(boardId);
557
+ return this._mutate(board, b => {
558
+ if (patch.title !== undefined) {
559
+ b.title = String(patch.title);
560
+ }
561
+ if (patch.linkTarget !== undefined) {
562
+ b.linkTarget = ['edit', 'board', 'url'].includes(patch.linkTarget) ? patch.linkTarget : 'board';
563
+ }
564
+ if (patch.linkUrl !== undefined) {
565
+ b.linkUrl = String(patch.linkUrl || '');
566
+ }
567
+ if (Array.isArray(patch.labels)) {
568
+ b.labels = patch.labels;
569
+ }
570
+ if (Array.isArray(patch.members)) {
571
+ b.members = patch.members.filter(x => typeof x === 'string');
572
+ }
573
+ if (patch.cleanup !== undefined) {
574
+ b.cleanup = normalizeCleanup(patch.cleanup);
575
+ }
576
+ if (Array.isArray(patch.columns)) {
577
+ // Spalten ersetzen; die Papierkorb-Systemspalte bleibt erhalten und ganz rechts.
578
+ const trashCol = this._trashColumn(b);
579
+ // IDs eindeutig halten und die der Papierkorb-Systemspalte sperren.
580
+ // Sonst legt eine mitgeschickte Spalte {"id":"trash"} den Papierkorb
581
+ // lahm: Geloeschte Karten gelten dann weiter als aktiv, waehrend der
582
+ // Aufraeumlauf sie nach der Frist endgueltig entfernt.
583
+ const used = new Set([trashCol ? trashCol.id : 'trash']);
584
+ const normal = patch.columns
585
+ .filter(c => !c.isTrash)
586
+ .map(c => {
587
+ let id = String(c.id || '').trim();
588
+ if (!id || used.has(id)) {
589
+ id = genId('col');
590
+ }
591
+ used.add(id);
592
+ return {
593
+ id,
594
+ title: String(c.title || '?'),
595
+ maxVisible: Number(c.maxVisible) || 0,
596
+ wipLimit: Number(c.wipLimit) || 0,
597
+ isDone: !!c.isDone,
598
+ allowAdd: !!c.allowAdd,
599
+ };
600
+ });
601
+ b.columns = trashCol ? normal.concat(trashCol) : normal;
602
+ this._ensureTrashColumn(b);
603
+ // Karten aus gelöschten Spalten in die erste normale Spalte schieben (Papierkorb-Karten bleiben)
604
+ const keepIds = new Set(b.columns.map(c => c.id));
605
+ const firstNormal = b.columns.find(c => !c.isTrash);
606
+ if (firstNormal) {
607
+ for (const card of b.cards) {
608
+ if (!keepIds.has(card.columnId)) {
609
+ card.columnId = firstNormal.id;
610
+ }
611
+ }
612
+ }
613
+ }
614
+ return b;
615
+ });
616
+ }
617
+
618
+ /**
619
+ * Unumkehrbare Aktionen protokollieren — hier im Store, damit es fuer jeden Weg
620
+ * gilt: Oberflaeche, REST, Webhook, sendTo und action-State.
621
+ *
622
+ * @param cmd
623
+ * @param boardId
624
+ * @param cardId
625
+ * @param source
626
+ */
627
+ _logIrreversible(cmd, boardId, cardId, source) {
628
+ this.adapter.log.info(
629
+ `${cmd}${boardId ? ` on board '${boardId}'` : ''}` +
630
+ `${cardId ? `, card '${cardId}'` : ''} - source: ${source || 'web ui'}`,
631
+ );
632
+ }
633
+
634
+ async deleteBoard(boardId, source) {
635
+ this._requireBoard(boardId);
636
+ this._logIrreversible('deleteBoard', boardId, '', source);
637
+ this.boards.delete(boardId);
638
+ const t = this._persistTimers.get(boardId);
639
+ if (t) {
640
+ this.adapter.clearTimeout(t);
641
+ this._persistTimers.delete(boardId);
642
+ }
643
+ await this.adapter.delObjectAsync(`boards.${boardId}`, { recursive: true });
644
+ await this.updateMirrors();
645
+ if (this.onChange) {
646
+ this.onChange(boardId, -1);
647
+ }
648
+ }
649
+
650
+ // ------------------------------------------------------------ Karten-CRUD
651
+
652
+ addCard(boardId, data, source) {
653
+ const board = this._requireBoard(boardId);
654
+ if (!data || !data.title) {
655
+ throw new Error('title fehlt');
656
+ }
657
+ const firstNormal = board.columns.find(c => !c.isTrash);
658
+ // Eine angegebene, aber unbekannte Spalte ist ein Fehler des Aufrufers — die
659
+ // Karte still in der ersten Spalte abzulegen verschleiert das nur.
660
+ if (data.columnId && !board.columns.some(c => c.id === data.columnId && !c.isTrash)) {
661
+ throw new Error(`Spalte '${data.columnId}' existiert nicht in Board '${boardId}'`);
662
+ }
663
+ const columnId = data.columnId || (firstNormal && firstNormal.id);
664
+ if (!columnId) {
665
+ throw new Error('Board hat keine Spalten');
666
+ }
667
+ const due = assertDate(data.due, 'due');
668
+ const priority = assertPriority(data.priority);
669
+ const recurrence =
670
+ data.recurrence && data.recurrence.type && data.recurrence.type !== 'none' ? data.recurrence : null;
671
+ const cronTime = assertRecurrence(recurrence);
672
+ const card = {
673
+ id: genId('c'),
674
+ columnId,
675
+ order: board.cards.filter(c => c.columnId === columnId).length,
676
+ title: String(data.title),
677
+ description: String(data.description || ''),
678
+ assignees: Array.isArray(data.assignees) ? data.assignees : [],
679
+ due,
680
+ dueTime: cronTime || (due ? assertTime(data.dueTime, 'dueTime') : ''),
681
+ labels: Array.isArray(data.labels) ? data.labels : [],
682
+ color: data.color || '',
683
+ priority,
684
+ checklist: Array.isArray(data.checklist) ? data.checklist : [],
685
+ link: data.link || '',
686
+ location: data.location || '',
687
+ calendarInvite: !!data.calendarInvite,
688
+ calendarDuration: cleanTime(data.calendarDuration) || '01:00',
689
+ recurrence,
690
+ createdAt: new Date().toISOString(),
691
+ createdBy: data.createdBy || source || '',
692
+ movedAt: null,
693
+ doneAt: null,
694
+ trashedAt: null,
695
+ lastReminderAt: null,
696
+ lastExactAt: null,
697
+ // Kalender-Serie: UID bleibt über die Wiederholungskette gleich,
698
+ // Fingerabdruck/Sequenz steuern, wann eine neue Einladung nötig ist
699
+ icsUid: null,
700
+ icsFingerprint: '',
701
+ icsSeq: 0,
702
+ };
703
+ // Wiederkehrende Karte ohne manuelles Datum: nächstes passendes Datum setzen
704
+ if (card.recurrence && !card.due) {
705
+ card.due = firstDue(card.recurrence, todayStr());
706
+ }
707
+ this._mutate(board, b => b.cards.push(card));
708
+ this.bus.emitEvent('cardCreated', { board: this._boardRef(board), card, detail: { by: card.createdBy } });
709
+ for (const a of card.assignees) {
710
+ this.bus.emitEvent('cardAssigned', {
711
+ board: this._boardRef(board),
712
+ card,
713
+ detail: { assignee: a, by: card.createdBy },
714
+ });
715
+ }
716
+ return card;
717
+ }
718
+
719
+ updateCard(boardId, cardId, patch, source) {
720
+ const board = this._requireBoard(boardId);
721
+ const card = board.cards.find(c => c.id === cardId);
722
+ if (!card) {
723
+ throw new Error(`Karte '${cardId}' existiert nicht`);
724
+ }
725
+ const oldAssignees = new Set(card.assignees);
726
+ const oldDueAt = `${card.due || ''} ${card.dueTime || ''}`;
727
+ // Vor der Mutation prüfen, damit ungültige Werte gar nicht erst ankommen
728
+ if (patch.due !== undefined) {
729
+ assertDate(patch.due, 'due');
730
+ }
731
+ if (patch.dueTime !== undefined) {
732
+ assertTime(patch.dueTime, 'dueTime');
733
+ }
734
+ if (patch.priority !== undefined) {
735
+ assertPriority(patch.priority);
736
+ }
737
+ if (patch.recurrence !== undefined) {
738
+ assertRecurrence(patch.recurrence);
739
+ }
740
+ this._mutate(board, () => {
741
+ for (const f of CARD_FIELDS) {
742
+ if (patch[f] !== undefined) {
743
+ card[f] = patch[f];
744
+ }
745
+ }
746
+ card.assignees = Array.isArray(card.assignees) ? card.assignees : [];
747
+ // Datum/Uhrzeit nach dem generischen Patch validieren (API kann Rohwerte setzen)
748
+ card.due = cleanDate(card.due);
749
+ card.dueTime = card.due && cleanTime(card.dueTime) ? cleanTime(card.dueTime) : '';
750
+ // Termindauer für die Kalender-Einladung (HH:MM, Standard 1 Stunde)
751
+ card.calendarDuration = cleanTime(card.calendarDuration) || '01:00';
752
+ // recurrence normalisieren + fehlendes Datum für Wiederholung nachziehen
753
+ if (card.recurrence && (!card.recurrence.type || card.recurrence.type === 'none')) {
754
+ card.recurrence = null;
755
+ }
756
+ if (card.recurrence && !card.due) {
757
+ card.due = firstDue(card.recurrence, todayStr());
758
+ }
759
+ // Cron gibt die Uhrzeit vor — sonst zeigt die Karte etwas anderes als ihr Muster
760
+ const ct = assertRecurrence(card.recurrence);
761
+ if (ct) {
762
+ card.dueTime = ct;
763
+ }
764
+ // Fälligkeit/Uhrzeit geändert → kartengenaues Fällig-Ereignis wieder freigeben
765
+ if (`${card.due || ''} ${card.dueTime || ''}` !== oldDueAt) {
766
+ card.lastExactAt = null;
767
+ }
768
+ });
769
+ const by = patch.by || source || '';
770
+ this.bus.emitEvent('cardUpdated', { board: this._boardRef(board), card, detail: { by } });
771
+ for (const a of card.assignees) {
772
+ if (!oldAssignees.has(a)) {
773
+ this.bus.emitEvent('cardAssigned', { board: this._boardRef(board), card, detail: { assignee: a, by } });
774
+ }
775
+ }
776
+ return card;
777
+ }
778
+
779
+ moveCard(boardId, cardId, columnId, order, source) {
780
+ const board = this._requireBoard(boardId);
781
+ const card = board.cards.find(c => c.id === cardId);
782
+ if (!card) {
783
+ throw new Error(`Karte '${cardId}' existiert nicht`);
784
+ }
785
+ if (!board.columns.some(c => c.id === columnId)) {
786
+ throw new Error(`Spalte '${columnId}' existiert nicht`);
787
+ }
788
+ const fromColumn = card.columnId;
789
+ const wasDone = this._isDoneColumn(board, fromColumn);
790
+ const isDone = this._isDoneColumn(board, columnId);
791
+ const fromTrash = this._isTrashColumn(board, fromColumn);
792
+ const toTrash = this._isTrashColumn(board, columnId);
793
+
794
+ this._mutate(board, b => {
795
+ card.columnId = columnId;
796
+ // movedAt = seit wann die Karte in dieser Spalte liegt. Nur bei echtem
797
+ // Spaltenwechsel neu setzen, sonst wuerde Umsortieren innerhalb der
798
+ // Spalte die "Dauer in Spalte" zuruecksetzen.
799
+ if (fromColumn !== columnId) {
800
+ card.movedAt = new Date().toISOString();
801
+ }
802
+ // Zielspalte neu durchnummerieren, Karte an gewünschter Position einsortieren
803
+ const targetCards = b.cards
804
+ .filter(c => c.columnId === columnId && c.id !== cardId)
805
+ .sort((x, y) => x.order - y.order);
806
+ const idx =
807
+ order === undefined || order === null
808
+ ? targetCards.length
809
+ : Math.max(0, Math.min(Number(order), targetCards.length));
810
+ targetCards.splice(idx, 0, card);
811
+ targetCards.forEach((c, i) => {
812
+ c.order = i;
813
+ });
814
+ // Quellspalte kompaktieren
815
+ b.cards
816
+ .filter(c => c.columnId === fromColumn && fromColumn !== columnId)
817
+ .sort((x, y) => x.order - y.order)
818
+ .forEach((c, i) => {
819
+ c.order = i;
820
+ });
821
+ // Papierkorb-Übergänge: rein = löschen (trashedAt setzen), raus = wiederherstellen
822
+ if (toTrash && !fromTrash) {
823
+ card.trashedAt = new Date().toISOString();
824
+ } else if (!toTrash) {
825
+ card.trashedAt = null;
826
+ }
827
+ card.doneAt = isDone ? card.doneAt || new Date().toISOString() : null;
828
+ });
829
+
830
+ const by = source || '';
831
+ if (fromColumn !== columnId) {
832
+ if (toTrash && !fromTrash) {
833
+ // In den Papierkorb gezogen = löschen (wiederherstellbar)
834
+ this.bus.emitEvent('cardDeleted', { board: this._boardRef(board), card, detail: { by, via: 'move' } });
835
+ } else if (fromTrash && !toTrash) {
836
+ // Aus dem Papierkorb gezogen = wiederhergestellt
837
+ this.bus.emitEvent('cardRestored', {
838
+ board: this._boardRef(board),
839
+ card,
840
+ detail: { toColumn: columnId, by },
841
+ });
842
+ } else if (!fromTrash && !toTrash) {
843
+ this.bus.emitEvent('cardMoved', {
844
+ board: this._boardRef(board),
845
+ card,
846
+ detail: { fromColumn, toColumn: columnId, by },
847
+ });
848
+ if (isDone && !wasDone) {
849
+ this.bus.emitEvent('cardDone', { board: this._boardRef(board), card, detail: { fromColumn, by } });
850
+ this._spawnRecurrence(board, card, by);
851
+ }
852
+ }
853
+ }
854
+ return card;
855
+ }
856
+
857
+ /**
858
+ * Beim Erledigen einer wiederkehrenden Karte: frische Instanz in der ersten
859
+ * offenen Spalte mit dem nächsten Fälligkeitsdatum erzeugen. Die Wiederholung
860
+ * wandert auf die neue Karte (die erledigte wird zur einmaligen Karte).
861
+ *
862
+ * @param board
863
+ * @param doneCard
864
+ * @param by
865
+ */
866
+ _spawnRecurrence(board, doneCard, by) {
867
+ const rec = doneCard.recurrence;
868
+ if (!rec || !rec.type || rec.type === 'none') {
869
+ return;
870
+ }
871
+ const nd = nextDue(rec, doneCard.due || todayStr());
872
+ if (!nd) {
873
+ return;
874
+ }
875
+ const firstCol = board.columns.find(c => !c.isDone && !c.isTrash) || board.columns.find(c => !c.isTrash);
876
+ if (!firstCol) {
877
+ return;
878
+ }
879
+ const fresh = {
880
+ id: genId('c'),
881
+ columnId: firstCol.id,
882
+ order: 0,
883
+ title: doneCard.title,
884
+ description: doneCard.description,
885
+ assignees: [...(doneCard.assignees || [])],
886
+ due: nd,
887
+ dueTime: (rec.type === 'cron' ? validateCron(rec.cron).time || '' : '') || doneCard.dueTime || '',
888
+ labels: [...(doneCard.labels || [])],
889
+ color: doneCard.color || '',
890
+ priority: Number(doneCard.priority) || 0,
891
+ checklist: (doneCard.checklist || []).map(i => ({ ...i, done: false })),
892
+ link: doneCard.link || '',
893
+ location: doneCard.location || '',
894
+ calendarInvite: !!doneCard.calendarInvite,
895
+ calendarDuration: doneCard.calendarDuration || '01:00',
896
+ recurrence: rec,
897
+ createdAt: new Date().toISOString(),
898
+ createdBy: by || 'recurrence',
899
+ movedAt: null,
900
+ doneAt: null,
901
+ trashedAt: null,
902
+ lastReminderAt: null,
903
+ lastExactAt: null,
904
+ // gleiche Kalender-Serie wie die erledigte Karte → keine zweite Einladung
905
+ icsUid: doneCard.icsUid || doneCard.id,
906
+ icsFingerprint: doneCard.icsFingerprint || '',
907
+ icsSeq: Number(doneCard.icsSeq) || 0,
908
+ };
909
+ doneCard.recurrence = null; // erledigte Karte nicht erneut spawnen lassen
910
+ this._mutate(board, b => {
911
+ b.cards.forEach(c => {
912
+ if (c.columnId === firstCol.id) {
913
+ c.order += 1;
914
+ }
915
+ });
916
+ b.cards.push(fresh);
917
+ });
918
+ this.bus.emitEvent('cardCreated', {
919
+ board: this._boardRef(board),
920
+ card: fresh,
921
+ detail: { by: fresh.createdBy, recurrence: true },
922
+ });
923
+ for (const a of fresh.assignees) {
924
+ this.bus.emitEvent('cardAssigned', {
925
+ board: this._boardRef(board),
926
+ card: fresh,
927
+ detail: { assignee: a, by: fresh.createdBy, recurrence: true },
928
+ });
929
+ }
930
+ this.adapter.log.info(`Recurrence: '${fresh.title}' created, due ${nd}`);
931
+ }
932
+
933
+ /**
934
+ * Merkt sich, dass für diese Karte eine Kalender-Einladung verschickt wurde:
935
+ * Fingerabdruck der Termindaten + Sequenz, damit eine Folgemail nur bei echten
936
+ * Terminänderungen eine aktualisierte Einladung mitschickt.
937
+ *
938
+ * @param boardId
939
+ * @param cardId
940
+ * @param fingerprint
941
+ * @param seq
942
+ */
943
+ markIcsSent(boardId, cardId, fingerprint, seq) {
944
+ const board = this.boards.get(boardId);
945
+ if (!board) {
946
+ return;
947
+ }
948
+ const card = board.cards.find(c => c.id === cardId);
949
+ if (!card) {
950
+ return;
951
+ }
952
+ if (card.icsFingerprint === fingerprint && Number(card.icsSeq) === Number(seq) && card.icsUid) {
953
+ return;
954
+ }
955
+ card.icsUid = card.icsUid || card.id;
956
+ card.icsFingerprint = fingerprint;
957
+ card.icsSeq = Number(seq) || 0;
958
+ this._schedulePersist(board.id);
959
+ }
960
+
961
+ /**
962
+ * Karten einer Spalte neu durchnummerieren (order = 0..n).
963
+ *
964
+ * @param board
965
+ * @param columnId
966
+ */
967
+ _renumberColumn(board, columnId) {
968
+ board.cards
969
+ .filter(c => c.columnId === columnId)
970
+ .sort((a, b) => a.order - b.order)
971
+ .forEach((c, i) => {
972
+ c.order = i;
973
+ });
974
+ }
975
+
976
+ /**
977
+ * Löschen = in den Papierkorb verschieben (wiederherstellbar). Erst der
978
+ * 30-Tage-Purge entfernt die Daten endgültig. opts.auto=true kennzeichnet
979
+ * das automatische Aufräumen (detail.auto + detail.reason).
980
+ *
981
+ * @param boardId
982
+ * @param cardId
983
+ * @param source
984
+ * @param opts
985
+ */
986
+ deleteCard(boardId, cardId, source, opts = {}) {
987
+ const board = this._requireBoard(boardId);
988
+ const card = board.cards.find(c => c.id === cardId);
989
+ if (!card) {
990
+ throw new Error(`Karte '${cardId}' existiert nicht`);
991
+ }
992
+ const trash = this._ensureTrashColumn(board);
993
+ const fromColumn = card.columnId;
994
+ const already = fromColumn === trash.id;
995
+ this._mutate(board, b => {
996
+ card.columnId = trash.id;
997
+ if (!card.trashedAt) {
998
+ card.trashedAt = new Date().toISOString();
999
+ }
1000
+ card.doneAt = null;
1001
+ this._renumberColumn(b, trash.id);
1002
+ if (!already) {
1003
+ this._renumberColumn(b, fromColumn);
1004
+ }
1005
+ });
1006
+ const detail = Object.assign(
1007
+ { by: source || '' },
1008
+ opts.auto ? { auto: true, reason: opts.reason || 'cleanup' } : {},
1009
+ );
1010
+ this.bus.emitEvent('cardDeleted', { board: this._boardRef(board), card, detail });
1011
+ return card;
1012
+ }
1013
+
1014
+ /**
1015
+ * Karte endgültig entfernen (aus dem Papierkorb). Feuert cardPurged.
1016
+ *
1017
+ * @param boardId
1018
+ * @param cardId
1019
+ * @param source
1020
+ * @param opts
1021
+ */
1022
+ purgeCard(boardId, cardId, source, opts = {}) {
1023
+ const board = this._requireBoard(boardId);
1024
+ if (!opts.auto) {
1025
+ this._logIrreversible('purgeCard', boardId, cardId, source);
1026
+ }
1027
+ const idx = board.cards.findIndex(c => c.id === cardId);
1028
+ if (idx === -1) {
1029
+ throw new Error(`Karte '${cardId}' existiert nicht`);
1030
+ }
1031
+ // Nur aus dem Papierkorb: Sonst entfernt ein einzelner Aufruf eine aktive
1032
+ // Karte unwiederbringlich und umgeht die Aufbewahrungsfrist. Der Aufraeumlauf
1033
+ // und "Papierkorb leeren" gehen ohnehin ueber eigene Wege.
1034
+ if (!opts.auto && !this._isTrashColumn(board, board.cards[idx].columnId)) {
1035
+ throw new Error(`Karte '${cardId}' liegt nicht im Papierkorb`);
1036
+ }
1037
+ const [card] = board.cards.splice(idx, 1);
1038
+ const col = card.columnId;
1039
+ this._mutate(board, b => {
1040
+ this._renumberColumn(b, col);
1041
+ });
1042
+ const detail = Object.assign(
1043
+ { by: source || '' },
1044
+ opts.auto ? { auto: true, reason: opts.reason || 'retention' } : {},
1045
+ );
1046
+ this.bus.emitEvent('cardPurged', { board: this._boardRef(board), card, detail });
1047
+ return card;
1048
+ }
1049
+
1050
+ /**
1051
+ * Karte aus dem Papierkorb zurückholen. Ziel: gewünschte Spalte, sonst erste offene.
1052
+ *
1053
+ * @param boardId
1054
+ * @param cardId
1055
+ * @param targetColumnId
1056
+ * @param source
1057
+ */
1058
+ restoreCard(boardId, cardId, targetColumnId, source) {
1059
+ const board = this._requireBoard(boardId);
1060
+ const card = board.cards.find(c => c.id === cardId);
1061
+ if (!card) {
1062
+ throw new Error(`Karte '${cardId}' existiert nicht`);
1063
+ }
1064
+ if (!this._isTrashColumn(board, card.columnId)) {
1065
+ return card;
1066
+ } // nicht im Papierkorb
1067
+ let target = targetColumnId && board.columns.find(c => c.id === targetColumnId && !c.isTrash);
1068
+ if (!target) {
1069
+ target = board.columns.find(c => !c.isTrash && !c.isDone) || board.columns.find(c => !c.isTrash);
1070
+ }
1071
+ if (!target) {
1072
+ throw new Error('Keine Zielspalte für die Wiederherstellung');
1073
+ }
1074
+ const fromColumn = card.columnId;
1075
+ this._mutate(board, b => {
1076
+ card.columnId = target.id;
1077
+ card.trashedAt = null;
1078
+ card.doneAt = target.isDone ? new Date().toISOString() : null;
1079
+ card.movedAt = new Date().toISOString();
1080
+ this._renumberColumn(b, target.id);
1081
+ this._renumberColumn(b, fromColumn);
1082
+ });
1083
+ this.bus.emitEvent('cardRestored', {
1084
+ board: this._boardRef(board),
1085
+ card,
1086
+ detail: { toColumn: target.id, by: source || '' },
1087
+ });
1088
+ return card;
1089
+ }
1090
+
1091
+ /**
1092
+ * Papierkorb leeren: alle Karten der Papierkorb-Spalte endgültig entfernen.
1093
+ *
1094
+ * @param boardId
1095
+ * @param source
1096
+ */
1097
+ emptyTrash(boardId, source) {
1098
+ const board = this._requireBoard(boardId);
1099
+ this._logIrreversible('emptyTrash', boardId, '', source);
1100
+ const trash = this._trashColumn(board);
1101
+ if (!trash) {
1102
+ return { purged: 0 };
1103
+ }
1104
+ const cards = board.cards.filter(c => c.columnId === trash.id);
1105
+ if (!cards.length) {
1106
+ return { purged: 0 };
1107
+ }
1108
+ const ids = new Set(cards.map(c => c.id));
1109
+ this._mutate(board, b => {
1110
+ b.cards = b.cards.filter(c => !ids.has(c.id));
1111
+ });
1112
+ for (const card of cards) {
1113
+ this.bus.emitEvent('cardPurged', {
1114
+ board: this._boardRef(board),
1115
+ card,
1116
+ detail: { by: source || '', auto: true, reason: 'emptyTrash' },
1117
+ });
1118
+ }
1119
+ // detail.auto unterdrückt die Einzel-Mails; stattdessen eine gebündelte
1120
+ // Zusammenfassung wie beim automatischen Aufräumen verschicken.
1121
+ const notifier = this.adapter.notifier;
1122
+ if (notifier && notifier.sendCleanupSummary) {
1123
+ Promise.resolve(
1124
+ notifier.sendCleanupSummary([{ board: this._boardRef(board), moved: [], purged: cards }]),
1125
+ ).catch(e => this.adapter.log.error(`Trash summary: ${e.message}`));
1126
+ }
1127
+ return { purged: cards.length };
1128
+ }
1129
+
1130
+ /**
1131
+ * Automatischer Aufräumlauf (vom Scheduler): alte Erledigt-Karten in den
1132
+ * Papierkorb, abgelaufene Papierkorb-Karten endgültig entfernen. Gibt je Board
1133
+ * { board, moved:[], purged:[] } zurück (für die gebündelte E-Mail-Zusammenfassung).
1134
+ *
1135
+ * @param nowMs
1136
+ */
1137
+ runCleanup(nowMs) {
1138
+ const now = nowMs || Date.now();
1139
+ const results = [];
1140
+ for (const board of this.boards.values()) {
1141
+ const trash = this._ensureTrashColumn(board);
1142
+ // 1) endgültig entfernen: Papierkorb-Karten älter als die Aufbewahrungsfrist.
1143
+ // Ohne lesbaren Zeitstempel wird nichts entfernt: `Date.parse(...) || 0`
1144
+ // machte aus einem unlesbaren Wert die Epoche und damit eine Karte, die
1145
+ // beim naechsten Lauf sofort endgueltig geloescht wird.
1146
+ const purged = board.cards.filter(c => {
1147
+ if (c.columnId !== trash.id) {
1148
+ return false;
1149
+ }
1150
+ const trashedMs = Date.parse(c.trashedAt);
1151
+ if (!Number.isFinite(trashedMs)) {
1152
+ return false;
1153
+ }
1154
+ return now - trashedMs >= TRASH_RETENTION_DAYS * 86400000;
1155
+ });
1156
+ // 2) aus Erledigt in den Papierkorb (nach Alter oder Anzahl je Erledigt-Spalte)
1157
+ const cl = normalizeCleanup(board.cleanup);
1158
+ let toMove = [];
1159
+ if (cl.mode === 'age' || cl.mode === 'count') {
1160
+ const doneColIds = board.columns.filter(c => c.isDone).map(c => c.id);
1161
+ if (cl.mode === 'age') {
1162
+ const cutoff = now - cl.days * 86400000;
1163
+ toMove = board.cards.filter(c => {
1164
+ if (!doneColIds.includes(c.columnId)) {
1165
+ return false;
1166
+ }
1167
+ const doneMs = Date.parse(c.doneAt); // unlesbar = liegen lassen
1168
+ return Number.isFinite(doneMs) && doneMs < cutoff;
1169
+ });
1170
+ } else {
1171
+ for (const colId of doneColIds) {
1172
+ const inCol = board.cards
1173
+ .filter(c => c.columnId === colId && c.doneAt)
1174
+ .sort((a, b) => (Date.parse(b.doneAt) || 0) - (Date.parse(a.doneAt) || 0));
1175
+ toMove.push(...inCol.slice(cl.count));
1176
+ }
1177
+ }
1178
+ }
1179
+ if (!purged.length && !toMove.length) {
1180
+ continue;
1181
+ }
1182
+ const purgeIds = new Set(purged.map(c => c.id));
1183
+ const nowIso = new Date(now).toISOString();
1184
+ this._mutate(board, b => {
1185
+ b.cards = b.cards.filter(c => !purgeIds.has(c.id));
1186
+ for (const card of toMove) {
1187
+ card.columnId = trash.id;
1188
+ card.trashedAt = nowIso;
1189
+ card.doneAt = null;
1190
+ }
1191
+ for (const col of b.columns) {
1192
+ this._renumberColumn(b, col.id);
1193
+ }
1194
+ });
1195
+ for (const card of toMove) {
1196
+ this.bus.emitEvent('cardDeleted', {
1197
+ board: this._boardRef(board),
1198
+ card,
1199
+ detail: { by: 'cleanup', auto: true, reason: 'cleanup' },
1200
+ });
1201
+ }
1202
+ for (const card of purged) {
1203
+ this.bus.emitEvent('cardPurged', {
1204
+ board: this._boardRef(board),
1205
+ card,
1206
+ detail: { by: 'retention', auto: true, reason: 'retention' },
1207
+ });
1208
+ }
1209
+ results.push({ board, moved: toMove, purged });
1210
+ }
1211
+ return results;
1212
+ }
1213
+
1214
+ /**
1215
+ * Karte auf ein anderes Board kopieren oder verschieben.
1216
+ * Labels werden über den Namen abgeglichen (ohne Entsprechung verworfen),
1217
+ * Zuständige auf Mitglieder des Ziel-Boards beschränkt. Bleibt keiner übrig,
1218
+ * müssen overrides.assignees (Ziel-Mitglieder) mindestens einen enthalten.
1219
+ *
1220
+ * @param fromBoardId
1221
+ * @param cardId
1222
+ * @param toBoardId
1223
+ * @param toColumnId
1224
+ * @param mode
1225
+ * @param overrides
1226
+ * @param source
1227
+ */
1228
+ transferCard(fromBoardId, cardId, toBoardId, toColumnId, mode, overrides, source) {
1229
+ const from = this._requireBoard(fromBoardId);
1230
+ const to = this._requireBoard(toBoardId);
1231
+ // Gleiches Board ist nur als Klon (Kopie) erlaubt – Verschieben innerhalb
1232
+ // eines Boards passiert per Drag & Drop bzw. über die Spaltenauswahl.
1233
+ const sameBoard = fromBoardId === toBoardId;
1234
+ if (sameBoard && mode !== 'copy') {
1235
+ throw new Error('Quell- und Ziel-Board sind identisch');
1236
+ }
1237
+ const card = from.cards.find(c => c.id === cardId);
1238
+ if (!card) {
1239
+ throw new Error(`Karte '${cardId}' existiert nicht`);
1240
+ }
1241
+ let target = toColumnId && to.columns.find(c => c.id === toColumnId && !c.isTrash);
1242
+ if (!target) {
1243
+ target = to.columns.find(c => c.allowAdd && !c.isTrash) || to.columns.find(c => !c.isTrash);
1244
+ }
1245
+ if (!target) {
1246
+ throw new Error('Ziel-Board hat keine Spalte');
1247
+ }
1248
+ // Labels über den Namen abgleichen
1249
+ const labelName = l =>
1250
+ String((l && (l.title || l.name || l.text)) || '')
1251
+ .trim()
1252
+ .toLowerCase();
1253
+ const fromById = new Map((from.labels || []).map(l => [l.id, l]));
1254
+ const toByName = new Map((to.labels || []).map(l => [labelName(l), l.id]));
1255
+ const mappedLabels = [];
1256
+ for (const lid of card.labels || []) {
1257
+ const nm = labelName(fromById.get(lid));
1258
+ if (nm && toByName.has(nm)) {
1259
+ mappedLabels.push(toByName.get(nm));
1260
+ }
1261
+ }
1262
+ // Zuständige: nur Mitglieder des Ziel-Boards. Zeigt dessen Mitgliederliste ins
1263
+ // Leere (umbenannte Benutzer-IDs), gelten alle bekannten Benutzer — wie in der
1264
+ // Oberfläche, sonst wäre kein Transfer mehr möglich.
1265
+ const known = (this.adapter.config.users || []).map(u => u && u.name).filter(Boolean);
1266
+ const listed = (Array.isArray(to.members) ? to.members : []).filter(n => known.includes(n));
1267
+ const members = new Set(listed.length ? listed : known);
1268
+ let assignees = (card.assignees || []).filter(a => members.has(a));
1269
+ if (!assignees.length && overrides && Array.isArray(overrides.assignees)) {
1270
+ assignees = overrides.assignees.filter(a => members.has(a));
1271
+ }
1272
+ if (!assignees.length) {
1273
+ throw new Error('Ziel-Board: mindestens eine zuständige Person (Mitglied) nötig');
1274
+ }
1275
+ const isDone = !!target.isDone;
1276
+ const nowIso = new Date().toISOString();
1277
+ const by = source || '';
1278
+
1279
+ if (mode === 'copy') {
1280
+ // Klon in derselben Spalte landet direkt unter dem Original,
1281
+ // jede andere Kopie am Ende der Ziel-Spalte.
1282
+ const insertAfter = sameBoard && target.id === card.columnId;
1283
+ const copy = {
1284
+ id: genId('c'),
1285
+ columnId: target.id,
1286
+ order: insertAfter
1287
+ ? (Number(card.order) || 0) + 1
1288
+ : to.cards.filter(c => c.columnId === target.id).length,
1289
+ title: card.title,
1290
+ description: card.description,
1291
+ assignees: [...assignees],
1292
+ due: card.due,
1293
+ dueTime: card.dueTime,
1294
+ labels: mappedLabels,
1295
+ color: card.color || '',
1296
+ priority: Number(card.priority) || 0,
1297
+ checklist: (card.checklist || []).map(i => ({ ...i })),
1298
+ link: card.link || '',
1299
+ location: card.location || '',
1300
+ calendarInvite: !!card.calendarInvite,
1301
+ calendarDuration: card.calendarDuration || '01:00',
1302
+ recurrence: card.recurrence || null,
1303
+ createdAt: nowIso,
1304
+ createdBy: by,
1305
+ movedAt: null,
1306
+ doneAt: isDone ? nowIso : null,
1307
+ trashedAt: null,
1308
+ lastReminderAt: null,
1309
+ lastExactAt: null,
1310
+ icsUid: null,
1311
+ icsFingerprint: '',
1312
+ icsSeq: 0,
1313
+ };
1314
+ this._mutate(to, b => {
1315
+ if (insertAfter) {
1316
+ for (const c of b.cards) {
1317
+ if (c.columnId === target.id && c.id !== card.id && (Number(c.order) || 0) >= copy.order) {
1318
+ c.order = (Number(c.order) || 0) + 1;
1319
+ }
1320
+ }
1321
+ }
1322
+ b.cards.push(copy);
1323
+ if (insertAfter) {
1324
+ this._renumberColumn(b, target.id);
1325
+ }
1326
+ });
1327
+ this.bus.emitEvent('cardCreated', {
1328
+ board: this._boardRef(to),
1329
+ card: copy,
1330
+ detail: { by, crossBoardCopy: !sameBoard, clone: sameBoard, fromBoard: from.id },
1331
+ });
1332
+ for (const a of copy.assignees) {
1333
+ this.bus.emitEvent('cardAssigned', {
1334
+ board: this._boardRef(to),
1335
+ card: copy,
1336
+ detail: { assignee: a, by },
1337
+ });
1338
+ }
1339
+ return copy;
1340
+ }
1341
+
1342
+ // Verschieben: aus Quell-Board entfernen, in Ziel-Board einfügen
1343
+ const fromColumn = card.columnId;
1344
+ this._mutate(from, b => {
1345
+ b.cards = b.cards.filter(c => c.id !== cardId);
1346
+ this._renumberColumn(b, fromColumn);
1347
+ });
1348
+ card.columnId = target.id;
1349
+ card.labels = mappedLabels;
1350
+ card.assignees = [...assignees];
1351
+ card.trashedAt = null;
1352
+ card.doneAt = isDone ? card.doneAt || nowIso : null;
1353
+ card.movedAt = nowIso;
1354
+ card.order = to.cards.filter(c => c.columnId === target.id).length;
1355
+ this._mutate(to, b => b.cards.push(card));
1356
+ this.bus.emitEvent('cardMoved', {
1357
+ board: this._boardRef(to),
1358
+ card,
1359
+ detail: { fromBoard: from.id, toBoard: to.id, toColumn: target.id, crossBoard: true, by },
1360
+ });
1361
+ return card;
1362
+ }
1363
+
1364
+ // ------------------------------------------------------------ Persistenz
1365
+
1366
+ _schedulePersist(boardId) {
1367
+ const t = this._persistTimers.get(boardId);
1368
+ if (t) {
1369
+ this.adapter.clearTimeout(t);
1370
+ }
1371
+ this._persistTimers.set(
1372
+ boardId,
1373
+ this.adapter.setTimeout(() => {
1374
+ this._persistTimers.delete(boardId);
1375
+ this._persist(boardId).catch(e => this.adapter.log.error(`Persisting board ${boardId}: ${e.message}`));
1376
+ }, PERSIST_DEBOUNCE_MS),
1377
+ );
1378
+ }
1379
+
1380
+ async _persist(boardId) {
1381
+ const board = this.boards.get(boardId);
1382
+ if (!board) {
1383
+ return;
1384
+ }
1385
+ await this._ensureBoardObjects(boardId, board.title);
1386
+ const today = todayStr();
1387
+ const overdue = board.cards.filter(
1388
+ c =>
1389
+ c.due &&
1390
+ c.due < today &&
1391
+ !this._isDoneColumn(board, c.columnId) &&
1392
+ !this._isTrashColumn(board, c.columnId),
1393
+ ).length;
1394
+ await this.adapter.setStateAsync(`boards.${boardId}.data`, JSON.stringify(board), true);
1395
+ await this.adapter.setStateChangedAsync(`boards.${boardId}.rev`, board.rev, true);
1396
+ await this.adapter.setStateChangedAsync(`boards.${boardId}.cardCount`, board.cards.length, true);
1397
+ await this.adapter.setStateChangedAsync(`boards.${boardId}.overdueCount`, overdue, true);
1398
+ }
1399
+
1400
+ /** Alle offenen Persist-Timer sofort ausführen (für onUnload). */
1401
+ async flush() {
1402
+ const ids = [...this._persistTimers.keys()];
1403
+ for (const id of ids) {
1404
+ const t = this._persistTimers.get(id);
1405
+ if (t) {
1406
+ this.adapter.clearTimeout(t);
1407
+ }
1408
+ this._persistTimers.delete(id);
1409
+ await this._persist(id);
1410
+ }
1411
+ }
1412
+
1413
+ async _ensureBoardObjects(boardId, title) {
1414
+ await this.adapter.setObjectNotExistsAsync(`boards.${boardId}`, {
1415
+ type: 'channel',
1416
+ common: { name: title },
1417
+ native: {},
1418
+ });
1419
+ const stateDefs = {
1420
+ data: { name: 'Board JSON', type: 'string', role: 'json', read: true, write: false },
1421
+ rev: { name: 'Revision', type: 'number', role: 'value', read: true, write: false },
1422
+ cardCount: { name: 'Anzahl Karten', type: 'number', role: 'value', read: true, write: false },
1423
+ overdueCount: { name: 'Überfällige Karten', type: 'number', role: 'value', read: true, write: false },
1424
+ };
1425
+ for (const [suffix, common] of Object.entries(stateDefs)) {
1426
+ await this.adapter.setObjectNotExistsAsync(`boards.${boardId}.${suffix}`, {
1427
+ type: 'state',
1428
+ common,
1429
+ native: {},
1430
+ });
1431
+ }
1432
+ }
1433
+
1434
+ // ------------------------------------------------------------ Spiegel-States je Benutzer
1435
+
1436
+ async updateMirrors() {
1437
+ const users = (this.adapter.config.users || []).map(u => u.name).filter(Boolean);
1438
+ const today = todayStr();
1439
+ for (const name of users) {
1440
+ let assigned = 0;
1441
+ let overdue = 0;
1442
+ const overdueList = [];
1443
+ for (const board of this.boards.values()) {
1444
+ for (const card of board.cards) {
1445
+ if (!card.assignees.includes(name)) {
1446
+ continue;
1447
+ }
1448
+ if (this._isDoneColumn(board, card.columnId)) {
1449
+ continue;
1450
+ }
1451
+ if (this._isTrashColumn(board, card.columnId)) {
1452
+ continue;
1453
+ }
1454
+ assigned++;
1455
+ if (card.due && card.due < today) {
1456
+ overdue++;
1457
+ const col = board.columns.find(c => c.id === card.columnId);
1458
+ overdueList.push(`${card.title} (${board.title}/${col ? col.title : '?'})`);
1459
+ }
1460
+ }
1461
+ }
1462
+ await this._ensureUserObjects(name);
1463
+ await this.adapter.setStateChangedAsync(`users.${name}.assignedCount`, assigned, true);
1464
+ await this.adapter.setStateChangedAsync(`users.${name}.overdueCount`, overdue, true);
1465
+ await this.adapter.setStateChangedAsync(`users.${name}.overdueList`, JSON.stringify(overdueList), true);
1466
+ }
1467
+ }
1468
+
1469
+ async _ensureUserObjects(name) {
1470
+ if (this._knownUserStates.has(name)) {
1471
+ return;
1472
+ }
1473
+ this._knownUserStates.add(name);
1474
+ await this.adapter.setObjectNotExistsAsync(`users.${name}`, {
1475
+ type: 'channel',
1476
+ common: { name },
1477
+ native: {},
1478
+ });
1479
+ const defs = {
1480
+ assignedCount: {
1481
+ name: 'Zugewiesene offene Karten',
1482
+ type: 'number',
1483
+ role: 'value',
1484
+ read: true,
1485
+ write: false,
1486
+ },
1487
+ overdueCount: { name: 'Überfällige Karten', type: 'number', role: 'value', read: true, write: false },
1488
+ overdueList: { name: 'Überfällige Karten (Liste)', type: 'string', role: 'json', read: true, write: false },
1489
+ };
1490
+ for (const [suffix, common] of Object.entries(defs)) {
1491
+ await this.adapter.setObjectNotExistsAsync(`users.${name}.${suffix}`, {
1492
+ type: 'state',
1493
+ common,
1494
+ native: {},
1495
+ });
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ module.exports = { Store, todayStr, nextDue, recMatches, nextCronDates, TRASH_RETENTION_DAYS };