nodebb-plugin-onekite-calendar 2.0.43 → 2.0.45

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/admin.js CHANGED
@@ -4,6 +4,41 @@ const meta = require.main.require('./src/meta');
4
4
  const user = require.main.require('./src/user');
5
5
  const emailer = require.main.require('./src/emailer');
6
6
  const { forumBaseUrl, formatFR } = require('./utils');
7
+ const realtime = require('./realtime');
8
+ const crypto = require('crypto');
9
+ const nconf = require.main.require('nconf');
10
+
11
+ function baseUrl() {
12
+ try { return String(nconf.get('url') || '').replace(/\/$/, ''); } catch (e) { return forumBaseUrl || ''; }
13
+ }
14
+ function hmacSecret() {
15
+ try {
16
+ const s = String(nconf.get('secret') || '').trim();
17
+ if (s) return s;
18
+ } catch (e) {}
19
+ return 'calendar-onekite';
20
+ }
21
+ function signCalendarLink(type, id, uid) {
22
+ try {
23
+ return crypto.createHmac('sha256', hmacSecret()).update(`${String(type)}:${String(id)}:${String(uid || 0)}`).digest('hex');
24
+ } catch (e) { return ''; }
25
+ }
26
+ function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
27
+ function dtToGCalUtc(dt) {
28
+ const d = (dt instanceof Date) ? dt : new Date(dt);
29
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
30
+ }
31
+ function buildCalendarLinks({ rid, uid, itemNames, pickupAddress, startYmd, endYmd }) {
32
+ const sig = signCalendarLink('reservation', String(rid), Number(uid) || 0);
33
+ const icsUrl = `${baseUrl()}/plugins/calendar-onekite/ics/reservation/${encodeURIComponent(String(rid))}?uid=${encodeURIComponent(String(uid || 0))}&sig=${encodeURIComponent(sig)}`;
34
+ const title = (Array.isArray(itemNames) && itemNames.length) ? `Location - ${itemNames.join(', ')}` : 'Location';
35
+ const gcal = new URL('https://calendar.google.com/calendar/render');
36
+ gcal.searchParams.set('action', 'TEMPLATE');
37
+ gcal.searchParams.set('text', title);
38
+ gcal.searchParams.set('dates', `${ymdToCompact(startYmd)}/${ymdToCompact(endYmd)}`);
39
+ if (pickupAddress) gcal.searchParams.set('location', String(pickupAddress));
40
+ return { icsUrl, googleCalUrl: gcal.toString() };
41
+ }
7
42
 
8
43
  function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
9
44
  const base = String(baseLabel || 'Réservation matériel Onekite').trim();
@@ -60,7 +95,6 @@ function normalizeReturnUrl(meta) {
60
95
 
61
96
 
62
97
  const dbLayer = require('./db');
63
- const realtime = require('./realtime');
64
98
  const helloasso = require('./helloasso');
65
99
 
66
100
  const ADMIN_PRIV = 'admin:settings';
@@ -224,6 +258,14 @@ admin.approveReservation = async function (req, res) {
224
258
  pickupLat: r.pickupLat || '',
225
259
  pickupLon: r.pickupLon || '',
226
260
  mapUrl,
261
+ ...(buildCalendarLinks({
262
+ rid: String(r.rid),
263
+ uid: requesterUid,
264
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
265
+ pickupAddress: r.pickupAddress || '',
266
+ startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
267
+ endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
268
+ })),
227
269
  validatedBy: r.approvedByUsername || '',
228
270
  validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
229
271
  });
@@ -284,6 +326,9 @@ admin.purgeByYear = async function (req, res) {
284
326
  await dbLayer.removeReservation(rid);
285
327
  removed++;
286
328
  }
329
+ try {
330
+ realtime.emitCalendarUpdated({ kind: 'reservation', action: 'purged', year: y, removed });
331
+ } catch (e) {}
287
332
  res.json({ ok: true, removed });
288
333
  };
289
334
 
@@ -302,6 +347,9 @@ admin.purgeSpecialEventsByYear = async function (req, res) {
302
347
  await dbLayer.removeSpecialEvent(eid);
303
348
  count++;
304
349
  }
350
+ try {
351
+ realtime.emitCalendarUpdated({ kind: 'special', action: 'purged', year: y, removed: count });
352
+ } catch (e) {}
305
353
  return res.json({ ok: true, removed: count });
306
354
  };
307
355
 
@@ -320,6 +368,9 @@ admin.purgeOutingsByYear = async function (req, res) {
320
368
  await dbLayer.removeOuting(oid);
321
369
  count++;
322
370
  }
371
+ try {
372
+ realtime.emitCalendarUpdated({ kind: 'outing', action: 'purged', year: y, removed: count });
373
+ } catch (e) {}
323
374
  return res.json({ ok: true, removed: count });
324
375
  };
325
376
 
package/lib/api.js CHANGED
@@ -409,6 +409,19 @@ function eventsFor(resv) {
409
409
  const itemIds = Array.isArray(resv.itemIds) ? resv.itemIds : (resv.itemId ? [resv.itemId] : []);
410
410
  const itemNames = Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : []);
411
411
 
412
+ // Calendar export links (only really useful to the reservation creator, but
413
+ // harmless to include for everyone; access is protected by signature).
414
+ const links = buildCalendarLinks({
415
+ type: 'reservation',
416
+ id: String(resv.rid),
417
+ uid: Number(resv.uid) || 0,
418
+ title: (Array.isArray(itemNames) && itemNames.length) ? `Location - ${String(itemNames[0])}` : 'Location',
419
+ details: (Array.isArray(itemNames) && itemNames.length) ? `Matériel: ${itemNames.join(', ')}` : '',
420
+ allDay: true,
421
+ startYmd: startIsoDate,
422
+ endYmd: endIsoDate,
423
+ });
424
+
412
425
  // One line = one material: return one calendar event per item
413
426
  const out = [];
414
427
  const count = Math.max(itemIds.length, itemNames.length, 1);
