nodebb-plugin-calendar-onekite 11.1.82 → 11.1.83

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
@@ -21,27 +21,26 @@ function formatFR(tsOrIso) {
21
21
  async function sendEmail(template, toEmail, subject, data) {
22
22
  if (!toEmail) return;
23
23
  try {
24
- const safeSubject = String(subject || '').trim() || 'Location';
25
- const dataWithSubject = Object.assign({}, data || {}, { subject: safeSubject, _subject: safeSubject });
26
-
27
- // Deterministic call order to avoid passing `subject` as data on some NodeBB versions.
28
24
  if (typeof emailer.sendToEmail === 'function') {
29
25
  if (emailer.sendToEmail.length >= 4) {
30
- await emailer.sendToEmail(template, toEmail, dataWithSubject, safeSubject);
26
+ await emailer.sendToEmail(template, toEmail, subject, data);
31
27
  } else {
28
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
32
29
  await emailer.sendToEmail(template, toEmail, dataWithSubject);
33
30
  }
34
31
  return;
35
32
  }
36
-
37
- // Fallback: some installs expose `emailer.send` only.
38
33
  if (typeof emailer.send === 'function') {
39
34
  if (emailer.send.length >= 4) {
40
- await emailer.send(template, toEmail, dataWithSubject, safeSubject);
41
- } else {
35
+ await emailer.send(template, toEmail, subject, data);
36
+ return;
37
+ }
38
+ if (emailer.send.length === 3) {
39
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
42
40
  await emailer.send(template, toEmail, dataWithSubject);
41
+ return;
43
42
  }
44
- return;
43
+ await emailer.send(template, toEmail, subject, data);
45
44
  }
46
45
  } catch (err) {
47
46
  console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err && err.message || err) });
@@ -87,12 +86,7 @@ admin.getSettings = async function (req, res) {
87
86
  };
88
87
 
89
88
  admin.saveSettings = async function (req, res) {
90
- // Merge instead of overwrite so a missing/empty body (eg. due to body parser
91
- // differences across NodeBB versions) does not wipe existing settings.
92
- const current = (await meta.settings.get('calendar-onekite')) || {};
93
- const incoming = req.body && typeof req.body === 'object' ? req.body : {};
94
- const merged = Object.assign({}, current, incoming);
95
- await meta.settings.set('calendar-onekite', merged);
89
+ await meta.settings.set('calendar-onekite', req.body || {});
96
90
  res.json({ ok: true });
97
91
  };
98
92
 
package/lib/api.js CHANGED
@@ -16,34 +16,44 @@ async function sendEmail(template, toEmail, subject, data) {
16
16
  if (!toEmail) return;
17
17
  const emailer = require.main.require('./src/emailer');
18
18
  try {
19
- const safeSubject = String(subject || '').trim() || 'Location';
20
- const dataWithSubject = Object.assign({}, data || {}, { subject: safeSubject, _subject: safeSubject });
21
-
22
- // Deterministic call order to avoid passing `subject` as `data` on some NodeBB versions.
19
+ // Newer NodeBB builds expose sendToEmail
23
20
  if (typeof emailer.sendToEmail === 'function') {
24
21
  if (emailer.sendToEmail.length >= 4) {
25
- await emailer.sendToEmail(template, toEmail, dataWithSubject, safeSubject);
26
- } else {
22
+ await emailer.sendToEmail(template, toEmail, subject, data);
23
+ return;
24
+ }
25
+ if (emailer.sendToEmail.length === 3) {
26
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
27
27
  await emailer.sendToEmail(template, toEmail, dataWithSubject);
28
+ return;
28
29
  }
30
+ // Fallback
31
+ await emailer.sendToEmail(template, toEmail, subject, data);
29
32
  return;
30
33
  }
31
-
32
34
  if (typeof emailer.send === 'function') {
35
+ // Common: (template, email, subject, data)
33
36
  if (emailer.send.length >= 4) {
34
- await emailer.send(template, toEmail, dataWithSubject, safeSubject);
35
- } else {
37
+ await emailer.send(template, toEmail, subject, data);
38
+ return;
39
+ }
40
+ // Some builds: (template, email, data)
41
+ // In that case, subject is expected inside `data.subject`.
42
+ if (emailer.send.length === 3) {
43
+ const dataWithSubject = Object.assign({}, data || {}, subject ? { subject } : {});
36
44
  await emailer.send(template, toEmail, dataWithSubject);
45
+ return;
37
46
  }
47
+ // Fallback: try 4-args anyway
48
+ await emailer.send(template, toEmail, subject, data);
38
49
  return;
39
50
  }
40
51
  } catch (err) {
41
52
  // eslint-disable-next-line no-console
42
- console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String((err && err.message) || err) });
53
+ console.warn('[calendar-onekite] Failed to send email', { template, toEmail, err: String(err && err.message || err) });
43
54
  }
