nodebb-plugin-onekite-calendar 2.0.39 → 2.0.41
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/CHANGELOG.md +4 -0
- package/lib/api.js +69 -41
- package/lib/discord.js +57 -0
- package/lib/group-helpers.js +31 -0
- package/lib/realtime.js +0 -4
- package/lib/widgets.js +13 -32
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +17 -33
- package/public/client.js +127 -40
- package/templates/admin/plugins/calendar-onekite.tpl +13 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.0.38
|
|
2
|
+
- Refactor : factorisation du code client (helpers headers/CSRF) et simplification du widget.
|
|
3
|
+
- Widget : suppression des fallbacks de polling/visibilitychange (mise à jour via sockets uniquement) + conservation du no-cache pour éviter les 304.
|
|
4
|
+
|
|
1
5
|
## 2.0.37
|
|
2
6
|
- Les rappels validateurs et notifications d’expiration ne sont envoyés qu’aux membres du/des groupe(s) "personnes notifiées" (notifyGroups).
|
|
3
7
|
|
package/lib/api.js
CHANGED
|
@@ -359,30 +359,17 @@ async function auditLog(action, actorUid, payload) {
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
async function canCreateSpecial(uid, settings) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
} catch (e) {}
|
|
368
|
-
const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
|
|
369
|
-
if (!allowed.length) return false;
|
|
370
|
-
if (await userInAnyGroup(uid, allowed)) return true;
|
|
371
|
-
|
|
372
|
-
return false;
|
|
362
|
+
async function canCreateSpecial(uid, settings, startTs) {
|
|
363
|
+
// Special events ("Évènements" / "Sorties") use the same rights as creating a reservation.
|
|
364
|
+
// startTs is used to resolve the auto yearly group (onekite-ffvl-YYYY).
|
|
365
|
+
const ts = Number(startTs) || Date.now();
|
|
366
|
+
return await canRequest(uid, settings, ts);
|
|
373
367
|
}
|
|
374
368
|
|
|
375
|
-
async function canDeleteSpecial(uid, settings) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (isAdmin) return true;
|
|
380
|
-
} catch (e) {}
|
|
381
|
-
const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
|
|
382
|
-
if (!allowed.length) return false;
|
|
383
|
-
if (await userInAnyGroup(uid, allowed)) return true;
|
|
384
|
-
|
|
385
|
-
return false;
|
|
369
|
+
async function canDeleteSpecial(uid, settings, startTs) {
|
|
370
|
+
// Deletion rights are the same as creation rights (creatorGroups).
|
|
371
|
+
const ts = Number(startTs) || Date.now();
|
|
372
|
+
return await canRequest(uid, settings, ts);
|
|
386
373
|
}
|
|
387
374
|
|
|
388
375
|
function eventsFor(resv) {
|
|
@@ -442,23 +429,36 @@ function eventsFor(resv) {
|
|
|
442
429
|
}
|
|
443
430
|
|
|
444
431
|
function eventsForSpecial(ev) {
|
|
432
|
+
const kind = String(ev.kind || 'event');
|
|
445
433
|
const start = new Date(parseInt(ev.start, 10));
|
|
446
434
|
const end = new Date(parseInt(ev.end, 10));
|
|
447
435
|
const startIso = start.toISOString();
|
|
448
436
|
const endIso = end.toISOString();
|
|
437
|
+
|
|
438
|
+
// Color coding:
|
|
439
|
+
// - event: purple
|
|
440
|
+
// - outing: blue
|
|
441
|
+
const palette = (kind === 'outing')
|
|
442
|
+
? { bg: '#0d6efd', border: '#0d6efd' }
|
|
443
|
+
: { bg: '#8e44ad', border: '#8e44ad' };
|
|
444
|
+
|
|
445
|
+
const defaultTitle = (kind === 'outing') ? 'Sortie' : 'Évènement';
|
|
446
|
+
|
|
449
447
|
return {
|
|
450
448
|
id: `special:${ev.eid}`,
|
|
451
|
-
title: `${ev.title ||
|
|
449
|
+
title: `${ev.title || defaultTitle}`.trim(),
|
|
452
450
|
allDay: false,
|
|
453
451
|
start: startIso,
|
|
454
452
|
end: endIso,
|
|
455
|
-
backgroundColor:
|
|
456
|
-
borderColor:
|
|
453
|
+
backgroundColor: palette.bg,
|
|
454
|
+
borderColor: palette.border,
|
|
457
455
|
textColor: '#ffffff',
|
|
458
456
|
extendedProps: {
|
|
459
457
|
type: 'special',
|
|
458
|
+
kind,
|
|
460
459
|
eid: ev.eid,
|
|
461
|
-
|
|
460
|
+
kind: String(ev.kind || 'event'),
|
|
461
|
+
title: ev.title || '',
|
|
462
462
|
notes: ev.notes || '',
|
|
463
463
|
pickupAddress: ev.address || '',
|
|
464
464
|
pickupLat: ev.lat || '',
|
|
@@ -493,8 +493,8 @@ api.getEvents = async function (req, res) {
|
|
|
493
493
|
const settings = await meta.settings.get('calendar-onekite');
|
|
494
494
|
const canMod = req.uid ? await canValidate(req.uid, settings) : false;
|
|
495
495
|
const widgetMode = String((req.query && req.query.widget) || '') === '1';
|
|
496
|
-
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
|
|
497
|
-
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
|
|
496
|
+
const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings, Date.now()) : false;
|
|
497
|
+
const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings, Date.now()) : false;
|
|
498
498
|
|
|
499
499
|
// Fetch a wider window because an event can start before the query range
|
|
500
500
|
// and still overlap.
|
|
@@ -611,7 +611,7 @@ api.getEvents = async function (req, res) {
|
|
|
611
611
|
type: 'special',
|
|
612
612
|
eid: sev.eid,
|
|
613
613
|
canCreateSpecial: canSpecialCreate,
|
|
614
|
-
canDeleteSpecial:
|
|
614
|
+
canDeleteSpecial: canSpecialDeleteResolved,
|
|
615
615
|
},
|
|
616
616
|
};
|
|
617
617
|
if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
|
|
@@ -696,17 +696,19 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
696
696
|
|
|
697
697
|
const settings = await meta.settings.get('calendar-onekite');
|
|
698
698
|
const canMod = await canValidate(uid, settings);
|
|
699
|
-
const canSpecialDelete = await canDeleteSpecial(uid, settings);
|
|
700
699
|
|
|
701
700
|
const eid = String(req.params.eid || '').trim();
|
|
702
701
|
if (!eid) return res.status(400).json({ error: 'missing-eid' });
|
|
703
702
|
const ev = await dbLayer.getSpecialEvent(eid);
|
|
704
703
|
if (!ev) return res.status(404).json({ error: 'not-found' });
|
|
705
704
|
|
|
705
|
+
const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
|
|
706
|
+
|
|
706
707
|
// Anyone who can see the calendar can view special events, but creator username
|
|
707
708
|
// is only visible to moderators/allowed users or the creator.
|
|
708
709
|
const out = {
|
|
709
710
|
eid: ev.eid,
|
|
711
|
+
kind: String(ev.kind || 'event'),
|
|
710
712
|
title: ev.title || '',
|
|
711
713
|
start: ev.start,
|
|
712
714
|
end: ev.end,
|
|
@@ -714,9 +716,9 @@ api.getSpecialEventDetails = async function (req, res) {
|
|
|
714
716
|
lat: ev.lat || '',
|
|
715
717
|
lon: ev.lon || '',
|
|
716
718
|
notes: ev.notes || '',
|
|
717
|
-
canDeleteSpecial:
|
|
719
|
+
canDeleteSpecial: canSpecialDeleteResolved,
|
|
718
720
|
};
|
|
719
|
-
if (ev.username && (canMod ||
|
|
721
|
+
if (ev.username && (canMod || canSpecialDeleteResolved || (uid && String(uid) === String(ev.uid)))) {
|
|
720
722
|
out.username = String(ev.username);
|
|
721
723
|
}
|
|
722
724
|
return res.json(out);
|
|
@@ -728,23 +730,28 @@ api.getCapabilities = async function (req, res) {
|
|
|
728
730
|
const canMod = uid ? await canValidate(uid, settings) : false;
|
|
729
731
|
res.json({
|
|
730
732
|
canModerate: canMod,
|
|
731
|
-
canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
|
|
732
|
-
canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
|
|
733
|
+
canCreateSpecial: uid ? await canCreateSpecial(uid, settings, Date.now()) : false,
|
|
734
|
+
canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings, Date.now()) : false,
|
|
733
735
|
});
|
|
734
736
|
};
|
|
735
737
|
|
|
736
738
|
api.createSpecialEvent = async function (req, res) {
|
|
737
739
|
const settings = await meta.settings.get('calendar-onekite');
|
|
738
740
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
739
|
-
const ok = await canCreateSpecial(req.uid, settings);
|
|
740
|
-
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
741
741
|
|
|
742
|
-
const
|
|
742
|
+
const kind = String((req.body && req.body.kind) || 'event').trim().toLowerCase();
|
|
743
|
+
const safeKind = (kind === 'outing') ? 'outing' : 'event';
|
|
744
|
+
|
|
743
745
|
const startTs = toTs(req.body && req.body.start);
|
|
744
746
|
const endTs = toTs(req.body && req.body.end);
|
|
745
747
|
if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
|
|
746
748
|
return res.status(400).json({ error: 'bad-dates' });
|
|
747
749
|
}
|
|
750
|
+
|
|
751
|
+
const ok = await canCreateSpecial(req.uid, settings, startTs);
|
|
752
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
753
|
+
|
|
754
|
+
const title = String((req.body && req.body.title) || '').trim() || (safeKind === 'outing' ? 'Sortie' : 'Évènement');
|
|
748
755
|
const address = String((req.body && req.body.address) || '').trim();
|
|
749
756
|
const notes = String((req.body && req.body.notes) || '').trim();
|
|
750
757
|
const lat = String((req.body && req.body.lat) || '').trim();
|
|
@@ -754,6 +761,7 @@ api.createSpecialEvent = async function (req, res) {
|
|
|
754
761
|
const eid = crypto.randomUUID();
|
|
755
762
|
const ev = {
|
|
756
763
|
eid,
|
|
764
|
+
kind: safeKind,
|
|
757
765
|
title,
|
|
758
766
|
start: String(startTs),
|
|
759
767
|
end: String(endTs),
|
|
@@ -765,21 +773,41 @@ api.createSpecialEvent = async function (req, res) {
|
|
|
765
773
|
username: u && u.username ? String(u.username) : '',
|
|
766
774
|
createdAt: String(Date.now()),
|
|
767
775
|
};
|
|
776
|
+
|
|
768
777
|
await dbLayer.saveSpecialEvent(ev);
|
|
778
|
+
|
|
779
|
+
// Discord notifications (separate webhooks per kind)
|
|
780
|
+
try {
|
|
781
|
+
await discord.notifySpecialEvent(settings, 'created', ev);
|
|
782
|
+
} catch (e) {}
|
|
783
|
+
|
|
769
784
|
// Real-time refresh for all viewers
|
|
770
|
-
realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid });
|
|
785
|
+
realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid, specialKind: ev.kind });
|
|
771
786
|
res.json({ ok: true, eid });
|
|
772
787
|
};
|
|
773
788
|
|
|
774
789
|
api.deleteSpecialEvent = async function (req, res) {
|
|
775
790
|
const settings = await meta.settings.get('calendar-onekite');
|
|
776
791
|
if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
|
|
777
|
-
|
|
778
|
-
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
792
|
+
|
|
779
793
|
const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
|
|
780
794
|
if (!eid) return res.status(400).json({ error: 'bad-id' });
|
|
795
|
+
|
|
796
|
+
const ev = await dbLayer.getSpecialEvent(eid);
|
|
797
|
+
if (!ev) return res.status(404).json({ error: 'not-found' });
|
|
798
|
+
|
|
799
|
+
const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
|
|
800
|
+
|
|
801
|
+
const ok = await canDeleteSpecial(req.uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
|
|
802
|
+
if (!ok) return res.status(403).json({ error: 'not-allowed' });
|
|
803
|
+
|
|
781
804
|
await dbLayer.removeSpecialEvent(eid);
|
|
782
|
-
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
await discord.notifySpecialEvent(settings, 'deleted', Object.assign({ eid }, ev || {}));
|
|
808
|
+
} catch (e) {}
|
|
809
|
+
|
|
810
|
+
realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid, specialKind: ev && ev.kind ? String(ev.kind) : 'event' });
|
|
783
811
|
res.json({ ok: true });
|
|
784
812
|
};
|
|
785
813
|
|
package/lib/discord.js
CHANGED
|
@@ -183,8 +183,65 @@ async function notifyReservationCancelled(settings, reservation) {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
|
|
187
|
+
function buildSpecialWebhookPayload(action, ev) {
|
|
188
|
+
const kind = String((ev && ev.kind) || 'event');
|
|
189
|
+
const webhookUsername = kind === 'outing'
|
|
190
|
+
? (action === 'deleted' ? 'Onekite • Sortie • Annulation' : 'Onekite • Sortie')
|
|
191
|
+
: (action === 'deleted' ? 'Onekite • Évènement • Annulation' : 'Onekite • Évènement');
|
|
192
|
+
|
|
193
|
+
const calUrl = 'https://www.onekite.com/calendar';
|
|
194
|
+
const title = kind === 'outing'
|
|
195
|
+
? (action === 'deleted' ? '❌ Sortie annulée' : '🗓️ Nouvelle sortie')
|
|
196
|
+
: (action === 'deleted' ? '❌ Évènement annulé' : '📣 Nouvel évènement');
|
|
197
|
+
|
|
198
|
+
const username = ev && ev.username ? String(ev.username) : '';
|
|
199
|
+
const start = ev && ev.start ? Number(ev.start) : NaN;
|
|
200
|
+
const end = ev && ev.end ? Number(ev.end) : NaN;
|
|
201
|
+
const address = ev && ev.address ? String(ev.address) : '';
|
|
202
|
+
const notes = ev && ev.notes ? String(ev.notes) : '';
|
|
203
|
+
|
|
204
|
+
const fields = [];
|
|
205
|
+
if (username) fields.push({ name: 'Créé par', value: username, inline: true });
|
|
206
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
207
|
+
fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
|
|
208
|
+
}
|
|
209
|
+
if (address) fields.push({ name: 'Adresse', value: address, inline: false });
|
|
210
|
+
if (notes) fields.push({ name: 'Notes', value: notes.length > 900 ? (notes.slice(0, 897) + '...') : notes, inline: false });
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
username: webhookUsername,
|
|
214
|
+
content: '',
|
|
215
|
+
embeds: [
|
|
216
|
+
{
|
|
217
|
+
title,
|
|
218
|
+
url: calUrl,
|
|
219
|
+
description: (ev && ev.title) ? String(ev.title) : '',
|
|
220
|
+
fields,
|
|
221
|
+
footer: { text: 'Onekite • Calendrier' },
|
|
222
|
+
timestamp: new Date().toISOString(),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function notifySpecialEvent(settings, action, ev) {
|
|
229
|
+
const kind = String((ev && ev.kind) || 'event');
|
|
230
|
+
const url = kind === 'outing'
|
|
231
|
+
? String((settings && settings.discordWebhookUrlOutings) || '').trim()
|
|
232
|
+
: String((settings && settings.discordWebhookUrlEvents) || '').trim();
|
|
233
|
+
if (!url) return;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await postWebhook(url, buildSpecialWebhookPayload(action, ev));
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.warn('[calendar-onekite] Discord webhook failed (special)', e && e.message ? e.message : String(e));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
186
242
|
module.exports = {
|
|
187
243
|
notifyReservationRequested,
|
|
188
244
|
notifyPaymentReceived,
|
|
189
245
|
notifyReservationCancelled,
|
|
246
|
+
notifySpecialEvent,
|
|
190
247
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const groups = require.main.require('./src/groups');
|
|
4
|
+
|
|
5
|
+
async function getGroupNameBySlug(slug) {
|
|
6
|
+
const fn = groups && groups.getGroupNameByGroupSlug;
|
|
7
|
+
if (typeof fn !== 'function') return null;
|
|
8
|
+
|
|
9
|
+
const id = String(slug || '').trim();
|
|
10
|
+
if (!id) return null;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const maybe = fn(id);
|
|
14
|
+
if (maybe && typeof maybe.then === 'function') {
|
|
15
|
+
return await maybe;
|
|
16
|
+
}
|
|
17
|
+
if (typeof maybe === 'string') {
|
|
18
|
+
return maybe;
|
|
19
|
+
}
|
|
20
|
+
} catch (e) {
|
|
21
|
+
// fall through to callback form
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return await new Promise((resolve) => {
|
|
25
|
+
try {
|
|
26
|
+
fn(id, (err, name) => resolve(err ? null : name));
|
|
27
|
+
} catch (e) {
|
|
28
|
+
resolve(null);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
package/lib/realtime.js
CHANGED
|
@@ -24,10 +24,6 @@ function emitCalendarUpdated(payload) {
|
|
|
24
24
|
|
|
25
25
|
// New event name (generic).
|
|
26
26
|
server.sockets.emit('event:calendar-onekite.calendarUpdated', payload || {});
|
|
27
|
-
|
|
28
|
-
// Backwards compatible event name (older clients only listened to this).
|
|
29
|
-
// Keep it emitted for *any* calendar mutation, not just reservation status.
|
|
30
|
-
server.sockets.emit('event:calendar-onekite.reservationUpdated', payload || {});
|
|
31
27
|
} catch (e) {}
|
|
32
28
|
}
|
|
33
29
|
|
package/lib/widgets.js
CHANGED
|
@@ -392,19 +392,19 @@ dateClick: function() { window.location.href = calUrl; },
|
|
|
392
392
|
calendar.render();
|
|
393
393
|
|
|
394
394
|
// Real-time refresh for the widget (same server events as the main calendar)
|
|
395
|
+
// We intentionally rely on sockets only (no periodic polling fallback):
|
|
396
|
+
// - keeps the widget lightweight
|
|
397
|
+
// - avoids pointless API traffic
|
|
398
|
+
// - updates are already broadcast server-side across NodeBB instances
|
|
395
399
|
try {
|
|
396
400
|
// Debounce per widget instance
|
|
397
401
|
let tRefetch = null;
|
|
398
402
|
const refetch = function () {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
tRefetch =
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
calendar.refetchEvents();
|
|
405
|
-
} catch (e) {}
|
|
406
|
-
}, 200);
|
|
407
|
-
} catch (e) {}
|
|
403
|
+
if (tRefetch) return;
|
|
404
|
+
tRefetch = setTimeout(() => {
|
|
405
|
+
tRefetch = null;
|
|
406
|
+
try { calendar.refetchEvents(); } catch (e) {}
|
|
407
|
+
}, 200);
|
|
408
408
|
};
|
|
409
409
|
|
|
410
410
|
// Register refetcher and bind socket listeners once per page
|
|
@@ -414,34 +414,15 @@ dateClick: function() { window.location.href = calUrl; },
|
|
|
414
414
|
if (!window.__oneKiteWidgetSocketBound && typeof socket !== 'undefined' && socket && typeof socket.on === 'function') {
|
|
415
415
|
window.__oneKiteWidgetSocketBound = true;
|
|
416
416
|
const triggerAll = function () {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
} catch (e) {}
|
|
417
|
+
const list = window.__oneKiteWidgetRefetchers || [];
|
|
418
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
419
|
+
try { list[i](); } catch (e) {}
|
|
420
|
+
}
|
|
423
421
|
};
|
|
424
422
|
socket.on('event:calendar-onekite.calendarUpdated', triggerAll);
|
|
425
|
-
socket.on('event:calendar-onekite.reservationUpdated', triggerAll);
|
|
426
423
|
}
|
|
427
424
|
} catch (e) {}
|
|
428
425
|
|
|
429
|
-
// Fallback refresh: in case sockets are unavailable or blocked by proxies,
|
|
430
|
-
// refetch periodically and when the tab becomes visible.
|
|
431
|
-
try {
|
|
432
|
-
window.__oneKiteWidgetIntervals = window.__oneKiteWidgetIntervals || {};
|
|
433
|
-
if (!window.__oneKiteWidgetIntervals[containerId]) {
|
|
434
|
-
window.__oneKiteWidgetIntervals[containerId] = setInterval(() => {
|
|
435
|
-
try { calendar.refetchEvents(); } catch (e) {}
|
|
436
|
-
}, 60000);
|
|
437
|
-
}
|
|
438
|
-
document.addEventListener('visibilitychange', () => {
|
|
439
|
-
try {
|
|
440
|
-
if (!document.hidden) calendar.refetchEvents();
|
|
441
|
-
} catch (e) {}
|
|
442
|
-
}, { passive: true });
|
|
443
|
-
} catch (e) {}
|
|
444
|
-
|
|
445
426
|
// Mobile swipe (left/right) to navigate weeks
|
|
446
427
|
try {
|
|
447
428
|
let touchStartX = null;
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/admin.js
CHANGED
|
@@ -109,48 +109,32 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
async function fetchJson(url, opts) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
116
|
-
const token =
|
|
112
|
+
const getCsrfToken = () => {
|
|
113
|
+
try {
|
|
114
|
+
return (
|
|
117
115
|
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
118
116
|
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
119
117
|
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
120
118
|
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
121
119
|
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
122
|
-
null
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
null
|
|
121
|
+
);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
128
|
+
const token = getCsrfToken();
|
|
129
|
+
if (token) headers['x-csrf-token'] = token;
|
|
130
|
+
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
credentials: 'same-origin',
|
|
133
|
+
headers,
|
|
126
134
|
...opts,
|
|
127
135
|
});
|
|
128
136
|
|
|
129
|
-
|
|
130
137
|
if (!res.ok) {
|
|
131
|
-
// NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
|
|
132
|
-
if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
|
|
133
|
-
const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
|
|
134
|
-
const res2 = await fetch(altUrl, {
|
|
135
|
-
credentials: 'same-origin',
|
|
136
|
-
headers: (() => {
|
|
137
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
138
|
-
const token =
|
|
139
|
-
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
140
|
-
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
141
|
-
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
142
|
-
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
143
|
-
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
144
|
-
null;
|
|
145
|
-
if (token) headers['x-csrf-token'] = token;
|
|
146
|
-
return headers;
|
|
147
|
-
})(),
|
|
148
|
-
...opts,
|
|
149
|
-
});
|
|
150
|
-
if (res2.ok) {
|
|
151
|
-
return await res2.json();
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
138
|
const text = await res.text().catch(() => '');
|
|
155
139
|
throw new Error(`${res.status} ${text}`);
|
|
156
140
|
}
|
package/public/client.js
CHANGED
|
@@ -138,6 +138,28 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
138
138
|
.replace(/'/g, ''');
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
function getCsrfToken() {
|
|
142
|
+
try {
|
|
143
|
+
return (
|
|
144
|
+
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
145
|
+
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
146
|
+
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
147
|
+
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
148
|
+
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
149
|
+
null
|
|
150
|
+
);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function jsonHeaders(extra) {
|
|
157
|
+
const headers = Object.assign({ 'Content-Type': 'application/json' }, extra || {});
|
|
158
|
+
const token = getCsrfToken();
|
|
159
|
+
if (token) headers['x-csrf-token'] = token;
|
|
160
|
+
return headers;
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
142
164
|
|
|
143
165
|
function toDateInputValue(d) {
|
|
@@ -186,7 +208,12 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
186
208
|
return opts.join('');
|
|
187
209
|
}
|
|
188
210
|
|
|
189
|
-
async function openSpecialEventDialog(selectionInfo) {
|
|
211
|
+
async function openSpecialEventDialog(selectionInfo, opts) {
|
|
212
|
+
const options = opts || {};
|
|
213
|
+
const kind = (String(options.kind || 'event').trim().toLowerCase() === 'outing') ? 'outing' : 'event';
|
|
214
|
+
const dialogTitle = String(options.dialogTitle || (kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement'));
|
|
215
|
+
const defaultTitle = String(options.defaultTitle || (kind === 'outing' ? 'Sortie' : 'Évènement'));
|
|
216
|
+
|
|
190
217
|
const start = selectionInfo.start;
|
|
191
218
|
// FullCalendar can omit `end` for certain interactions. Also, for all-day
|
|
192
219
|
// selections, `end` is exclusive (next day at 00:00). We normalise below
|
|
@@ -240,7 +267,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
240
267
|
const html = `
|
|
241
268
|
<div class="mb-3">
|
|
242
269
|
<label class="form-label">Titre</label>
|
|
243
|
-
<input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
|
|
270
|
+
<input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." value="${escapeHtml(defaultTitle)}" />
|
|
244
271
|
</div>
|
|
245
272
|
<div class="row g-2">
|
|
246
273
|
<div class="col-12 col-md-6">
|
|
@@ -285,7 +312,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
285
312
|
return await new Promise((resolve) => {
|
|
286
313
|
let resolved = false;
|
|
287
314
|
const dialog = bootbox.dialog({
|
|
288
|
-
title:
|
|
315
|
+
title: dialogTitle,
|
|
289
316
|
message: html,
|
|
290
317
|
buttons: {
|
|
291
318
|
cancel: {
|
|
@@ -334,7 +361,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
334
361
|
const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
|
|
335
362
|
const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
|
|
336
363
|
resolved = true;
|
|
337
|
-
resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
|
|
364
|
+
resolve({ kind, title, start: startVal, end: endVal, address, notes, lat, lon });
|
|
338
365
|
return true;
|
|
339
366
|
},
|
|
340
367
|
},
|
|
@@ -502,18 +529,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
502
529
|
async function fetchJson(url, opts) {
|
|
503
530
|
const res = await fetch(url, {
|
|
504
531
|
credentials: 'same-origin',
|
|
505
|
-
headers: (()
|
|
506
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
507
|
-
const token =
|
|
508
|
-
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
509
|
-
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
510
|
-
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
511
|
-
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
512
|
-
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
513
|
-
null;
|
|
514
|
-
if (token) headers['x-csrf-token'] = token;
|
|
515
|
-
return headers;
|
|
516
|
-
})(),
|
|
532
|
+
headers: jsonHeaders((opts && opts.headers) || {}),
|
|
517
533
|
...opts,
|
|
518
534
|
});
|
|
519
535
|
if (!res.ok) {
|
|
@@ -563,19 +579,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
563
579
|
try {
|
|
564
580
|
res = await fetch(url, {
|
|
565
581
|
credentials: 'same-origin',
|
|
566
|
-
headers: (
|
|
567
|
-
// reuse csrf header builder (fetchJson) by calling it indirectly
|
|
568
|
-
const base = { 'Content-Type': 'application/json' };
|
|
569
|
-
const token =
|
|
570
|
-
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
571
|
-
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
572
|
-
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
573
|
-
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
574
|
-
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
575
|
-
null;
|
|
576
|
-
if (token) base['x-csrf-token'] = token;
|
|
577
|
-
return Object.assign(base, headers);
|
|
578
|
-
})(),
|
|
582
|
+
headers: jsonHeaders(headers),
|
|
579
583
|
...opts,
|
|
580
584
|
});
|
|
581
585
|
} catch (e) {
|
|
@@ -1359,6 +1363,55 @@ function toDatetimeLocalValue(date) {
|
|
|
1359
1363
|
return;
|
|
1360
1364
|
}
|
|
1361
1365
|
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
// On a day click / selection in reservation mode, propose the user to create either
|
|
1369
|
+
// a Location (reservation) or a "Prévision de sortie" (special event).
|
|
1370
|
+
try {
|
|
1371
|
+
const choice = await new Promise((resolve) => {
|
|
1372
|
+
bootbox.dialog({
|
|
1373
|
+
title: 'Créer',
|
|
1374
|
+
message: '<div>Que veux-tu créer ?</div>',
|
|
1375
|
+
buttons: {
|
|
1376
|
+
cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
|
|
1377
|
+
outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
|
|
1378
|
+
reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
if (!choice) {
|
|
1384
|
+
calendar.unselect();
|
|
1385
|
+
isDialogOpen = false;
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (choice === 'outing') {
|
|
1390
|
+
const payload = await openSpecialEventDialog(info, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
|
|
1391
|
+
if (!payload) {
|
|
1392
|
+
calendar.unselect();
|
|
1393
|
+
isDialogOpen = false;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
1397
|
+
method: 'POST',
|
|
1398
|
+
body: JSON.stringify(payload),
|
|
1399
|
+
}).catch(async () => {
|
|
1400
|
+
return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
1401
|
+
method: 'POST',
|
|
1402
|
+
body: JSON.stringify(payload),
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
showAlert('success', 'Sortie créée.');
|
|
1406
|
+
invalidateEventsCache();
|
|
1407
|
+
scheduleRefetch(calendar);
|
|
1408
|
+
calendar.unselect();
|
|
1409
|
+
isDialogOpen = false;
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
// ignore
|
|
1414
|
+
}
|
|
1362
1415
|
// Business rule: reservations cannot start in the past.
|
|
1363
1416
|
// (We validate again on the server, but this gives immediate feedback.)
|
|
1364
1417
|
try {
|
|
@@ -1545,15 +1598,19 @@ function toDatetimeLocalValue(date) {
|
|
|
1545
1598
|
}
|
|
1546
1599
|
},
|
|
1547
1600
|
eventDidMount: function (arg) {
|
|
1548
|
-
// Keep special event colors consistent.
|
|
1601
|
+
// Keep special event colors consistent (kind-specific).
|
|
1549
1602
|
try {
|
|
1550
1603
|
const ev = arg && arg.event;
|
|
1551
1604
|
if (!ev) return;
|
|
1552
1605
|
if (ev.extendedProps && ev.extendedProps.type === 'special') {
|
|
1606
|
+
const kind = String((ev.extendedProps.kind || 'event'));
|
|
1607
|
+
const palette = (kind === 'outing')
|
|
1608
|
+
? { bg: '#0d6efd', border: '#0d6efd' }
|
|
1609
|
+
: { bg: '#8e44ad', border: '#8e44ad' };
|
|
1553
1610
|
const el2 = arg.el;
|
|
1554
1611
|
if (el2 && el2.style) {
|
|
1555
|
-
el2.style.backgroundColor =
|
|
1556
|
-
el2.style.borderColor =
|
|
1612
|
+
el2.style.backgroundColor = palette.bg;
|
|
1613
|
+
el2.style.borderColor = palette.border;
|
|
1557
1614
|
el2.style.color = '#ffffff';
|
|
1558
1615
|
}
|
|
1559
1616
|
}
|
|
@@ -1599,6 +1656,8 @@ function toDatetimeLocalValue(date) {
|
|
|
1599
1656
|
|
|
1600
1657
|
try {
|
|
1601
1658
|
if (p.type === 'special') {
|
|
1659
|
+
const specialKind = String((p.kind || p0.kind || 'event'));
|
|
1660
|
+
const specialLabel = (specialKind === 'outing') ? 'Sortie' : 'Évènement';
|
|
1602
1661
|
const username = String(p.username || '').trim();
|
|
1603
1662
|
const userLine = username
|
|
1604
1663
|
? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
@@ -1622,7 +1681,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1622
1681
|
`;
|
|
1623
1682
|
const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
|
|
1624
1683
|
bootbox.dialog({
|
|
1625
|
-
title:
|
|
1684
|
+
title: specialLabel,
|
|
1626
1685
|
message: html,
|
|
1627
1686
|
buttons: {
|
|
1628
1687
|
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
@@ -1634,7 +1693,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1634
1693
|
try {
|
|
1635
1694
|
const eid = String(p.eid || ev.id).replace(/^special:/, '');
|
|
1636
1695
|
await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
|
|
1637
|
-
showAlert('success',
|
|
1696
|
+
showAlert('success', `${specialLabel} supprimé.`);
|
|
1638
1697
|
calendar.refetchEvents();
|
|
1639
1698
|
} catch (e) {
|
|
1640
1699
|
showAlert('error', 'Suppression impossible.');
|
|
@@ -2153,6 +2212,40 @@ async function openFabDatePicker() {
|
|
|
2153
2212
|
try {
|
|
2154
2213
|
if (isDialogOpen) return;
|
|
2155
2214
|
isDialogOpen = true;
|
|
2215
|
+
|
|
2216
|
+
// Let the user pick between a Location or an Outing forecast.
|
|
2217
|
+
const choice = await new Promise((resolve) => {
|
|
2218
|
+
bootbox.dialog({
|
|
2219
|
+
title: 'Créer',
|
|
2220
|
+
message: '<div>Que veux-tu créer ?</div>',
|
|
2221
|
+
buttons: {
|
|
2222
|
+
cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
|
|
2223
|
+
outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
|
|
2224
|
+
reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
|
|
2225
|
+
},
|
|
2226
|
+
});
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
if (!choice) return;
|
|
2230
|
+
|
|
2231
|
+
if (choice === 'outing') {
|
|
2232
|
+
const payload = await openSpecialEventDialog({ start: s, end: endExcl, allDay: true }, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
|
|
2233
|
+
if (!payload) return;
|
|
2234
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
2235
|
+
method: 'POST',
|
|
2236
|
+
body: JSON.stringify(payload),
|
|
2237
|
+
}).catch(async () => {
|
|
2238
|
+
return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
2239
|
+
method: 'POST',
|
|
2240
|
+
body: JSON.stringify(payload),
|
|
2241
|
+
});
|
|
2242
|
+
});
|
|
2243
|
+
showAlert('success', 'Sortie créée.');
|
|
2244
|
+
invalidateEventsCache();
|
|
2245
|
+
if (currentCalendar) scheduleRefetch(currentCalendar);
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2156
2249
|
const items = cachedItems || (await loadItems());
|
|
2157
2250
|
const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
|
|
2158
2251
|
if (chosen && chosen.itemIds && chosen.itemIds.length) {
|
|
@@ -2383,12 +2476,6 @@ try {
|
|
|
2383
2476
|
scheduleRefetch(cal);
|
|
2384
2477
|
} catch (e) {}
|
|
2385
2478
|
});
|
|
2386
|
-
socket.on('event:calendar-onekite.reservationUpdated', function () {
|
|
2387
|
-
try {
|
|
2388
|
-
const cal = window.oneKiteCalendar;
|
|
2389
|
-
scheduleRefetch(cal);
|
|
2390
|
-
} catch (e) {}
|
|
2391
|
-
});
|
|
2392
2479
|
}
|
|
2393
2480
|
} catch (e) {}
|
|
2394
2481
|
|
|
@@ -80,6 +80,19 @@
|
|
|
80
80
|
<div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
|
+
<div class="mb-3">
|
|
84
|
+
<label class="form-label">Webhook URL (Évènements)</label>
|
|
85
|
+
<input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
|
|
86
|
+
<div class="form-text">Notifications Discord pour les <strong>évènements</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="mb-3">
|
|
90
|
+
<label class="form-label">Webhook URL (Sorties)</label>
|
|
91
|
+
<input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
|
|
92
|
+
<div class="form-text">Notifications Discord pour les <strong>sorties</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
|
|
83
96
|
<div class="mb-3">
|
|
84
97
|
<label class="form-label">Envoyer une notification à la demande</label>
|
|
85
98
|
<select class="form-select" name="discordNotifyOnRequest">
|
|
@@ -157,17 +170,6 @@
|
|
|
157
170
|
<div class="tab-pane fade" id="onekite-tab-events" role="tabpanel">
|
|
158
171
|
<h4>Évènements (autre couleur)</h4>
|
|
159
172
|
<div class="form-text mb-3">Permet de créer des évènements horaires (début/fin) avec adresse (Leaflet) et notes.</div>
|
|
160
|
-
|
|
161
|
-
<div class="mb-3">
|
|
162
|
-
<label class="form-label">Groupes autorisés à créer des évènements (csv)</label>
|
|
163
|
-
<input class="form-control" name="specialCreatorGroups" placeholder="ex: staff,instructors">
|
|
164
|
-
</div>
|
|
165
|
-
|
|
166
|
-
<div class="mb-3">
|
|
167
|
-
<label class="form-label">Groupes autorisés à supprimer des évènements (csv)</label>
|
|
168
|
-
<input class="form-control" name="specialDeleterGroups" placeholder="Si vide: même liste que la création">
|
|
169
|
-
</div>
|
|
170
|
-
|
|
171
173
|
<hr class="my-4" />
|
|
172
174
|
<h4>Purge des évènements</h4>
|
|
173
175
|
<div class="d-flex gap-2 align-items-center">
|