@@ -435,6 +448,8 @@ function eventsFor(resv) {
435
448
  itemNames: itemNames.filter(Boolean),
436
449
  itemIdLine: itemId,
437
450
  itemNameLine: itemName,
451
+ icsUrl: links.icsUrl,
452
+ googleCalUrl: links.googleCalUrl,
438
453
  },
439
454
  });
440
455
  }
@@ -446,6 +461,17 @@ function eventsForSpecial(ev) {
446
461
  const end = new Date(parseInt(ev.end, 10));
447
462
  const startIso = start.toISOString();
448
463
  const endIso = end.toISOString();
464
+ const links = buildCalendarLinks({
465
+ type: 'special',
466
+ id: String(ev.eid),
467
+ uid: Number(ev.uid) || 0,
468
+ title: String(ev.title || 'Évènement'),
469
+ details: String(ev.notes || ''),
470
+ location: String(ev.address || ''),
471
+ allDay: false,
472
+ start,
473
+ end,
474
+ });
449
475
  return {
450
476
  id: `special:${ev.eid}`,
451
477
  title: `${ev.title || 'Évènement'}`.trim(),
@@ -465,6 +491,8 @@ function eventsForSpecial(ev) {
465
491
  pickupLon: ev.lon || '',
466
492
  createdBy: ev.uid || 0,
467
493
  username: ev.username || '',
494
+ icsUrl: links.icsUrl,
495
+ googleCalUrl: links.googleCalUrl,
468
496
  },
469
497
  };
470
498
  }
@@ -475,6 +503,17 @@ function eventsForOuting(o) {
475
503
  const end = new Date(parseInt(o.end, 10));
476
504
  const startIso = start.toISOString();
477
505
  const endIso = end.toISOString();
506
+ const links = buildCalendarLinks({
507
+ type: 'outing',
508
+ id: String(o.oid),
509
+ uid: Number(o.uid) || 0,
510
+ title: String(o.title || 'Prévision de sortie'),
511
+ details: String(o.notes || ''),
512
+ location: String(o.address || ''),
513
+ allDay: false,
514
+ start,
515
+ end,
516
+ });
478
517
  return {
479
518
  id: `outing:${o.oid}`,
480
519
  title: `${o.title || 'Prévision de sortie'}`.trim(),
@@ -494,12 +533,89 @@ function eventsForOuting(o) {
494
533
  pickupLon: o.lon || '',
495
534
  createdBy: o.uid || 0,
496
535
  username: o.username || '',
536
+ icsUrl: links.icsUrl,
537
+ googleCalUrl: links.googleCalUrl,
497
538
  },
498
539
  };
499
540
  }
500
541
 
501
542
  const api = {};
502
543
 
544
+ // --------------------
545
+ // Calendar export helpers (ICS / Google Calendar template link)
546
+ // --------------------
547
+ const nconf = require.main.require('nconf');
548
+ function baseUrl() {
549
+ try {
550
+ return String(nconf.get('url') || '').replace(/\/$/, '');
551
+ } catch (e) {
552
+ return '';
553
+ }
554
+ }
555
+
556
+ function hmacSecret() {
557
+ // Prefer NodeBB's instance secret if present.
558
+ try {
559
+ const s = String(nconf.get('secret') || '').trim();
560
+ if (s) return s;
561
+ } catch (e) {}
562
+ // Fallback: still deterministic per instance.
563
+ return 'calendar-onekite';
564
+ }
565
+
566
+ function signCalendarLink(type, id, uid) {
567
+ try {
568
+ const msg = `${String(type)}:${String(id)}:${String(uid || 0)}`;
569
+ return crypto.createHmac('sha256', hmacSecret()).update(msg).digest('hex');
570
+ } catch (e) {
571
+ return '';
572
+ }
573
+ }
574
+
575
+ function ymdToCompact(ymd) {
576
+ return String(ymd || '').replace(/-/g, '');
577
+ }
578
+
579
+ function dtToGCalUtc(dt) {
580
+ const d = (dt instanceof Date) ? dt : new Date(dt);
581
+ const iso = d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
582
+ // 20260116T120000Z style
583
+ return iso;
584
+ }
585
+
586
+ function buildCalendarLinks(opts) {
587
+ const type = String(opts.type || '').trim();
588
+ const id = String(opts.id || '').trim();
589
+ const uid = Number(opts.uid) || 0;
590
+ const title = String(opts.title || '').trim() || 'Évènement';
591
+ const details = String(opts.details || '').trim();
592
+ const location = String(opts.location || '').trim();
593
+ const isAllDay = !!opts.allDay;
594
+
595
+ // Signed ICS URL (public, but protected by signature for reservations).
596
+ const sig = signCalendarLink(type, id, uid);
597
+ const icsPath = `/plugins/calendar-onekite/ics/${encodeURIComponent(type)}/${encodeURIComponent(id)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
598
+ const icsUrl = `${baseUrl()}${icsPath}`;
599
+
600
+ // Google Calendar template URL.
601
+ // docs: action=TEMPLATE&text=...&dates=...&details=...&location=...
602
+ let dates = '';
603
+ if (isAllDay) {
604
+ // Google expects exclusive end date for all-day ranges.
605
+ dates = `${ymdToCompact(opts.startYmd)}/${ymdToCompact(opts.endYmd)}`;
606
+ } else {
607
+ dates = `${dtToGCalUtc(opts.start)}/${dtToGCalUtc(opts.end)}`;
608
+ }
609
+ const gcal = new URL('https://calendar.google.com/calendar/render');
610
+ gcal.searchParams.set('action', 'TEMPLATE');
611
+ gcal.searchParams.set('text', title);
612
+ if (dates) gcal.searchParams.set('dates', dates);
613
+ if (details) gcal.searchParams.set('details', details);
614
+ if (location) gcal.searchParams.set('location', location);
615
+
616
+ return { icsUrl, googleCalUrl: gcal.toString() };
617
+ }
618
+
503
619
  function computeEtag(payload) {
504
620
  // Weak ETag is fine here: it is only used to skip identical JSON payloads.
505
621
  const hash = crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex');
@@ -1424,6 +1540,28 @@ api.approveReservation = async function (req, res) {
1424
1540
  pickupLon: r.pickupLon || '',
1425
1541
  mapUrl,
1426
1542
  paymentUrl: r.paymentUrl || '',
1543
+ icsUrl: (buildCalendarLinks({
1544
+ type: 'reservation',
1545
+ id: String(r.rid),
1546
+ uid: requesterUid,
1547
+ title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
1548
+ details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
1549
+ location: r.pickupAddress || '',
1550
+ allDay: true,
1551
+ startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
1552
+ endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
1553
+ })).icsUrl,
1554
+ googleCalUrl: (buildCalendarLinks({
1555
+ type: 'reservation',
1556
+ id: String(r.rid),
1557
+ uid: requesterUid,
1558
+ title: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Location - ${r.itemNames.join(', ')}` : 'Location',
1559
+ details: (Array.isArray(r.itemNames) && r.itemNames.length) ? `Matériel: ${r.itemNames.join(', ')}` : (r.itemName ? `Matériel: ${r.itemName}` : ''),
1560
+ location: r.pickupAddress || '',
1561
+ allDay: true,
1562
+ startYmd: (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10),
1563
+ endYmd: (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10),
1564
+ })).googleCalUrl,
1427
1565
  validatedBy: r.approvedByUsername || '',