44
55
  }
45
56
 
46
-
47
57
  function normalizeBaseUrl(meta) {
48
58
  // Prefer meta.config.url, fallback to nconf.get('url')
49
59
  let base = (meta && meta.config && (meta.config.url || meta.config['url'])) ? (meta.config.url || meta.config['url']) : '';
@@ -229,12 +239,7 @@ function eventsForSpecial(ev) {
229
239
  allDay: false,
230
240
  start: startIso,
231
241
  end: endIso,
232
- // In month (dayGrid) view, timed events default to a "list-item" rendering (dot only).
233
- // Force a block rendering so the event stays visually distinct (purple background).
234
- display: 'block',
235
- backgroundColor: '#8e44ad',
236
- borderColor: '#8e44ad',
237
- textColor: '#ffffff',
242
+ color: '#8e44ad',
238
243
  extendedProps: {
239
244
  type: 'special',
240
245
  eid: ev.eid,
@@ -502,7 +507,7 @@ api.createReservation = async function (req, res) {
502
507
  await sendEmail(
503
508
  'calendar-onekite_pending',
504
509
  md.email,
505
- 'Location - Demande de réservation',
510
+ 'Location matériel - Demande de réservation',
506
511
  {
507
512
  username: md.username,
508
513
  requester: requester.username,
@@ -600,7 +605,7 @@ api.approveReservation = async function (req, res) {
600
605
  const mapUrl = (Number.isFinite(latNum) && Number.isFinite(lonNum))
601
606
  ? `https://www.openstreetmap.org/?mlat=${encodeURIComponent(String(latNum))}&mlon=${encodeURIComponent(String(lonNum))}#map=18/${encodeURIComponent(String(latNum))}/${encodeURIComponent(String(lonNum))}`
602
607
  : '';
603
- await sendEmail('calendar-onekite_approved', requester.email, 'Location - Réservation validée', {
608
+ await sendEmail('calendar-onekite_approved', requester.email, 'Location matériel - Réservation validée', {
604
609
  username: requester.username,
605
610
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
606
611
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
@@ -636,7 +641,7 @@ api.refuseReservation = async function (req, res) {
636
641
 
637
642
  const requester = await user.getUserFields(r.uid, ['username', 'email']);
638
643
  if (requester && requester.email) {
639
- await sendEmail('calendar-onekite_refused', requester.email, 'Location - Demande de réservation', {
644
+ await sendEmail('calendar-onekite_refused', requester.email, 'Location matériel - Demande de réservation', {
640
645
  username: requester.username,
641
646
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
642
647
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
@@ -26,34 +26,31 @@ const PROCESSED_KEY = 'calendar-onekite:helloasso:processedPayments';
26
26
  async function sendEmail(template, toEmail, subject, data) {
27
27
  if (!toEmail) return;
28
28
  const emailer = require.main.require('./src/emailer');
29
- const dataWithSubject = Object.assign({}, data || {}, subject ? { subject, _subject: subject } : {});
30
29
  try {
31
- const attempts = [];
32
30
  if (typeof emailer.sendToEmail === 'function') {
33
- if (subject) {
34
- attempts.push(() => emailer.sendToEmail(template, toEmail, subject, dataWithSubject));
35
- attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject, subject));
31
+ // NodeBB versions differ:
32
+ // - sendToEmail(template, email, subject, data)
33
+ // - sendToEmail(template, email, data)
34
+ if (emailer.sendToEmail.length >= 4) {
35
+ await emailer.sendToEmail(template, toEmail, subject, data);
36
+ } else {
37
+ const dataWithSubject = Object.assign({}, data || {}, { subject });
38
+ await emailer.sendToEmail(template, toEmail, dataWithSubject);
36
39
  }
37
- attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
40
+ return;
38
41
  }
39
42
  if (typeof emailer.send === 'function') {
40
- if (subject) {
41
- attempts.push(() => emailer.send(template, toEmail, subject, dataWithSubject));
42
- attempts.push(() => emailer.send(template, toEmail, dataWithSubject, subject));
43
+ if (emailer.send.length >= 4) {
44
+ await emailer.send(template, toEmail, subject, data);
45
+ return;
43
46
  }
44
- attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
45
- }
46
-
47
- let lastErr = null;
48
- for (const fn of attempts) {
49
- try {
50
- await fn();
47
+ if (emailer.send.length === 3) {
48
+ const dataWithSubject = Object.assign({}, data || {}, { subject });
49
+ await emailer.send(template, toEmail, dataWithSubject);
51
50
  return;
52
- } catch (e) {
53
- lastErr = e;
54
51
  }
52
+ await emailer.send(template, toEmail, subject, data);
55
53
  }
56
- if (lastErr) throw lastErr;
57
54
  } catch (err) {
58
55
  // eslint-disable-next-line no-console
59
56
  console.warn('[calendar-onekite] Failed to send email (webhook)', { template, toEmail, err: String(err && err.message || err) });
package/lib/scheduler.js CHANGED
@@ -58,34 +58,21 @@ async function processAwaitingPayment() {
58
58
  async function sendEmail(template, toEmail, subject, data) {
59
59
  if (!toEmail) return;
60
60
  try {
61
- const dataWithSubject = Object.assign({}, data || {}, subject ? { subject, _subject: subject } : {});
62
-
63
- const attempts = [];
64
- if (typeof emailer.sendToEmail === 'function') {
65
- if (subject) {
66
- attempts.push(() => emailer.sendToEmail(template, toEmail, subject, dataWithSubject));
67
- attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject, subject));
68
- }
69
- attempts.push(() => emailer.sendToEmail(template, toEmail, dataWithSubject));
70
- }
71
- if (typeof emailer.send === 'function') {
72
- if (subject) {
73
- attempts.push(() => emailer.send(template, toEmail, subject, dataWithSubject));
74
- attempts.push(() => emailer.send(template, toEmail, dataWithSubject, subject));
61
+ if (typeof emailer.sendToEmail === 'function') {
62
+ await emailer.sendToEmail(template, toEmail, subject, data);
63
+ return;
75
64
  }
76
- attempts.push(() => emailer.send(template, toEmail, dataWithSubject));
77
- }
78
-
79
- let lastErr = null;
80
- for (const fn of attempts) {
81
- try {
82
- await fn();
65
+ if (typeof emailer.send === 'function') {
66
+ if (emailer.send.length >= 4) {
67
+ await emailer.send(template, toEmail, subject, data);
68
+ return;
69
+ }
70
+ if (emailer.send.length === 3) {
71
+ await emailer.send(template, toEmail, data);
83
72
  return;
84
- } catch (e) {
85
- lastErr = e;
86
73
  }
74
+ await emailer.send(template, toEmail, subject, data);
87
75
  }
88
- if (lastErr) throw lastErr;
89
76
  } catch (err) {
90
77
  // eslint-disable-next-line no-console
91
78
  console.warn('[calendar-onekite] Failed to send email (scheduler)', { template, toEmail, err: String(err && err.message || err) });
package/library.js CHANGED
@@ -19,120 +19,9 @@ const Plugin = {};
19
19
  const isFn = (fn) => typeof fn === 'function';
20
20
  const mw = (...fns) => fns.filter(isFn);
21
21
 
22
- function escapeHtml(str) {
23
- return String(str)
24
- .replace(/&/g, '&')
25
- .replace(/</g, '&lt;')
26
- .replace(/>/g, '&gt;')
27
- .replace(/"/g, '&quot;')
28
- .replace(/'/g, '&#39;');
29
- }
30
-
31
- // --- Widgets ---------------------------------------------------------------
32
- // Docs: https://docs.nodebb.org/development/widgets/
33
- Plugin.defineWidgets = async function (widgets, callback) {
34
- try {
35
- widgets = widgets || [];
36
- widgets.push({
37
- widget: 'calendar-onekite-mini',
38
- name: 'Calendrier OneKite (mini)',
39
- description: 'Mini calendrier (mois en cours) avec indicateurs de réservations/évènements.',
40
- // Widget settings form (ACP Widgets editor)
41
- content: [
42
- '<div class="form-group">',
43
- ' <label>Titre</label>',
44
- ' <input type="text" class="form-control" name="title" placeholder="Calendrier" />',
45
- '</div>',
46
- '<div class="form-group">',
47
- ' <label>Afficher les évènements</label>',
48
- ' <select class="form-control" name="showSpecial">',
49
- ' <option value="1">Oui</option>',
50
- ' <option value="0">Non</option>',
51
- ' </select>',
52
- '</div>',
53
- ].join(''),
54
- });
55
-
56
- if (typeof callback === 'function') {
57
- return callback(null, widgets);
58
- }
59
- return widgets;
60
- } catch (err) {
61
- if (typeof callback === 'function') {
62
- return callback(err);
63
- }
64
- throw err;
65
- }
66
- };
67
-
68
- Plugin.renderMiniWidget = async function (hookData, callback) {
69
- try {
70
- const widget = hookData.widget || {};
71
- const data = widget.data || {};
72
- const title = (data.title || 'Calendrier').toString();
73
- const showSpecial = String(data.showSpecial ?? '1') !== '0';
74
-
75
- const widgetId = `calendar-onekite-mini-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
76
-
77
- // Note: We render client-side to avoid extra server-side queries. The widget fetches
78
- // events via the existing public API endpoint.
79
- widget.html = `
80
- <div class="calendar-onekite-widget" id="${widgetId}" data-show-special="${showSpecial ? '1' : '0'}">
81
- <div class="calendar-onekite-widget__header">
82
- <strong>${escapeHtml(title)}</strong>
83
- <a class="calendar-onekite-widget__link" href="/calendar">Ouvrir</a>
84
- </div>
85
- <div class="calendar-onekite-widget__month"></div>
86
- <div class="calendar-onekite-widget__legend">
87
- <span class="calendar-onekite-dot calendar-onekite-dot--pending"></span> en attente
88
- <span class="calendar-onekite-dot calendar-onekite-dot--paid"></span> payée
89
- ${showSpecial ? '<span class="calendar-onekite-dot calendar-onekite-dot--special"></span> évènement' : ''}
90
- </div>
91
- </div>
92
- <style>
93
- .calendar-onekite-widget{border:1px solid rgba(0,0,0,.1);border-radius:8px;padding:10px}
94
- .calendar-onekite-widget__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
95
- .calendar-onekite-widget__link{font-size:12px}
96
- .calendar-onekite-widget__grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px}
97
- .calendar-onekite-widget__cell{position:relative;aspect-ratio:1/1;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:12px;user-select:none}
98
- .calendar-onekite-widget__cell--muted{opacity:.35}
99
- .calendar-onekite-widget__cell--today{outline:2px solid rgba(0,0,0,.2)}
100
- .calendar-onekite-badges{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);display:flex;gap:2px}
101
- .calendar-onekite-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin:0 4px 0 10px;vertical-align:middle}
102
- .calendar-onekite-dot--pending{background:#0d6efd}
103
- .calendar-onekite-dot--paid{background:#198754}
104
- .calendar-onekite-dot--special{background:#fd7e14}
105
- .calendar-onekite-badge{width:6px;height:6px;border-radius:50%}
106
- .calendar-onekite-badge--pending{background:#0d6efd}
107
- .calendar-onekite-badge--paid{background:#198754}
108
- .calendar-onekite-badge--special{background:#fd7e14}
109
- .calendar-onekite-widget__legend{margin-top:8px;font-size:12px;opacity:.8}
110
- </style>
111
- `;
112
-
113
- hookData.widget = widget;
114
- if (typeof callback === 'function') {
115
- return callback(null, hookData);
116
- }
117
- return hookData;
118
- } catch (err) {
119
- if (typeof callback === 'function') {
120
- return callback(err);
121
- }
122
- throw err;
123
- }
124
- };
125
-
126
22
  Plugin.init = async function (params) {
127
23
  const { router, middleware } = params;
128
24
 
129
- // Parse JSON bodies for our API routes.
130
- // NodeBB core parses bodies for many built-in routes, but custom plugin
131
- // routes are not guaranteed to have body parsing enabled across versions.
132
- const jsonBody = bodyParser.json({
133
- type: ['application/json', 'application/*+json'],
134
- });
135
-
136
25
  // Build middleware arrays safely and always spread them into Express route methods.
137
26
  // Express will throw if any callback is undefined, so we filter strictly.
138
27
  const publicExpose = mw(middleware && middleware.exposeUid);
@@ -180,12 +69,12 @@ Plugin.init = async function (params) {
180
69
  });
181
70
 
182
71
  ['/api/v3/plugins/calendar-onekite/reservations', '/api/plugins/calendar-onekite/reservations'].forEach((p) => {
183
- router.post(p, jsonBody, ...publicAuth, api.createReservation);
72
+ router.post(p, ...publicAuth, api.createReservation);
184
73
  });
185
74
 
186
75
  // Special events (other colour) - created/deleted by configured groups
187
76
  ['/api/v3/plugins/calendar-onekite/special-events', '/api/plugins/calendar-onekite/special-events'].forEach((p) => {
188
- router.post(p, jsonBody, ...publicAuth, api.createSpecialEvent);
77
+ router.post(p, ...publicAuth, api.createSpecialEvent);
189
78
  });
190
79
  ['/api/v3/plugins/calendar-onekite/special-events/:eid', '/api/plugins/calendar-onekite/special-events/:eid'].forEach((p) => {
191
80
  router.delete(p, ...publicAuth, api.deleteSpecialEvent);
@@ -193,14 +82,14 @@ Plugin.init = async function (params) {
193
82
 
194
83
  // Validator actions from the calendar popup (requires login + validatorGroups)
195
84
  ['/api/v3/plugins/calendar-onekite/reservations/:rid/approve', '/api/plugins/calendar-onekite/reservations/:rid/approve'].forEach((p) => {
196
- router.put(p, jsonBody, ...publicAuth, api.approveReservation);
85
+ router.put(p, ...publicAuth, api.approveReservation);
197
86
  });
198
87
  ['/api/v3/plugins/calendar-onekite/reservations/:rid/refuse', '/api/plugins/calendar-onekite/reservations/:rid/refuse'].forEach((p) => {
199
- router.put(p, jsonBody, ...publicAuth, api.refuseReservation);
88
+ router.put(p, ...publicAuth, api.refuseReservation);
200
89
  });
201
90
  // Cancellation by requester (or staff): owner can cancel even if not in validator group
202
91
  ['/api/v3/plugins/calendar-onekite/reservations/:rid/cancel', '/api/plugins/calendar-onekite/reservations/:rid/cancel'].forEach((p) => {
203
- router.put(p, jsonBody, ...publicAuth, api.cancelReservation);
92
+ router.put(p, ...publicAuth, api.cancelReservation);
204
93
  });
205
94
 
206
95
 
@@ -209,21 +98,21 @@ Plugin.init = async function (params) {
209
98
 
210
99
  adminBases.forEach((base) => {
211
100
  router.get(`${base}/settings`, ...adminMws, admin.getSettings);
212
- router.put(`${base}/settings`, jsonBody, ...adminMws, admin.saveSettings);
101
+ router.put(`${base}/settings`, ...adminMws, admin.saveSettings);
213
102
 
214
103
  router.get(`${base}/pending`, ...adminMws, admin.listPending);
215
- router.put(`${base}/reservations/:rid/approve`, jsonBody, ...adminMws, admin.approveReservation);
216
- router.put(`${base}/reservations/:rid/refuse`, jsonBody, ...adminMws, admin.refuseReservation);
104
+ router.put(`${base}/reservations/:rid/approve`, ...adminMws, admin.approveReservation);
105
+ router.put(`${base}/reservations/:rid/refuse`, ...adminMws, admin.refuseReservation);
217
106
 
218
- router.post(`${base}/purge`, jsonBody, ...adminMws, admin.purgeByYear);
107
+ router.post(`${base}/purge`, ...adminMws, admin.purgeByYear);
219
108
  router.get(`${base}/debug`, ...adminMws, admin.debugHelloAsso);
220
109
  // Accounting / exports
221
110
  router.get(`${base}/accounting`, ...adminMws, admin.getAccounting);
222
111
  router.get(`${base}/accounting.csv`, ...adminMws, admin.exportAccountingCsv);
223
- router.post(`${base}/accounting/purge`, jsonBody, ...adminMws, admin.purgeAccounting);
112
+ router.post(`${base}/accounting/purge`, ...adminMws, admin.purgeAccounting);
224
113
 
225
114
  // Purge special events by year
226
- router.post(`${base}/special-events/purge`, jsonBody, ...adminMws, admin.purgeSpecialEventsByYear);
115
+ router.post(`${base}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
227
116
  });
228
117
 
229
118
  // HelloAsso callback endpoint (hardened)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.1.82",
3
+ "version": "11.1.83",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -8,4 +8,4 @@
8
8
  "node": ">=18"
9
9
  },
10
10
  "dependencies": {}
11
- }
11
+ }
package/plugin.json CHANGED
@@ -8,16 +8,6 @@
8
8
  "hook": "static:app.load",
9
9
  "method": "init"
10
10
  },
11
- {
12
- "hook": "filter:widgets.getWidgets",
13
- "method": "defineWidgets",
14
- "callbacked": true
15
- },
16
- {
17
- "hook": "filter:widget.render:calendar-onekite-mini",
18
- "method": "renderMiniWidget",
19
- "callbacked": true
20
- },
21
11
  {
22
12
  "hook": "filter:admin.header.build",
23
13
  "method": "addAdminNavigation"
@@ -27,14 +17,15 @@
27
17
  "public": "./public"
28
18
  },
29
19
  "templates": "./templates",
20
+ "modules": {
21
+ "../admin/plugins/calendar-onekite.js": "./public/admin.js",
22
+ "admin/plugins/calendar-onekite": "./public/admin.js"
23
+ },
30
24
  "scripts": [
31
25
  "public/client.js"
32
26
  ],
33
27
  "acpScripts": [
34
- "public/admin/plugins/calendar-onekite.js"
28
+ "public/admin.js"
35
29
  ],
36
- "version": "1.0.59",
37
- "modules": {
38
- "./plugins/calendar-onekite": "./public/admin/plugins/calendar-onekite.js"
39
- }
30
+ "version": "1.0.46"
40
31
  }