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/main.js ADDED
@@ -0,0 +1,382 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const os = require('node:os');
5
+ const { EventBus } = require('./lib/events');
6
+ const { Store } = require('./lib/store');
7
+ const holidays = require('./lib/holidays');
8
+ const { Notifier } = require('./lib/notify');
9
+ const { Scheduler } = require('./lib/scheduler');
10
+ const { cardWithDueAt, boardWithDueAt } = require('./lib/dueat');
11
+ const { Server } = require('./lib/server');
12
+
13
+ class Kanban extends utils.Adapter {
14
+ constructor(options) {
15
+ super({ ...options, name: 'kanban', useFormatDate: true });
16
+ this.on('ready', this.onReady.bind(this));
17
+ this.on('stateChange', this.onStateChange.bind(this));
18
+ this.on('message', this.onMessage.bind(this));
19
+ this.on('unload', this.onUnload.bind(this));
20
+ }
21
+
22
+ async onReady() {
23
+ await this.setStateAsync('info.connection', false, true);
24
+
25
+ this.bus = new EventBus();
26
+ this.store = new Store(this, this.bus);
27
+ this.notifier = new Notifier(
28
+ this,
29
+ () => this._baseUrl(),
30
+ () => this._timezone,
31
+ );
32
+ this.notifier.attach(this.bus);
33
+ this.scheduler = new Scheduler(this, this.store, this.bus);
34
+ this.webServer = new Server(this, this.store, (cmd, payload, source) =>
35
+ this.handleCommand(cmd, payload, source),
36
+ );
37
+
38
+ // Meta-Objekt für den Dateispeicher (Benutzer-Avatare)
39
+ await this.setForeignObjectNotExistsAsync(this.namespace, {
40
+ type: 'meta',
41
+ common: { name: 'Kanban files (avatars)', type: 'meta.user' },
42
+ native: {},
43
+ });
44
+
45
+ // Sprache zuerst: die Papierkorb-Systemspalte wird beim Laden/Migrieren
46
+ // angelegt und braucht dafür den lokalisierten Namen.
47
+ await this._resolveLanguage();
48
+ await this.store.load();
49
+ await this._resolveTimezone();
50
+ await this._initHolidays();
51
+ await this._initApiSecret();
52
+
53
+ try {
54
+ this._port = await this.webServer.start();
55
+ await this.setStateAsync('info.connection', true, true);
56
+ } catch (e) {
57
+ this.log.error(`Web server failed to start: ${e.message}`);
58
+ return;
59
+ }
60
+
61
+ this.scheduler.start();
62
+ // Der action-State ist eine vollwertige Kommando-Schnittstelle (inkl. Löschen).
63
+ // Wer ihn nicht braucht, kann ihn in den Instanz-Einstellungen abschalten.
64
+ if (this.config.actionStateEnabled === false) {
65
+ this.log.info('action state is disabled - commands are only accepted via REST, webhooks and sendTo.');
66
+ } else {
67
+ await this.subscribeStatesAsync('action');
68
+ }
69
+ this.log.info(`Kanban ready - UI: ${this._baseUrl()}/`);
70
+ }
71
+
72
+ /**
73
+ * Sprache ermitteln: Instanz-Einstellung `language` (leer/'auto' = System),
74
+ * sonst ioBroker-Systemsprache, sonst Englisch.
75
+ */
76
+ async _resolveLanguage() {
77
+ let lang = String(this.config.language || '').toLowerCase();
78
+ if (!lang || lang === 'auto') {
79
+ try {
80
+ const sys = await this.getForeignObjectAsync('system.config');
81
+ lang = (sys && sys.common && sys.common.language) || 'en';
82
+ } catch {
83
+ lang = 'en';
84
+ }
85
+ }
86
+ this._language = lang || 'en';
87
+ this.log.info(`Language: ${this._language}`);
88
+ }
89
+
90
+ /**
91
+ * Zeitzone ermitteln (für Kalender-Anhänge). ioBroker führt keine eigene
92
+ * Zeitzone → System-Zeitzone des Prozesses; falls ioBroker künftig eine
93
+ * in system.config.common pflegt, hat diese Vorrang.
94
+ */
95
+ async _resolveTimezone() {
96
+ let tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
97
+ try {
98
+ const sys = await this.getForeignObjectAsync('system.config');
99
+ const cfgTz = sys && sys.common && (sys.common.timezone || sys.common.tz);
100
+ if (cfgTz) {
101
+ tz = cfgTz;
102
+ }
103
+ } catch {
104
+ /* System-Zeitzone genügt */
105
+ }
106
+ this._timezone = tz || 'Europe/Berlin';
107
+ this.log.info(`Time zone: ${this._timezone}`);
108
+ }
109
+
110
+ /**
111
+ * Feiertage für die Arbeitstag-Wiederholungen einrichten. Nutzt – falls
112
+ * installiert – die Bundesland-Konfiguration des feiertage-Adapters,
113
+ * sonst die bundesweit einheitlichen gesetzlichen Feiertage.
114
+ */
115
+ async _initHolidays() {
116
+ let native = null;
117
+ try {
118
+ const obj = await this.getForeignObjectAsync('system.adapter.feiertage.0');
119
+ if (obj && obj.native) {
120
+ native = obj.native;
121
+ }
122
+ } catch {
123
+ /* Adapter nicht vorhanden -> Default */
124
+ }
125
+ const info = holidays.configure(native);
126
+ this.log.info(`Public holidays: source ${info.source}, ${info.count} relevant holidays per year`);
127
+ }
128
+
129
+ /**
130
+ * Secret für den Schreibschutz der /api-Routen. Wird in index.html als
131
+ * <meta name="kanban-token"> an die eigene SPA ausgeliefert.
132
+ *
133
+ * Ab 0.3.0 liegt das Secret im Dateispeicher des Adapters (apisecret.json) statt
134
+ * im lesbaren State info.apiSecret: Objektzugriff soll nicht automatisch
135
+ * Schreibzugriff auf die API bedeuten. Der State bleibt aus Kompatibilitätsgründen
136
+ * bestehen, wird aber leer geführt; ein dort noch vorhandener Wert wird einmalig
137
+ * übernommen und danach entfernt. Für Skripte sind die Agenten-Tokens der Weg.
138
+ */
139
+ async _initApiSecret() {
140
+ const newSecret = () => require('node:crypto').randomBytes(24).toString('hex');
141
+ try {
142
+ await this.setObjectNotExistsAsync('info.apiSecret', {
143
+ type: 'state',
144
+ common: {
145
+ name: 'API write secret (deprecated, moved to file storage)',
146
+ type: 'string',
147
+ role: 'text',
148
+ read: true,
149
+ write: false,
150
+ },
151
+ native: {},
152
+ });
153
+
154
+ let secret = '';
155
+ try {
156
+ const data = await this.readFileAsync(this.namespace, 'apisecret.json');
157
+ const buf = data && data.file !== undefined ? data.file : data;
158
+ const parsed = JSON.parse((Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).toString('utf8'));
159
+ if (parsed && parsed.secret) {
160
+ secret = String(parsed.secret);
161
+ }
162
+ } catch {
163
+ /* noch keine Datei -> unten anlegen */
164
+ }
165
+
166
+ if (!secret) {
167
+ // Migration: bisher im State abgelegtes Secret übernehmen, sonst neu erzeugen
168
+ const st = await this.getStateAsync('info.apiSecret');
169
+ secret = st && st.val ? String(st.val) : newSecret();
170
+ await this.writeFileAsync(
171
+ this.namespace,
172
+ 'apisecret.json',
173
+ Buffer.from(JSON.stringify({ secret }), 'utf8'),
174
+ );
175
+ this.log.info('API secret moved to the file storage; the state info.apiSecret is no longer filled.');
176
+ }
177
+
178
+ this._apiSecret = secret;
179
+
180
+ // State leeren (einmalig nach der Migration, danach idempotent)
181
+ const cur = await this.getStateAsync('info.apiSecret');
182
+ if (cur && cur.val) {
183
+ await this.setStateAsync('info.apiSecret', '', true);
184
+ }
185
+ } catch (e) {
186
+ this._apiSecret = newSecret();
187
+ this.log.warn(`Could not persist the API secret, using a volatile one: ${e.message}`);
188
+ }
189
+ }
190
+
191
+ _baseUrl() {
192
+ const cfg = this.config;
193
+ if (cfg.publicUrl) {
194
+ return String(cfg.publicUrl).replace(/\/+$/, '');
195
+ }
196
+ let ip = '127.0.0.1';
197
+ for (const ifaces of Object.values(os.networkInterfaces())) {
198
+ for (const iface of ifaces || []) {
199
+ if (iface.family === 'IPv4' && !iface.internal) {
200
+ ip = iface.address;
201
+ break;
202
+ }
203
+ }
204
+ if (ip !== '127.0.0.1') {
205
+ break;
206
+ }
207
+ }
208
+ return `http://${ip}:${this._port || cfg.port || 8095}`;
209
+ }
210
+
211
+ /**
212
+ * Gemeinsamer Kommando-Kern — bedient REST (indirekt), eingehende Webhooks,
213
+ * sendTo('kanban.0', <cmd>, {...}) und den action-State.
214
+ *
215
+ * @param cmd
216
+ * @param payload
217
+ * @param source
218
+ */
219
+ async handleCommand(cmd, payload, source) {
220
+ payload = payload || {};
221
+ const boardId = payload.board || payload.boardId;
222
+ const cardId = payload.cardId || payload.id;
223
+ // Karten-/Board-Antworten tragen wie in der REST-API das berechnete dueAt
224
+ const tz = () => this._timezone || 'Europe/Berlin';
225
+ const card = c => cardWithDueAt(c, tz());
226
+ const board = b => boardWithDueAt(b, tz());
227
+ switch (cmd) {
228
+ case 'listBoards':
229
+ case 'getBoards':
230
+ return this.store.listBoards();
231
+ case 'getBoard': {
232
+ const b = this.store.getBoard(boardId);
233
+ // Nicht still null liefern: ein Skript, das nur den Statuscode prüft,
234
+ // hielte das sonst für einen Erfolg.
235
+ if (!b) {
236
+ throw new Error(`Board '${boardId}' existiert nicht`);
237
+ }
238
+ return board(b);
239
+ }
240
+ case 'addBoard':
241
+ return this.store.createBoard({ id: payload.id, title: payload.title });
242
+ case 'deleteBoard':
243
+ await this.store.deleteBoard(boardId, source);
244
+ return { ok: true };
245
+ case 'addCard':
246
+ return card(this.store.addCard(boardId, payload, source));
247
+ case 'updateCard':
248
+ case 'editCard':
249
+ return card(this.store.updateCard(boardId, cardId, payload, source));
250
+ case 'moveCard':
251
+ return card(
252
+ this.store.moveCard(boardId, cardId, payload.column || payload.columnId, payload.order, source),
253
+ );
254
+ case 'doneCard': {
255
+ const b = this.store.getBoard(boardId);
256
+ if (!b) {
257
+ throw new Error(`Board '${boardId}' existiert nicht`);
258
+ }
259
+ const doneCol = b.columns.find(c => c.isDone);
260
+ if (!doneCol) {
261
+ throw new Error(`Board '${boardId}' hat keine Erledigt-Spalte`);
262
+ }
263
+ return card(this.store.moveCard(boardId, cardId, doneCol.id, undefined, source));
264
+ }
265
+ case 'deleteCard':
266
+ return card(this.store.deleteCard(boardId, cardId, source));
267
+ case 'restoreCard':
268
+ return card(this.store.restoreCard(boardId, cardId, payload.column || payload.columnId, source));
269
+ case 'purgeCard':
270
+ return card(this.store.purgeCard(boardId, cardId, source));
271
+ case 'emptyTrash':
272
+ return this.store.emptyTrash(boardId, source);
273
+ case 'transferCard':
274
+ return card(
275
+ this.store.transferCard(
276
+ boardId,
277
+ cardId,
278
+ payload.toBoard || payload.targetBoard,
279
+ payload.toColumn || payload.targetColumn,
280
+ payload.mode === 'copy' ? 'copy' : 'move',
281
+ { assignees: payload.assignees },
282
+ source,
283
+ ),
284
+ );
285
+ default:
286
+ throw new Error(`Unbekanntes Kommando '${cmd}'`);
287
+ }
288
+ }
289
+
290
+ async onStateChange(id, state) {
291
+ if (!state || state.ack || !state.val) {
292
+ return;
293
+ }
294
+ if (id !== `${this.namespace}.action`) {
295
+ return;
296
+ }
297
+ if (this.config.actionStateEnabled === false) {
298
+ return;
299
+ }
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(state.val);
303
+ } catch (e) {
304
+ this.log.warn(`action state does not contain valid JSON: ${e.message}`);
305
+ await this.setStateAsync('action', '', true);
306
+ return;
307
+ }
308
+ try {
309
+ await this.handleCommand(parsed.cmd, parsed, 'action-state');
310
+ this.log.debug(`action command '${parsed.cmd}' executed`);
311
+ } catch (e) {
312
+ this.log.warn(`action command failed: ${e.message}`);
313
+ }
314
+ await this.setStateAsync('action', '', true);
315
+ }
316
+
317
+ async onMessage(obj) {
318
+ if (!obj || !obj.command) {
319
+ return;
320
+ }
321
+
322
+ // Admin-Button „Token generieren": hängt einen neuen Zufallstoken an die
323
+ // aktuelle Tokens-Tabelle an und gibt sie zurück (Admin schreibt sie ins Feld).
324
+ if (obj.command === 'generateToken') {
325
+ const native = obj.message || {};
326
+ const tokens = Array.isArray(native.inboundTokens) ? native.inboundTokens.slice() : [];
327
+ const token = require('node:crypto').randomBytes(16).toString('hex');
328
+ let name = 'agent';
329
+ for (let i = 1; tokens.some(t => t && t.name === name); i++) {
330
+ name = `agent${i}`;
331
+ }
332
+ tokens.push({ name, token, allowedBoards: '*', enabled: true });
333
+ // jsonConfig übernimmt bei useNative nur ein Feld namens `native` in die
334
+ // Konfiguration; ohne diese Hülle blieb der Button wirkungslos.
335
+ if (obj.callback) {
336
+ this.sendTo(
337
+ obj.from,
338
+ obj.command,
339
+ { native: { inboundTokens: tokens }, saveConfig: true },
340
+ obj.callback,
341
+ );
342
+ }
343
+ return;
344
+ }
345
+
346
+ let result;
347
+ let error;
348
+ try {
349
+ result = await this.handleCommand(obj.command, obj.message || {}, `sendTo:${obj.from || ''}`);
350
+ } catch (e) {
351
+ error = e.message;
352
+ }
353
+ if (obj.callback) {
354
+ this.sendTo(obj.from, obj.command, error ? { error } : result, obj.callback);
355
+ }
356
+ }
357
+
358
+ async onUnload(callback) {
359
+ try {
360
+ if (this.scheduler) {
361
+ this.scheduler.stop();
362
+ }
363
+ if (this.webServer) {
364
+ await this.webServer.stop();
365
+ }
366
+ if (this.store) {
367
+ await this.store.flush();
368
+ }
369
+ await this.setStateAsync('info.connection', false, true);
370
+ } catch {
371
+ // ignorieren — wir fahren ohnehin herunter
372
+ } finally {
373
+ callback();
374
+ }
375
+ }
376
+ }
377
+
378
+ if (require.main !== module) {
379
+ module.exports = options => new Kanban(options);
380
+ } else {
381
+ new Kanban();
382
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "iobroker.kanban",
3
+ "version": "0.3.0",
4
+ "description": "Kanban board for ioBroker with webhooks, multi-user support and email notifications",
5
+ "author": {
6
+ "name": "Björn Müller",
7
+ "email": "bjoern@mueller.family"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/bmueller77/ioBroker.kanban.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/bmueller77/ioBroker.kanban/issues"
16
+ },
17
+ "homepage": "https://github.com/bmueller77/ioBroker.kanban#readme",
18
+ "keywords": [
19
+ "ioBroker",
20
+ "kanban",
21
+ "board",
22
+ "tasks"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "dependencies": {
28
+ "@iobroker/adapter-core": "^3.4.3",
29
+ "express": "^5.2.1",
30
+ "ws": "^8.21.1"
31
+ },
32
+ "main": "main.js",
33
+ "files": [
34
+ "admin/",
35
+ "lib/",
36
+ "www/",
37
+ "io-package.json",
38
+ "main.js",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "start": "node main.js",
43
+ "test:package": "mocha test/package --exit",
44
+ "test:guards": "mocha test/guards --exit",
45
+ "test:cron": "mocha test/cron --exit",
46
+ "test:trash": "mocha test/trash --exit",
47
+ "test:ui": "mocha test/ui.mjs --exit",
48
+ "test:integration": "mocha test/integration --exit",
49
+ "test": "npm run test:package && npm run test:guards && npm run test:cron && npm run test:trash && npm run test:ui",
50
+ "lint": "eslint ."
51
+ },
52
+ "devDependencies": {
53
+ "@iobroker/adapter-dev": "^1.5.0",
54
+ "@iobroker/eslint-config": "^2.2.0",
55
+ "@iobroker/testing": "^5.2.2",
56
+ "@types/node": "^22.0.0"
57
+ }
58
+ }