1428
1566
  validatedByUrl: (r.approvedByUsername ? `https://www.onekite.com/user/${encodeURIComponent(String(r.approvedByUsername))}` : ''),
1429
1567
  });
@@ -1677,4 +1815,144 @@ api.purgeAudit = async function (req, res) {
1677
1815
  return res.json(result || { ok: true });
1678
1816
  };
1679
1817
 
1818
+ // --------------------
1819
+ // ICS export (for Google/Apple/etc.)
1820
+ // --------------------
1821
+
1822
+ function icsEscape(str) {
1823
+ return String(str || '')
1824
+ .replace(/\\/g, '\\\\')
1825
+ .replace(/\n/g, '\\n')
1826
+ .replace(/\r/g, '')
1827
+ .replace(/;/g, '\\;')
1828
+ .replace(/,/g, '\\,');
1829
+ }
1830
+
1831
+ function dtstampUtc() {
1832
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
1833
+ }
1834
+
1835
+ function toIcsUtc(dt) {
1836
+ return new Date(dt).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
1837
+ }
1838
+
1839
+ function buildIcs({ uid, summary, description, location, allDay, startYmd, endYmd, start, end, url }) {
1840
+ const lines = [];
1841
+ lines.push('BEGIN:VCALENDAR');
1842
+ lines.push('VERSION:2.0');
1843
+ lines.push('PRODID:-//Calendar Onekite//EN');
1844
+ lines.push('CALSCALE:GREGORIAN');
1845
+ lines.push('METHOD:PUBLISH');
1846
+ lines.push('BEGIN:VEVENT');
1847
+ lines.push(`UID:${icsEscape(uid)}`);
1848
+ lines.push(`DTSTAMP:${dtstampUtc()}`);
1849
+ if (allDay) {
1850
+ lines.push(`DTSTART;VALUE=DATE:${ymdToCompact(startYmd)}`);
1851
+ lines.push(`DTEND;VALUE=DATE:${ymdToCompact(endYmd)}`);
1852
+ } else {
1853
+ lines.push(`DTSTART:${toIcsUtc(start)}`);
1854
+ lines.push(`DTEND:${toIcsUtc(end)}`);
1855
+ }
1856
+ lines.push(`SUMMARY:${icsEscape(summary)}`);
1857
+ if (description) lines.push(`DESCRIPTION:${icsEscape(description)}`);
1858
+ if (location) lines.push(`LOCATION:${icsEscape(location)}`);
1859
+ if (url) lines.push(`URL:${icsEscape(url)}`);
1860
+ lines.push('END:VEVENT');
1861
+ lines.push('END:VCALENDAR');
1862
+ return lines.join('\r\n') + '\r\n';
1863
+ }
1864
+
1865
+ api.getIcs = async function (req, res) {
1866
+ try {
1867
+ const type = String((req.params && req.params.type) || '').trim();
1868
+ const id = String((req.params && req.params.id) || '').trim();
1869
+ const uidQ = Number((req.query && req.query.uid) || 0) || 0;
1870
+ const sigQ = String((req.query && req.query.sig) || '').trim();
1871
+
1872
+ if (!type || !id) {
1873
+ return res.status(400).send('missing');
1874
+ }
1875
+
1876
+ // Validate signature when present. For reservations we require it.
1877
+ const expected = signCalendarLink(type, id, uidQ);
1878
+ const hasValidSig = !!(sigQ && expected && sigQ === expected);
1879
+
1880
+ let ics = '';
1881
+ let filename = `onekite-${type}-${id}.ics`;
1882
+
1883
+ if (type === 'reservation') {
1884
+ if (!hasValidSig) return res.status(403).send('not-authorized');
1885
+ const r = await dbLayer.getReservation(id);
1886
+ if (!r) return res.status(404).send('not-found');
1887
+ // Extra guard: signature uid must match the actual owner uid.
1888
+ if (Number(r.uid) !== Number(uidQ)) return res.status(403).send('not-authorized');
1889
+
1890
+ const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate)))
1891
+ ? String(r.startDate)
1892
+ : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10);
1893
+ const endYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate)))
1894
+ ? String(r.endDate)
1895
+ : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10);
1896
+
1897
+ const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
1898
+ const summary = itemNames.length ? `Location - ${itemNames.join(', ')}` : 'Location matériel';
1899
+ const description = [
1900
+ itemNames.length ? `Matériel: ${itemNames.join(', ')}` : '',
1901
+ r.pickupTime ? `Heure de récupération: ${r.pickupTime}` : '',
1902
+ r.pickupAddress ? `Adresse: ${r.pickupAddress}` : '',
1903
+ r.notes ? `Notes: ${r.notes}` : '',
1904
+ ].filter(Boolean).join('\n');
1905
+
1906
+ ics = buildIcs({
1907
+ uid: `reservation-${id}@onekite`,
1908
+ summary,
1909
+ description,
1910
+ location: String(r.pickupAddress || ''),
1911
+ allDay: true,
1912
+ startYmd,
1913
+ endYmd,
1914
+ url: `${baseUrl()}/calendar`,
1915
+ });
1916
+ } else if (type === 'special') {
1917
+ const ev = await dbLayer.getSpecialEvent(id);
1918
+ if (!ev) return res.status(404).send('not-found');
1919
+ const start = new Date(parseInt(ev.start, 10));
1920
+ const end = new Date(parseInt(ev.end, 10));
1921
+ ics = buildIcs({
1922
+ uid: `special-${id}@onekite`,
1923
+ summary: String(ev.title || 'Évènement'),
1924
+ description: String(ev.notes || ''),
1925
+ location: String(ev.address || ''),
1926
+ allDay: false,
1927
+ start,
1928
+ end,
1929
+ url: `${baseUrl()}/calendar`,
1930
+ });
1931
+ } else if (type === 'outing') {
1932
+ const o = await dbLayer.getOuting(id);
1933
+ if (!o) return res.status(404).send('not-found');
1934
+ const start = new Date(parseInt(o.start, 10));
1935
+ const end = new Date(parseInt(o.end, 10));
1936
+ ics = buildIcs({
1937
+ uid: `outing-${id}@onekite`,
1938
+ summary: String(o.title || 'Prévision de sortie'),
1939
+ description: String(o.notes || ''),
1940
+ location: String(o.address || ''),
1941
+ allDay: false,
1942
+ start,
1943
+ end,
1944
+ url: `${baseUrl()}/calendar`,
1945
+ });
1946
+ } else {
1947
+ return res.status(400).send('unknown-type');
1948
+ }
1949
+
1950
+ res.set('Content-Type', 'text/calendar; charset=utf-8');
1951
+ res.set('Content-Disposition', `attachment; filename="${filename.replace(/[^a-zA-Z0-9_.-]/g, '_')}"`);
1952
+ return res.status(200).send(ics);
1953
+ } catch (err) {
1954
+ return res.status(500).send('error');
1955
+ }
1956
+ };
1957
+
1680
1958
  module.exports = api;
