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/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 };
|