nodebb-plugin-onekite-calendar 2.0.66 → 2.0.67

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/lib/shared.js ADDED
@@ -0,0 +1,427 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Centralised helpers used across api.js, admin.js, helloassoWebhook.js,
5
+ * scheduler.js, discord.js and widgets.js.
6
+ *
7
+ * Previously these were duplicated (sometimes with subtle divergences) in
8
+ * multiple files. This module is the single source of truth.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const nconf = require.main.require('nconf');
13
+ const meta = require.main.require('./src/meta');
14
+ const emailer = require.main.require('./src/emailer');
15
+ const groups = require.main.require('./src/groups');
16
+ const user = require.main.require('./src/user');
17
+
18
+ const { getGroupNameBySlug } = require('./group-helpers');
19
+
20
+ // ─── Base URL ────────────────────────────────────────────────────────────────
21
+
22
+ function forumBaseUrl() {
23
+ // Try meta.config first (runtime-accurate), then nconf fallback.
24
+ let base = '';
25
+ try {
26
+ base = (meta && meta.config && (meta.config.url || meta.config['url']))
27
+ ? String(meta.config.url || meta.config['url'])
28
+ : '';
29
+ } catch (_) { /* ignore */ }
30
+ if (!base) {
31
+ try { base = String(nconf.get('url') || ''); } catch (_) { /* ignore */ }
32
+ }
33
+ base = base.trim().replace(/\/$/, '');
34
+ if (base && !/^https?:\/\//i.test(base)) {
35
+ base = `https://${base.replace(/^\/\//, '')}`;
36
+ }
37
+ return base;
38
+ }
39
+
40
+ // ─── HMAC / signing ──────────────────────────────────────────────────────────
41
+
42
+ function hmacSecret() {
43
+ try {
44
+ const s = String(nconf.get('secret') || '').trim();
45
+ if (s) return s;
46
+ } catch (_) { /* ignore */ }
47
+ return 'calendar-onekite';
48
+ }
49
+
50
+ function signCalendarLink(type, id, uid) {
51
+ try {
52
+ const msg = `${String(type)}:${String(id)}:${String(uid || 0)}`;
53
+ return crypto.createHmac('sha256', hmacSecret()).update(msg).digest('hex');
54
+ } catch (_) {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ // ─── Date formatting ─────────────────────────────────────────────────────────
60
+
61
+ function formatFR(tsOrIso) {
62
+ const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-'))
63
+ ? Date.parse(tsOrIso)
64
+ : Number(tsOrIso);
65
+ const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
66
+ const dd = String(d.getDate()).padStart(2, '0');
67
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
68
+ const yyyy = d.getFullYear();
69
+ return `${dd}/${mm}/${yyyy}`;
70
+ }
71
+
72
+ function formatFRShort(tsOrIso) {
73
+ const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-'))
74
+ ? Date.parse(tsOrIso)
75
+ : Number(tsOrIso);
76
+ const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
77
+ const dd = String(d.getDate()).padStart(2, '0');
78
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
79
+ const yy = String(d.getFullYear()).slice(-2);
80
+ return `${dd}/${mm}/${yy}`;
81
+ }
82
+
83
+ // ─── Group / UID normalisation ───────────────────────────────────────────────
84
+
85
+ function normalizeAllowedGroups(raw) {
86
+ if (!raw) return [];
87
+ if (Array.isArray(raw)) {
88
+ return raw.map((v) => String(v || '').trim()).filter(Boolean);
89
+ }
90
+ const s = String(raw || '').trim();
91
+ if (!s) return [];
92
+
93
+ // Handle JSON-encoded arrays (ACP can store them this way)
94
+ if (s.startsWith('[') && s.endsWith(']')) {
95
+ try {
96
+ const parsed = JSON.parse(s);
97
+ if (Array.isArray(parsed)) {
98
+ return parsed.map((v) => String(v || '').trim()).filter(Boolean);
99
+ }
100
+ } catch (_) { /* fall through */ }
101
+ }
102
+
103
+ return s
104
+ .split(',')
105
+ .map((v) => String(v || '').trim().replace(/^"+|"+$/g, ''))
106
+ .filter(Boolean);
107
+ }
108
+
109
+ function normalizeUids(members) {
110
+ if (!Array.isArray(members)) return [];
111
+ const out = [];
112
+ for (const m of members) {
113
+ if (Number.isInteger(m)) { out.push(m); continue; }
114
+ if (typeof m === 'string' && m.trim()) {
115
+ const n = parseInt(m, 10);
116
+ if (Number.isFinite(n)) { out.push(n); continue; }
117
+ }
118
+ if (m && typeof m === 'object' && m.uid != null) {
119
+ const n = Number.isInteger(m.uid) ? m.uid : parseInt(String(m.uid).trim(), 10);
120
+ if (Number.isFinite(n)) out.push(n);
121
+ }
122
+ }
123
+ return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
124
+ }
125
+
126
+ function normalizeUidList(uids) {
127
+ if (!uids) return [];
128
+ const arr = Array.isArray(uids) ? uids : (() => {
129
+ const s = String(uids || '').trim();
130
+ if (!s) return [];
131
+ try {
132
+ const parsed = JSON.parse(s);
133
+ return Array.isArray(parsed) ? parsed : [];
134
+ } catch (_) {
135
+ return s.split(',').map((x) => String(x).trim()).filter(Boolean);
136
+ }
137
+ })();
138
+
139
+ const out = [];
140
+ for (const u of arr) {
141
+ const n = Number.isInteger(u) ? u : parseInt(String(u || '').trim(), 10);
142
+ if (Number.isFinite(n) && n > 0) out.push(String(n));
143
+ }
144
+ return Array.from(new Set(out));
145
+ }
146
+
147
+ // ─── Group membership helpers ────────────────────────────────────────────────
148
+
149
+ async function getMembersByGroupIdentifier(groupIdentifier) {
150
+ const id = String(groupIdentifier || '').trim();
151
+ if (!id) return [];
152
+
153
+ try {
154
+ const members = await groups.getMembers(id, 0, -1);
155
+ if (Array.isArray(members) && members.length) return members;
156
+ } catch (_) { /* ignore */ }
157
+
158
+ const groupName = await getGroupNameBySlug(id);
159
+ if (groupName && String(groupName).trim() && String(groupName).trim() !== id) {
160
+ try {
161
+ const members = await groups.getMembers(String(groupName).trim(), 0, -1);
162
+ return Array.isArray(members) ? members : [];
163
+ } catch (_) {
164
+ return [];
165
+ }
166
+ }
167
+ return [];
168
+ }
169
+
170
+ async function userInAnyGroup(uid, allowedGroups) {
171
+ const allowed = normalizeAllowedGroups(allowedGroups);
172
+ if (!uid || !allowed.length) return false;
173
+
174
+ const ug = await groups.getUserGroups([uid]);
175
+ const list = (ug && ug[0]) ? ug[0] : [];
176
+
177
+ const seen = new Set();
178
+ for (const g of list) {
179
+ if (!g) continue;
180
+ if (g.slug) seen.add(String(g.slug));
181
+ if (g.name) seen.add(String(g.name));
182
+ if (g.groupName) seen.add(String(g.groupName));
183
+ if (g.displayName) seen.add(String(g.displayName));
184
+ }
185
+
186
+ return allowed.some((v) => seen.has(String(v)));
187
+ }
188
+
189
+ // ─── User helpers ────────────────────────────────────────────────────────────
190
+
191
+ async function usernamesByUids(uids) {
192
+ const ids = normalizeUidList(uids);
193
+ if (!ids.length) return [];
194
+
195
+ try {
196
+ if (typeof user.getUsersFields === 'function') {
197
+ const rows = await user.getUsersFields(ids, ['username']);
198
+ return (rows || []).map((r) => (r && r.username ? String(r.username) : '')).filter(Boolean);
199
+ }
200
+ } catch (_) { /* fall through */ }
201
+
202
+ const rows = await Promise.all(ids.map(async (uid) => {
203
+ try {
204
+ const r = await user.getUserFields(uid, ['username']);
205
+ return r && r.username ? String(r.username) : '';
206
+ } catch (_) { return ''; }
207
+ }));
208
+ return rows.filter(Boolean);
209
+ }
210
+
211
+ // ─── Email ───────────────────────────────────────────────────────────────────
212
+
213
+ async function sendEmail(template, uid, subject, data) {
214
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(String(uid), 10) : NaN);
215
+ if (!Number.isFinite(toUid) || toUid <= 0) return;
216
+
217
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
218
+
219
+ try {
220
+ if (typeof emailer.send !== 'function') return;
221
+ await emailer.send(template, toUid, params);
222
+ } catch (err) {
223
+ // eslint-disable-next-line no-console
224
+ console.warn('[calendar-onekite] Failed to send email', {
225
+ template,
226
+ uid: toUid,
227
+ err: err && err.message ? err.message : String(err),
228
+ });
229
+ }
230
+ }
231
+
232
+ // ─── Calendar export helpers (ICS / Google Calendar) ─────────────────────────
233
+
234
+ function ymdToCompact(ymd) {
235
+ return String(ymd || '').replace(/-/g, '');
236
+ }
237
+
238
+ function dtToGCalUtc(dt) {
239
+ const d = (dt instanceof Date) ? dt : new Date(dt);
240
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
241
+ }
242
+
243
+ /**
244
+ * Build ICS download URL + Google Calendar template URL.
245
+ *
246
+ * Accepts two calling conventions for backward-compatibility:
247
+ * (a) { type, id, uid, title, details, location, allDay, start, end, startYmd, endYmd }
248
+ * (used by api.js for all entity types)
249
+ * (b) { rid, uid, itemNames, pickupAddress, startYmd, endYmd }
250
+ * (used by admin.js / helloassoWebhook.js for reservations only)
251
+ */
252
+ function buildCalendarLinks(opts) {
253
+ // Normalise the two calling conventions
254
+ const type = opts.type || 'reservation';
255
+ const id = opts.id || opts.rid || '';
256
+ const uid = Number(opts.uid) || 0;
257
+ const itemNames = Array.isArray(opts.itemNames) ? opts.itemNames : [];
258
+ const defaultTitle = itemNames.length ? `Location - ${itemNames.join(', ')}` : 'Location';
259
+ const title = String(opts.title || defaultTitle).trim() || 'Évènement';
260
+ const details = String(opts.details || '').trim();
261
+ const location = String(opts.location || opts.pickupAddress || '').trim();
262
+ const isAllDay = opts.allDay !== undefined ? !!opts.allDay : true;
263
+
264
+ // Signed ICS URL
265
+ const sig = signCalendarLink(type, String(id), uid);
266
+ const icsPath = `/plugins/calendar-onekite/ics/${encodeURIComponent(type)}/${encodeURIComponent(id)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
267
+ const icsUrl = `${forumBaseUrl()}${icsPath}`;
268
+
269
+ // Google Calendar template URL
270
+ let dates = '';
271
+ if (isAllDay) {
272
+ dates = `${ymdToCompact(opts.startYmd)}/${ymdToCompact(opts.endYmd)}`;
273
+ } else {
274
+ dates = `${dtToGCalUtc(opts.start)}/${dtToGCalUtc(opts.end)}`;
275
+ }
276
+ const gcal = new URL('https://calendar.google.com/calendar/render');
277
+ gcal.searchParams.set('action', 'TEMPLATE');
278
+ gcal.searchParams.set('text', title);
279
+ if (dates) gcal.searchParams.set('dates', dates);
280
+ if (details) gcal.searchParams.set('details', details);
281
+ if (location) gcal.searchParams.set('location', location);
282
+
283
+ return { icsUrl, googleCalUrl: gcal.toString() };
284
+ }
285
+
286
+ // ─── HelloAsso URL helpers ───────────────────────────────────────────────────
287
+
288
+ function normalizeCallbackUrl(configured) {
289
+ const base = forumBaseUrl();
290
+ let url = (configured || '').trim();
291
+ if (!url) {
292
+ url = base ? `${base}/plugins/calendar-onekite/helloasso` : '';
293
+ }
294
+ if (url && url.startsWith('/') && base) {
295
+ url = `${base}${url}`;
296
+ }
297
+ if (url && !/^https?:\/\//i.test(url)) {
298
+ url = `https://${url.replace(/^\/\//, '')}`;
299
+ }
300
+ return url;
301
+ }
302
+
303
+ function normalizeReturnUrl() {
304
+ const base = forumBaseUrl();
305
+ return base ? `${base}/calendar` : '';
306
+ }
307
+
308
+ function autoFormSlugForYear(year) {
309
+ return `locations-materiel-${year}`;
310
+ }
311
+
312
+ function autoCreatorGroupForYear(year) {
313
+ return `onekite-ffvl-${year}`;
314
+ }
315
+
316
+ // ─── HelloAsso item name builder ─────────────────────────────────────────────
317
+
318
+ function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
319
+ const base = String(baseLabel || '').trim();
320
+ const items = Array.isArray(itemNames)
321
+ ? itemNames.map((s) => String(s || '').trim()).filter(Boolean)
322
+ : [];
323
+ const range = (start && end) ? (`Du ${formatFR(start)} au ${formatFR(end)}`) : '';
324
+
325
+ let out = '';
326
+ if (base) out += base;
327
+ if (items.length) out += (out ? ' — ' : '') + items.map((it) => '• ' + it).join(' ');
328
+ if (range) out += (out ? ' — ' : '') + range;
329
+
330
+ out = String(out || '').trim();
331
+ if (!out) out = 'Réservation matériel';
332
+
333
+ // HelloAsso constraint: itemName max 250 chars
334
+ if (out.length > 250) {
335
+ out = out.slice(0, 249).trimEnd() + '…';
336
+ }
337
+ return out;
338
+ }
339
+
340
+ // ─── Calendar-day arithmetic ─────────────────────────────────────────────────
341
+
342
+ /**
343
+ * Calendar-day difference (end exclusive) computed purely from Y/M/D.
344
+ * Uses UTC midnights to avoid any dependency on local timezone or DST.
345
+ */
346
+ function calendarDaysExclusiveYmd(startYmd, endYmd) {
347
+ try {
348
+ const m1 = /^\d{4}-\d{2}-\d{2}$/.exec(String(startYmd || '').trim());
349
+ const m2 = /^\d{4}-\d{2}-\d{2}$/.exec(String(endYmd || '').trim());
350
+ if (!m1 || !m2) return null;
351
+ const [sy, sm, sd] = String(startYmd).split('-').map((x) => parseInt(x, 10));
352
+ const [ey, em, ed] = String(endYmd).split('-').map((x) => parseInt(x, 10));
353
+ const sUtc = Date.UTC(sy, sm - 1, sd);
354
+ const eUtc = Date.UTC(ey, em - 1, ed);
355
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
356
+ return Math.max(1, diff);
357
+ } catch (_) {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ function yearFromTs(ts) {
363
+ const d = new Date(Number(ts));
364
+ return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
365
+ }
366
+
367
+ // ─── Misc ────────────────────────────────────────────────────────────────────
368
+
369
+ function arrayifyNames(obj) {
370
+ return (Array.isArray(obj && obj.itemNames)
371
+ ? obj.itemNames
372
+ : (obj && obj.itemName ? [obj.itemName] : [])
373
+ ).filter(Boolean);
374
+ }
375
+
376
+ function getSetting(settings, key, fallback) {
377
+ const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
378
+ if (v == null || v === '') return fallback;
379
+ if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
380
+ return v;
381
+ }
382
+
383
+ // ─── Exports ─────────────────────────────────────────────────────────────────
384
+
385
+ module.exports = {
386
+ // URLs
387
+ forumBaseUrl,
388
+ normalizeCallbackUrl,
389
+ normalizeReturnUrl,
390
+ autoFormSlugForYear,
391
+ autoCreatorGroupForYear,
392
+
393
+ // HMAC / signing
394
+ hmacSecret,
395
+ signCalendarLink,
396
+
397
+ // Dates
398
+ formatFR,
399
+ formatFRShort,
400
+ ymdToCompact,
401
+ dtToGCalUtc,
402
+ calendarDaysExclusiveYmd,
403
+ yearFromTs,
404
+
405
+ // Groups / UIDs
406
+ normalizeAllowedGroups,
407
+ normalizeUids,
408
+ normalizeUidList,
409
+ getMembersByGroupIdentifier,
410
+ userInAnyGroup,
411
+
412
+ // Users
413
+ usernamesByUids,
414
+
415
+ // Email
416
+ sendEmail,
417
+
418
+ // Calendar links
419
+ buildCalendarLinks,
420
+
421
+ // HelloAsso
422
+ buildHelloAssoItemName,
423
+
424
+ // Misc
425
+ arrayifyNames,
426
+ getSetting,
427
+ };
package/lib/utils.js CHANGED
@@ -1,70 +1,15 @@
1
1
  'use strict';
2
2
 
3
- const nconf = require.main.require('nconf');
4
-
5
- function getSetting(settings, key, fallback) {
6
- const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
7
- if (v == null || v === '') return fallback;
8
- if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
9
- return v;
10
- }
11
-
12
- function formatFR(tsOrIso) {
13
- const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
14
- const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
15
- const dd = String(d.getDate()).padStart(2, '0');
16
- const mm = String(d.getMonth() + 1).padStart(2, '0');
17
- const yyyy = d.getFullYear();
18
- return `${dd}/${mm}/${yyyy}`;
19
- }
20
-
21
- function formatFRShort(tsOrIso) {
22
- const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
23
- const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
24
- const dd = String(d.getDate()).padStart(2, '0');
25
- const mm = String(d.getMonth() + 1).padStart(2, '0');
26
- return `${dd}/${mm}`;
27
- }
28
-
29
- function forumBaseUrl() {
30
- return String(nconf.get('url') || '').trim().replace(/\/$/, '');
31
- }
32
-
33
- function normalizeAllowedGroups(raw) {
34
- if (!raw) return [];
35
- if (Array.isArray(raw)) {
36
- return raw.map((s) => String(s || '').trim()).filter(Boolean);
37
- }
38
- return String(raw)
39
- .split(',')
40
- .map((s) => String(s || '').trim())
41
- .filter(Boolean);
42
- }
43
-
44
- function normalizeUids(members) {
45
- if (!Array.isArray(members)) return [];
46
- const out = [];
47
- for (const m of members) {
48
- if (Number.isInteger(m)) { out.push(m); continue; }
49
- if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
50
- if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
51
- const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
52
- if (!Number.isNaN(u)) out.push(u);
53
- }
54
- }
55
- return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
56
- }
57
-
58
- function arrayifyNames(obj) {
59
- return (Array.isArray(obj && obj.itemNames) ? obj.itemNames : (obj && obj.itemName ? [obj.itemName] : [])).filter(Boolean);
60
- }
3
+ // Thin re-export layer for backward compatibility.
4
+ // All helpers now live in ./shared.js.
5
+ const shared = require('./shared');
61
6
 
62
7
  module.exports = {
63
- getSetting,
64
- formatFR,
65
- formatFRShort,
66
- forumBaseUrl,
67
- normalizeAllowedGroups,
68
- normalizeUids,
69
- arrayifyNames,
8
+ getSetting: shared.getSetting,
9
+ formatFR: shared.formatFR,
10
+ formatFRShort: shared.formatFRShort,
11
+ forumBaseUrl: shared.forumBaseUrl,
12
+ normalizeAllowedGroups: shared.normalizeAllowedGroups,
13
+ normalizeUids: shared.normalizeUids,
14
+ arrayifyNames: shared.arrayifyNames,
70
15
  };
package/lib/widgets.js CHANGED
@@ -1,10 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const nconf = require.main.require('nconf');
4
-
5
- function forumBaseUrl() {
6
- return String(nconf.get('url') || '').trim().replace(/\/$/, '');
7
- }
3
+ const { forumBaseUrl } = require('./shared');
8
4
 
9
5
  function escapeHtml(s) {
10
6
  return String(s || '')
@@ -331,7 +327,7 @@ dateClick: function() { window.location.href = calUrl; },
331
327
  const title = (ep.itemNameLine || ep.title || ev.title || '').toString();
332
328
  const statusLabel = (function(s){
333
329
  const map = {
334
- pending: 'En attente de validation de validation',
330
+ pending: 'En attente de validation',
335
331
  awaiting_payment: 'Validée – paiement en attente',
336
332
  paid: 'Payée',
337
333
  rejected: 'Rejetée',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.66",
3
+ "version": "2.0.67",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",