package/lib/discord.js CHANGED
@@ -216,7 +216,6 @@ function buildSimpleCalendarPayload(kind, label, entity, opts) {
216
216
  {
217
217
  title: embedTitle,
218
218
  url: calUrl,
219
- description: kind === 'deleted' ? 'Une entrée a été annulée.' : 'Une nouvelle entrée a été créée.',
220
219
  fields,
221
220
  footer: { text: 'Onekite • Calendrier' },
222
221
  timestamp: new Date().toISOString(),
@@ -5,6 +5,7 @@ const crypto = require('crypto');
5
5
  const db = require.main.require('./src/database');
6
6
  const meta = require.main.require('./src/meta');
7
7
  const user = require.main.require('./src/user');
8
+ const nconf = require.main.require('nconf');
8
9
 
9
10
  const dbLayer = require('./db');
10
11
  const helloasso = require('./helloasso');
@@ -12,6 +13,44 @@ const discord = require('./discord');
12
13
  const realtime = require('./realtime');
13
14
  const { formatFR } = require('./utils');
14
15
 
16
+ function baseUrl() {
17
+ try { return String(nconf.get('url') || '').replace(/\/$/, ''); } catch (e) { return ''; }
18
+ }
19
+
20
+ function hmacSecret() {
21
+ try {
22
+ const s = String(nconf.get('secret') || '').trim();
23
+ if (s) return s;
24
+ } catch (e) {}
25
+ return 'calendar-onekite';
26
+ }
27
+
28
+ function signCalendarLink(type, id, uid) {
29
+ try {
30
+ return crypto.createHmac('sha256', hmacSecret()).update(`${String(type)}:${String(id)}:${String(uid || 0)}`).digest('hex');
31
+ } catch (e) { return ''; }
32
+ }
33
+
34
+ function ymdToCompact(ymd) { return String(ymd || '').replace(/-/g, ''); }
35
+
36
+ function buildReservationCalendarLinks(r) {
37
+ const rid = String(r && r.rid ? r.rid : '');
38
+ const uid = Number(r && r.uid) || 0;
39
+ const sig = signCalendarLink('reservation', rid, uid);
40
+ const icsUrl = `${baseUrl()}/plugins/calendar-onekite/ics/reservation/${encodeURIComponent(rid)}?uid=${encodeURIComponent(String(uid))}&sig=${encodeURIComponent(sig)}`;
41
+ const startYmd = (r.startDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.startDate))) ? String(r.startDate) : new Date(parseInt(r.start, 10)).toISOString().slice(0, 10);
42
+ const endYmd = (r.endDate && /^\d{4}-\d{2}-\d{2}$/.test(String(r.endDate))) ? String(r.endDate) : new Date(parseInt(r.end, 10)).toISOString().slice(0, 10);
43
+ const itemNames = Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : []);
44
+ const title = itemNames.length ? `Location - ${itemNames.join(', ')}` : 'Location';
45
+ const gcal = new URL('https://calendar.google.com/calendar/render');
46
+ gcal.searchParams.set('action', 'TEMPLATE');
47
+ gcal.searchParams.set('text', title);
48
+ gcal.searchParams.set('dates', `${ymdToCompact(startYmd)}/${ymdToCompact(endYmd)}`);
49
+ if (r.pickupAddress) gcal.searchParams.set('location', String(r.pickupAddress));
50
+ if (r.notes) gcal.searchParams.set('details', String(r.notes));
51
+ return { icsUrl, googleCalUrl: gcal.toString() };
52
+ }
53
+
15
54
  async function auditLog(action, actorUid, payload) {
16
55
  try {
17
56
  const uid = actorUid ? parseInt(actorUid, 10) : 0;
@@ -329,6 +368,7 @@ async function handler(req, res, next) {
329
368
  pickupTime: r.pickupTime || '',
330
369
  pickupAddress: r.pickupAddress || '',
331
370
  mapUrl,
371
+ ...(buildReservationCalendarLinks(r)),
332
372
  });
333
373
  }
334
374
 
package/library.js CHANGED
@@ -88,6 +88,10 @@ Plugin.init = async function (params) {
88
88
  router.get('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.getOutingDetails);
89
89
  router.delete('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.deleteOuting);
90
90
 
91
+ // Calendar export (ICS)
92
+ // Note: reservations are protected by a signature in the querystring.
93
+ router.get('/plugins/calendar-onekite/ics/:type/:id', api.getIcs);
94
+
91
95
  // Admin API (JSON)
92
96
  const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
93
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.43",
3
+ "version": "2.0.45",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.43"
42
+ "version": "2.0.45"
43
43
  }
package/public/client.js CHANGED
@@ -62,6 +62,35 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
62
62
  }
63
63
  }
64
64
 
65
+ /* Calendar item action buttons (match calendar colors) */
66
+ .btn-onekite-special {
67
+ background: #8e44ad;
68
+ border-color: #8e44ad;
69
+ color: #fff;
70
+ }
71
+ .btn-onekite-special:hover {
72
+ filter: brightness(0.95);
73
+ color: #fff;
74
+ }
75
+ .btn-onekite-outing {
76
+ background: #2980b9;
77
+ border-color: #2980b9;
78
+ color: #fff;
79
+ }
80
+ .btn-onekite-outing:hover {
81
+ filter: brightness(0.95);
82
+ color: #fff;
83
+ }
84
+ .btn-onekite-location {
85
+ background: #27ae60;
86
+ border-color: #27ae60;
87
+ color: #fff;
88
+ }
89
+ .btn-onekite-location:hover {
90
+ filter: brightness(0.95);
91
+ color: #fff;
92
+ }
93
+
65
94
  /* Mobile FAB date range picker (single calendar) */
66
95
  .onekite-range-picker { user-select: none; }
67
96
  .onekite-range-header { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
@@ -110,6 +139,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
110
139
  // Current FullCalendar instance (for refresh after actions)
111
140
  let currentCalendar = null;
112
141
 
142
+ // The creation chooser handler is created inside init() (it needs the
143
+ // calendar instance and capability flags). The mobile FAB is mounted outside
144
+ // init(), so we store the handler here.
145
+ let createFromSelectionHandler = null;
146
+
113
147
  // Mobile FAB (mounted only on the calendar page)
114
148
  let fabEl = null;
115
149
  let fabHandler = null;
@@ -208,7 +242,8 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
208
242
  return opts.join('');
209
243
  }
210
244
 
211
- async function openSpecialEventDialog(selectionInfo) {
245
+ async function openSpecialEventDialog(selectionInfo, opts) {
246
+ const kind = (opts && opts.kind) ? String(opts.kind) : 'special';
212
247
  const start = selectionInfo.start;
213
248
  // FullCalendar can omit `end` for certain interactions. Also, for all-day
214
249
  // selections, `end` is exclusive (next day at 00:00). We normalise below
@@ -259,10 +294,13 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
259
294
 
260
295
  const seStartTime = timeString(seStart);
261
296
  const seEndTime = timeString(seEnd);
297
+ const defaultTitlePlaceholder = kind === 'outing' ? 'Prévision de sortie' : 'Ex: ...';
298
+ const defaultTitleValue = kind === 'outing' ? 'Prévision de sortie' : '';
299
+
262
300
  const html = `
263
301
  <div class="mb-3">
264
302
  <label class="form-label">Titre</label>
265
- <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
303
+ <input type="text" class="form-control" id="onekite-se-title" value="${escapeHtml(defaultTitleValue)}" placeholder="${escapeHtml(defaultTitlePlaceholder)}" />
266
304
  </div>
267
305
  <div class="row g-2">
268
306
  <div class="col-12 col-md-6">
@@ -307,7 +345,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
307
345
  return await new Promise((resolve) => {
308
346
  let resolved = false;
309
347
  const dialog = bootbox.dialog({
310
- title: 'Créer un évènement',
348
+ title: kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement',
311
349
  message: html,
312
350
  buttons: {
313
351
  cancel: {
@@ -430,8 +468,9 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
430
468
 
431
469
 
432
470
  async function openOutingDialog(selectionInfo) {
433
- // Same UI as special events, but saved as "Sorties" (prévisions).
434
- const payload = await openSpecialEventDialog(selectionInfo);
471
+ // Same UI as special events, but saved as "Sorties" (prévisions) and with
472
+ // a different title.
473
+ const payload = await openSpecialEventDialog(selectionInfo, { kind: 'outing' });
435
474
  if (!payload) return null;
436
475
  // Default title if empty
437
476
  if (!payload.title) payload.title = 'Prévision de sortie';
@@ -1009,6 +1048,10 @@ function toDatetimeLocalValue(date) {
1009
1048
  const endDisplay = endInclusiveForDisplay(start, end);
1010
1049
 
1011
1050
  const messageHtml = `
1051
+ <div class="mb-2">
1052
+ <label class="form-label">Titre (facultatif)</label>
1053
+ <input type="text" class="form-control" id="onekite-res-title" placeholder="Ex: ..." />
1054
+ </div>
1012
1055
  <div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(endDisplay)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
1013
1056
  ${shortcutsHtml}
1014
1057
  <div class="mb-2"><strong>Matériel</strong></div>
@@ -1044,6 +1087,7 @@ function toDatetimeLocalValue(date) {
1044
1087
  label: 'Envoyer',
1045
1088
  className: 'btn-primary',
1046
1089
  callback: function () {
1090
+ const title = (document.getElementById('onekite-res-title')?.value || '').trim();
1047
1091
  const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
1048
1092
  if (!cbs.length) {
1049
1093
  showAlert('error', 'Choisis au moins un matériel.');
@@ -1055,7 +1099,7 @@ function toDatetimeLocalValue(date) {
1055
1099
  const total = (sum / 100) * days;
1056
1100
  // Return the effective end date (exclusive) because duration shortcuts can
1057
1101
  // change the range without updating the original FullCalendar selection.
1058
- resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
1102
+ resolve({ title, itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
1059
1103
  },
1060
1104
  },
1061
1105
  },
@@ -1287,12 +1331,13 @@ function toDatetimeLocalValue(date) {
1287
1331
  }
1288
1332
  }
1289
1333
 
1290
- const buttons = {
1291
- close: { label: 'Annuler', className: 'btn-secondary' },
1292
- location: {
1293
- label: 'Location',
1294
- className: 'btn-primary',
1295
- callback: async () => {
1334
+ // Buttons order matters for UX: put "Annuler" at bottom-right (last).
1335
+ const buttons = {};
1336
+
1337
+ buttons.location = {
1338
+ label: 'Location',
1339
+ className: 'btn-onekite-location',
1340
+ callback: async () => {
1296
1341
  try {
1297
1342
  isDialogOpen = true;
1298
1343
  if (!items || !items.length) {
@@ -1304,6 +1349,7 @@ function toDatetimeLocalValue(date) {
1304
1349
  const startDate = toLocalYmd(info.start);
1305
1350
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1306
1351
  const resp = await requestReservation({
1352
+ title: (chosen && chosen.title) ? String(chosen.title) : '',
1307
1353
  start: startDate,
1308
1354
  end: endDate,
1309
1355
  itemIds: chosen.itemIds,
@@ -1324,12 +1370,13 @@ function toDatetimeLocalValue(date) {
1324
1370
  isDialogOpen = false;
1325
1371
  }
1326
1372
  return true;
1327
- },
1328
1373
  },
1329
- outing: {
1330
- label: 'Prévision de sortie',
1331
- className: 'btn-outline-primary',
1332
- callback: async () => {
1374
+ };
1375
+
1376
+ buttons.outing = {
1377
+ label: 'Prévision de sortie',
1378
+ className: 'btn-onekite-outing',
1379
+ callback: async () => {
1333
1380
  try {
1334
1381
  isDialogOpen = true;
1335
1382
  const payload = await openOutingDialog(info);
@@ -1348,14 +1395,13 @@ function toDatetimeLocalValue(date) {
1348
1395
  isDialogOpen = false;
1349
1396
  }
1350
1397
  return true;
1351
- },
1352
1398
  },
1353
1399
  };
1354
1400
 
1355
1401
  if (canCreateSpecial) {
1356
1402
  buttons.special = {
1357
1403
  label: 'Évènement',
1358
- className: 'btn-outline-secondary',
1404
+ className: 'btn-onekite-special',
1359
1405
  callback: async () => {
1360
1406
  try {
1361
1407
  isDialogOpen = true;
@@ -1384,6 +1430,8 @@ function toDatetimeLocalValue(date) {
1384
1430
  };
1385
1431
  }
1386
1432
 
1433
+ buttons.cancel = { label: 'Annuler', className: 'btn-danger' };
1434
+
1387
1435
  bootbox.dialog({
1388
1436
  title: 'Créer',
1389
1437
  message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
@@ -1391,6 +1439,9 @@ function toDatetimeLocalValue(date) {
1391
1439
  });
1392
1440
  }
1393
1441
 
1442
+ // Expose to the mobile FAB (mounted outside init).
1443
+ createFromSelectionHandler = handleCreateFromSelection;
1444
+
1394
1445
  calendar = new FullCalendar.Calendar(el, {
1395
1446
  initialView: 'dayGridMonth',
1396
1447
  height: 'auto',
@@ -1528,13 +1579,19 @@ function toDatetimeLocalValue(date) {
1528
1579
  pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1529
1580
  pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1530
1581
  });
1531
- } else if (p0.type === 'outing' && p0.oid) {
1532
- const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
1533
- p = Object.assign({}, p0, details, {
1534
- pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1535
- pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1536
- pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1537
- });
1582
+ } else if (p0.type === 'outing') {
1583
+ // After a create+refetch cycle, FullCalendar can briefly provide an
1584
+ // event object without the full extendedProps we expect. Always
1585
+ // attempt to resolve the oid from either extendedProps or the event id.
1586
+ const oidGuess = String(p0.oid || ev.id || '').replace(/^outing:/, '').trim();
1587
+ if (oidGuess) {
1588
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oidGuess)}`);
1589
+ p = Object.assign({}, p0, details, {
1590
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1591
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1592
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1593
+ });
1594
+ }
1538
1595
  }
1539
1596
  } catch (e) {
1540
1597
  // ignore detail fetch errors; fall back to minimal props
@@ -1552,6 +1609,14 @@ function toDatetimeLocalValue(date) {
1552
1609
  const lon = Number(p.pickupLon || p.lon);
1553
1610
  const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1554
1611
  const notes = String(p.notes || '').trim();
1612
+ const icsUrl = String(p.icsUrl || (p.extendedProps && p.extendedProps.icsUrl) || '').trim();
1613
+ const googleCalUrl = String(p.googleCalUrl || (p.extendedProps && p.extendedProps.googleCalUrl) || '').trim();
1614
+ const calHtml = (icsUrl || googleCalUrl)
1615
+ ? `<div class="mb-2"><strong>Calendrier</strong><br>
1616
+ ${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
1617
+ ${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
1618
+ </div>`
1619
+ : '';
1555
1620
  const addrHtml = addr
1556
1621
  ? (hasCoords
1557
1622
  ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
@@ -1561,18 +1626,19 @@ function toDatetimeLocalValue(date) {
1561
1626
  <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1562
1627
  ${userLine}
1563
1628
  <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1629
+ ${calHtml}
1564
1630
  ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1565
1631
  ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1566
1632
  `;
1567
1633
  const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1568
- bootbox.dialog({
1634
+ const dlg = bootbox.dialog({
1569
1635
  title: 'Évènement',
1570
1636
  message: html,
1571
1637
  buttons: {
1572
1638
  close: { label: 'Fermer', className: 'btn-secondary' },
1573
1639
  ...(canDel ? {
1574
1640
  del: {
1575
- label: 'Supprimer',
1641
+ label: 'Annuler',
1576
1642
  className: 'btn-danger',
1577
1643
  callback: async () => {
1578
1644
  try {
@@ -1588,6 +1654,12 @@ function toDatetimeLocalValue(date) {
1588
1654
  } : {}),
1589
1655
  },
1590
1656
  });
1657
+ try {
1658
+ dlg.on('hidden.bs.modal', function () { isDialogOpen = false; });
1659
+ } catch (e) {
1660
+ // Fallback: never leave the dialog lock stuck.
1661
+ isDialogOpen = false;
1662
+ }
1591
1663
  return;
1592
1664
  }
1593
1665
 
@@ -1601,6 +1673,14 @@ function toDatetimeLocalValue(date) {
1601
1673
  const lon = Number(p.lon || p.pickupLon);
1602
1674
  const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1603
1675
  const notes = String(p.notes || '').trim();
1676
+ const icsUrl = String(p.icsUrl || (p.extendedProps && p.extendedProps.icsUrl) || '').trim();
1677
+ const googleCalUrl = String(p.googleCalUrl || (p.extendedProps && p.extendedProps.googleCalUrl) || '').trim();
1678
+ const calHtml = (icsUrl || googleCalUrl)
1679
+ ? `<div class="mb-2"><strong>Calendrier</strong><br>
1680
+ ${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
1681
+ ${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
1682
+ </div>`
1683
+ : '';
1604
1684
  const addrHtml = addr
1605
1685
  ? (hasCoords
1606
1686
  ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
@@ -1610,11 +1690,12 @@ function toDatetimeLocalValue(date) {
1610
1690
  <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1611
1691
  ${userLine}
1612
1692
  <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1693
+ ${calHtml}
1613
1694
  ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1614
1695
  ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1615
1696
  `;
1616
1697
  const canDel = !!(p.canDeleteOuting);
1617
- bootbox.dialog({
1698
+ const dlg = bootbox.dialog({
1618
1699
  title: 'Prévision de sortie',
1619
1700
  message: html,
1620
1701
  buttons: {
@@ -1637,6 +1718,11 @@ function toDatetimeLocalValue(date) {
1637
1718
  } : {}),
1638
1719
  },
1639
1720
  });
1721
+ try {
1722
+ dlg.on('hidden.bs.modal', function () { isDialogOpen = false; });
1723
+ } catch (e) {
1724
+ isDialogOpen = false;
1725
+ }
1640
1726
  return;
1641
1727
  }
1642
1728
  } catch (e) {
@@ -1660,6 +1746,9 @@ function toDatetimeLocalValue(date) {
1660
1746
  })();
1661
1747
  const period = `${formatDt(ev.start)} → ${formatDt(ev.end)}`;
1662
1748
 
1749
+ const icsUrl = String(p.icsUrl || (ev.extendedProps && ev.extendedProps.icsUrl) || '').trim();
1750
+ const googleCalUrl = String(p.googleCalUrl || (ev.extendedProps && ev.extendedProps.googleCalUrl) || '').trim();
1751
+
1663
1752
  const approvedBy = String(p.approvedByUsername || '').trim();
1664
1753
  const validatedByHtml = approvedBy
1665
1754
  ? `<div class=\"mb-2\"><strong>Validée par</strong><br><a href=\"https://www.onekite.com/user/${encodeURIComponent(approvedBy)}\">${escapeHtml(approvedBy)}</a></div>`
@@ -1699,6 +1788,10 @@ function toDatetimeLocalValue(date) {
1699
1788
  ${userLine}
1700
1789
  <div class="mb-2"><strong>Matériel</strong><br>${itemsHtml}</div>
1701
1790
  <div class="mb-2"><strong>Période</strong><br>${period}</div>
1791
+ ${ (icsUrl || googleCalUrl) ? `<div class="mb-2" id="onekite-cal-links" style="display:none;"><strong>Calendrier</strong><br>
1792
+ ${icsUrl ? `<a href="${escapeHtml(icsUrl)}" target="_blank" rel="noopener" class="me-3"><i class="fa fa-calendar-plus"></i> Ajouter (ICS)</a>` : ''}
1793
+ ${googleCalUrl ? `<a href="${escapeHtml(googleCalUrl)}" target="_blank" rel="noopener"><i class="fa fa-calendar"></i> Google Calendar</a>` : ''}
1794
+ </div>` : '' }
1702
1795
  ${validatedByHtml}
1703
1796
  ${pickupHtml}
1704
1797
  ${refusedReasonHtml}
@@ -1721,6 +1814,22 @@ function toDatetimeLocalValue(date) {
1721
1814
  const buttons = {
1722
1815
  close: { label: 'Fermer', className: 'btn-secondary' },
1723
1816
  };
1817
+
1818
+ const cancelBtn = showCancel ? {
1819
+ label: 'Annuler',
1820
+ className: 'btn-danger',
1821
+ callback: async () => {
1822
+ try {
1823
+ if (!lockAction(`cancel:${rid}`, 1200)) return false;
1824
+ await cancelReservation(rid);
1825
+ showAlert('success', 'Réservation annulée.');
1826
+ invalidateEventsCache();
1827
+ scheduleRefetch(calendar);
1828
+ } catch (e) {
1829
+ showAlert('error', 'Annulation impossible.');
1830
+ }
1831
+ },
1832
+ } : null;
1724
1833
  if (showPay) {
1725
1834
  buttons.pay = {
1726
1835
  label: 'Payer maintenant',
@@ -1733,23 +1842,6 @@ function toDatetimeLocalValue(date) {
1733
1842
  },
1734
1843
  };
1735
1844
  }
1736
- if (showCancel) {
1737
- buttons.cancel = {
1738
- label: 'Annuler',
1739
- className: 'btn-outline-warning',
1740
- callback: async () => {
1741
- try {
1742
- if (!lockAction(`cancel:${rid}`, 1200)) return false;
1743
- await cancelReservation(rid);
1744
- showAlert('success', 'Réservation annulée.');
1745
- invalidateEventsCache();
1746
- scheduleRefetch(calendar);
1747
- } catch (e) {
1748
- showAlert('error', 'Annulation impossible.');
1749
- }
1750
- },
1751
- };
1752
- }
1753
1845
  if (showModeration) {
1754
1846
  buttons.refuse = {
1755
1847
  label: 'Refuser',
@@ -1949,11 +2041,32 @@ function toDatetimeLocalValue(date) {
1949
2041
  };
1950
2042
  }
1951
2043
 
1952
- bootbox.dialog({
2044
+ // Put the cancellation action at the bottom-right (last button).
2045
+ if (cancelBtn) {
2046
+ buttons.cancel = cancelBtn;
2047
+ }
2048
+
2049
+ const dlg = bootbox.dialog({
1953
2050
  title: 'Réservation',
1954
2051
  message: baseHtml,
1955
2052
  buttons,
1956
2053
  });
2054
+
2055
+ // Only the creator should see the "Ajouter à mon calendrier" links for reservations.
2056
+ try {
2057
+ dlg.on('shown.bs.modal', () => {
2058
+ try {
2059
+ const cal = document.getElementById('onekite-cal-links');
2060
+ if (cal) {
2061
+ cal.style.display = isOwner ? 'block' : 'none';
2062
+ }
2063
+ } catch (e) {}
2064
+
2065
+ });
2066
+ dlg.on('hidden.bs.modal', () => { isDialogOpen = false; });
2067
+ } catch (e) {
2068
+ isDialogOpen = false;
2069
+ }
1957
2070
  },
1958
2071
  });
1959
2072
 
@@ -2368,7 +2481,12 @@ function parseYmdDate(ymdStr) {
2368
2481
  fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2369
2482
  fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2370
2483
 
2371
- fabHandler = () => openFabDatePicker(handleCreateFromSelection);
2484
+ fabHandler = () => {
2485
+ // init() sets createFromSelectionHandler. If the calendar has not
2486
+ // finished initialising, do nothing.
2487
+ if (typeof createFromSelectionHandler !== 'function') return;
2488
+ openFabDatePicker(createFromSelectionHandler);
2489
+ };
2372
2490
  fabEl.addEventListener('click', fabHandler);
2373
2491
 
2374
2492
  document.body.appendChild(fabEl);
@@ -83,18 +83,6 @@
83
83
  <div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
84
84
  </div>
85
85
 
86
- <div class="mb-3">
87
- <label class="form-label">Webhook URL — Évènements</label>
88
- <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
89
- <div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
90
- </div>
91
-
92
- <div class="mb-3">
93
- <label class="form-label">Webhook URL — Sorties</label>
94
- <input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
95
- <div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
96
- </div>
97
-
98
86
  <div class="mb-3">
99
87
  <label class="form-label">Envoyer une notification à la demande</label>
100
88
  <select class="form-select" name="discordNotifyOnRequest">
@@ -173,6 +161,13 @@
173
161
  <h4>Évènements (autre couleur)</h4>
174
162
  <div class="form-text mb-3">Permet de créer des évènements horaires (début/fin) avec adresse (Leaflet) et notes.</div>
175
163
 
164
+ <h4 class="mt-3">Discord</h4>
165
+ <div class="mb-3">
166
+ <label class="form-label">Webhook URL — Évènements</label>
167
+ <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
168
+ <div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
169
+ </div>
170
+
176
171
  <div class="mb-3">
177
172
  <label class="form-label">Groupes autorisés à créer des évènements (csv)</label>
178
173
  <input class="form-control" name="specialCreatorGroups" placeholder="ex: staff,instructors">
@@ -183,6 +178,14 @@
183
178
  <input class="form-control" name="specialDeleterGroups" placeholder="Si vide: même liste que la création">
184
179
  </div>
185
180
 
181
+ <hr class="my-4" />
182
+ <h4>Discord</h4>
183
+ <div class="mb-3">
184
+ <label class="form-label">Webhook URL — Évènements</label>
185
+ <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
186
+ <div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
187
+ </div>
188
+
186
189
  <hr class="my-4" />
187
190
  <h4>Purge des évènements</h4>
188
191
  <div class="d-flex gap-2 align-items-center">
@@ -196,6 +199,13 @@
196
199
  <h4>Sorties (prévisions)</h4>
197
200
  <div class="form-text mb-3">Prévisions de sortie (autre couleur) créées par les utilisateurs autorisés à faire une demande de location. Les utilisateurs peuvent annuler leurs propres prévisions. Les droits sont ceux des locations.</div>
198
201
 
202
+ <h4 class="mt-3">Discord</h4>
203
+ <div class="mb-3">
204
+ <label class="form-label">Webhook URL — Sorties</label>
205
+ <input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
206
+ <div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
207
+ </div>
208
+
199
209
  <hr class="my-4" />
200
210
  <h4>Purge des sorties</h4>
201
211
  <div class="d-flex gap-2 align-items-center">
@@ -14,6 +14,16 @@
14
14
 
15
15
  <p>{dateRange}</p>
16
16
 
17
+ <!-- IF icsUrl -->
18
+ <p>
19
+ 📅 <strong>Ajouter à mon calendrier :</strong>
20
+ <a href="{icsUrl}">ICS</a>
21
+ <!-- IF googleCalUrl -->
22
+ | <a href="{googleCalUrl}">Google Calendar</a>
23
+ <!-- ENDIF googleCalUrl -->
24
+ </p>
25
+ <!-- ENDIF icsUrl -->
26
+
17
27
  <!-- IF pickupTime -->
18
28
  <p><strong>Heure de récupération :</strong> {pickupTime}</p>
19
29
  <!-- ENDIF pickupTime -->
@@ -10,6 +10,16 @@
10
10
 
11
11
  <p>{dateRange}</p>
12
12
 
13
+ <!-- IF icsUrl -->
14
+ <p>
15
+ 📅 <strong>Ajouter à mon calendrier :</strong>
16
+ <a href="{icsUrl}">ICS</a>
17
+ <!-- IF googleCalUrl -->
18
+ | <a href="{googleCalUrl}">Google Calendar</a>
19
+ <!-- ENDIF googleCalUrl -->
20
+ </p>
21
+ <!-- ENDIF icsUrl -->
22
+
13
23
  <!-- IF pickupTime -->
14
24
  <p><strong>Heure de récupération :</strong> {pickupTime}</p>
15
25
  <!-- ENDIF pickupTime -->