@acms-subir/core 0.3.57 → 0.3.59
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 +30 -0
- package/cli/migrations.generated.mjs +9 -2
- package/docs/calendar.md +19 -2
- package/docs/google-calendar.md +22 -9
- package/package.json +1 -1
- package/src/lib/booking/service.ts +4 -2
- package/src/lib/calendar/api.ts +7 -7
- package/src/lib/calendar/data.ts +8 -6
- package/src/lib/calendar/google/inbound.ts +245 -45
- package/src/lib/calendar/google/map.ts +54 -14
- package/src/lib/calendar/google/match.ts +3 -2
- package/src/lib/calendar/google/outbound.ts +71 -25
- package/src/lib/calendar/ics-write.ts +3 -2
- package/src/lib/calendar/request-mail.ts +2 -1
- package/src/lib/calendar/request-page.ts +2 -1
- package/src/lib/calendar/request-service.ts +2 -2
- package/src/lib/display/board.ts +6 -5
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +5 -5
- package/src/lib/mobile/search.ts +2 -1
- package/src/lib/repo/migrations.ts +8 -1
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +10 -0
- package/src/lib/sections.ts +2 -1
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/members/events/index.astro +3 -1
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
// title. Only the fields the sync owns are patched; guests are never added
|
|
9
9
|
// and Google never emails anyone (sendUpdates=none).
|
|
10
10
|
//
|
|
11
|
+
// A series changed "from this date on" (CalEvent.segments) is one Google
|
|
12
|
+
// series per part, as Google does for "this and following": the first part's
|
|
13
|
+
// rule stops where the next takes over, and each later part is its own
|
|
14
|
+
// series (a segment pair, its hidden link naming its `from`). A cancelled
|
|
15
|
+
// part has no Google copy (its dates are removed in Google, like a cancelled
|
|
16
|
+
// date's instance), and a part the site took back is removed from Google.
|
|
17
|
+
//
|
|
11
18
|
// A change that already matches what the pair agreed is never sent, which is
|
|
12
19
|
// what stops loops: the site's write of a change that came from Google leaves
|
|
13
20
|
// nothing to send back to that link (only to the event's other links).
|
|
14
21
|
import { hasTag, type CalEvent } from '../../schema';
|
|
22
|
+
import { seriesParts, type SeriesPart } from '../events';
|
|
15
23
|
import type { SyncContext } from './context';
|
|
16
24
|
import { entry } from './context';
|
|
17
25
|
import { GoogleError } from './client';
|
|
18
|
-
import { applyGoogleChange } from './inbound';
|
|
26
|
+
import { applyGoogleChange, applySegmentChange } from './inbound';
|
|
19
27
|
import {
|
|
20
28
|
BOOKING_TAG, NONE, changed, dateHash, fieldHashes, fieldWord, googleBody, googleFields, googleIdFor, googlePatch, inWindow, instanceBody,
|
|
21
29
|
instanceState, keyStart, linkCarries, originalStart, siteFields, windowStart, type DateState, type Fields,
|
|
@@ -96,8 +104,43 @@ async function pushToLink(ctx: SyncContext, link: GcalLink, e: CalEvent | null,
|
|
|
96
104
|
// deleted from this Google calendar while the event stays in its other copies: it stays out
|
|
97
105
|
if (main.leftOut) return;
|
|
98
106
|
// deleted in Google (and so cancelled here, Q6): made again only once the site brings it back
|
|
99
|
-
if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main);
|
|
100
|
-
await updateCopy(ctx, link, e, main
|
|
107
|
+
if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main); }
|
|
108
|
+
else await updateCopy(ctx, link, e, main);
|
|
109
|
+
if (e.rrule || mine.some((i) => i.kind === 'segment')) {
|
|
110
|
+
// as they are now (taking Google's changes may have written the event)
|
|
111
|
+
const fresh = await ctx.repo.getEvent(e.id);
|
|
112
|
+
if (fresh) await pushParts(ctx, link, fresh, (await ctx.repo.gcal.itemsForEvent(e.id)).filter((i) => i.linkId === link.id));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The later parts of a series changed from a date on: one Google series each. */
|
|
117
|
+
async function pushParts(ctx: SyncContext, link: GcalLink, e: CalEvent, mine: GcalItem[]) {
|
|
118
|
+
const parts = e.rrule ? seriesParts(e).slice(1) : [];
|
|
119
|
+
const segments = mine.filter((i) => i.kind === 'segment');
|
|
120
|
+
for (const p of parts) {
|
|
121
|
+
const item = segments.find((i) => i.segmentFrom === p.from);
|
|
122
|
+
if (p.cancelled) {
|
|
123
|
+
// its dates are cancelled: removed in Google (kept as the pair, so restoring the part makes it again)
|
|
124
|
+
if (item && !item.gone) {
|
|
125
|
+
await ctx.client.deleteEvent(link.googleId, item.googleId);
|
|
126
|
+
await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, leftOut: false, syncedAt: ctx.now });
|
|
127
|
+
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `Dates from ${p.from.replace('T', ' ')} on cancelled on the site: removed from ${link.googleName}.` })], ctx.now);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!item) await createCopy(ctx, link, e, undefined, p);
|
|
132
|
+
else if (item.gone) await createCopy(ctx, link, e, item, p);
|
|
133
|
+
else await updateCopy(ctx, link, e, item, p);
|
|
134
|
+
}
|
|
135
|
+
for (const item of segments) {
|
|
136
|
+
if (parts.some((p) => p.from === item.segmentFrom)) continue;
|
|
137
|
+
// a split Google had at the first match, never on the site: Google keeps it as it is
|
|
138
|
+
if (!Object.keys(item.site).length) continue;
|
|
139
|
+
// the site took that change back ("Undo"): its Google series goes
|
|
140
|
+
if (!item.gone) await ctx.client.deleteEvent(link.googleId, item.googleId);
|
|
141
|
+
await ctx.repo.gcal.deleteItem(link.id, item.googleId);
|
|
142
|
+
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `The change from ${item.segmentFrom.replace('T', ' ')} on was undone on the site: its series was removed from ${link.googleName}.` })], ctx.now);
|
|
143
|
+
}
|
|
101
144
|
}
|
|
102
145
|
|
|
103
146
|
/** Google won't let the account change this event there: listed once, and the site's change counts as seen (no retries). */
|
|
@@ -115,7 +158,7 @@ async function unpair(ctx: SyncContext, link: GcalLink, item: GcalItem, e: CalEv
|
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
async function removeCopy(ctx: SyncContext, link: GcalLink, item: GcalItem, all: GcalItem[], e: CalEvent | null) {
|
|
118
|
-
// the event's later parts
|
|
161
|
+
// the event's later parts (their own Google series) go with it
|
|
119
162
|
for (const i of all) {
|
|
120
163
|
if (!i.gone) await ctx.client.deleteEvent(link.googleId, i.googleId);
|
|
121
164
|
await ctx.repo.gcal.deleteItem(link.id, i.googleId);
|
|
@@ -151,18 +194,19 @@ function written(patch: Record<string, unknown>, rules: GcalLink['rules']): stri
|
|
|
151
194
|
}
|
|
152
195
|
const masterHashes = (h: Hashes) => Object.fromEntries(Object.entries(h).filter(([k]) => !k.startsWith('date:')));
|
|
153
196
|
|
|
154
|
-
/** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates. */
|
|
155
|
-
export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem) {
|
|
156
|
-
const f = siteFields(e, link, ctx.map);
|
|
157
|
-
const
|
|
158
|
-
const
|
|
197
|
+
/** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates; `part`: a later part's own series. */
|
|
198
|
+
export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem, part?: SeriesPart) {
|
|
199
|
+
const f = siteFields(e, link, ctx.map, part);
|
|
200
|
+
const segment = part?.from ?? '';
|
|
201
|
+
const body = googleBody(e, f, link, ctx.map, { desc: true, segment });
|
|
202
|
+
const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0, segment);
|
|
159
203
|
let g: GoogleEvent;
|
|
160
204
|
try { g = await ctx.client.insertEvent(link.googleId, id ? { id, ...body } : body); }
|
|
161
205
|
catch (err) {
|
|
162
206
|
if (!(err instanceof GoogleError && err.code === 'conflict' && id)) throw err;
|
|
163
207
|
// that id is taken: our own copy from a run that stopped before saving the pair, or one Google deleted (ids stay taken)
|
|
164
208
|
const found = await ctx.client.getEvent(link.googleId, id).catch(() => null);
|
|
165
|
-
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id
|
|
209
|
+
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id && (found.extendedProperties.private.acmsSegment ?? '') === segment
|
|
166
210
|
? await ctx.client.patchEvent(link.googleId, id, body)
|
|
167
211
|
: await ctx.client.insertEvent(link.googleId, body);
|
|
168
212
|
}
|
|
@@ -177,12 +221,13 @@ export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent,
|
|
|
177
221
|
else google[`date:${key}`] = dateHash(after);
|
|
178
222
|
}
|
|
179
223
|
const item: GcalItem = {
|
|
180
|
-
linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: e.rrule ? 'series' : 'single', segmentFrom:
|
|
224
|
+
linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: segment ? 'segment' : e.rrule ? 'series' : 'single', segmentFrom: segment, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
|
|
181
225
|
site: fieldHashes(f), google, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: previous?.createdAt ?? ctx.now,
|
|
182
226
|
};
|
|
183
227
|
if (previous) await ctx.repo.gcal.replaceItem(previous.googleId, item); else await ctx.repo.gcal.putItem(item);
|
|
184
228
|
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'created',
|
|
185
|
-
summary:
|
|
229
|
+
summary: segment ? `From ${segment.replace('T', ' ')} on, a series of its own in ${link.googleName} (“this and following”).`
|
|
230
|
+
: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
|
|
186
231
|
}
|
|
187
232
|
|
|
188
233
|
/** Sets one date of the Google series; returns its state there afterwards (null: Google has no such date). */
|
|
@@ -207,9 +252,11 @@ async function writeDate(ctx: SyncContext, link: GcalLink, masterId: string, key
|
|
|
207
252
|
}
|
|
208
253
|
}
|
|
209
254
|
|
|
210
|
-
/** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field). */
|
|
211
|
-
async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem,
|
|
212
|
-
|
|
255
|
+
/** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field); `part`: a later part's series. */
|
|
256
|
+
async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem, part?: SeriesPart) {
|
|
257
|
+
const partFrom = part?.from;
|
|
258
|
+
const partNow = (ev: CalEvent) => (partFrom === undefined ? undefined : seriesParts(ev).find((p) => p.from === partFrom));
|
|
259
|
+
let f = siteFields(e, link, ctx.map, part);
|
|
213
260
|
if (!pendingSite(fieldHashes(f), item, link, {}).ch.length) return;
|
|
214
261
|
let g: GoogleEvent | null = await ctx.client.getEvent(link.googleId, item.googleId).catch((err) => {
|
|
215
262
|
if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null;
|
|
@@ -223,10 +270,12 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
223
270
|
let gf = googleFields(g, link, ctx.settings);
|
|
224
271
|
// Google changed fields too: take those first (the site wins where both changed, Q4), then send what's left
|
|
225
272
|
if (link.direction === 'both' && gf && changed(masterHashes(fieldHashes(gf)), masterHashes(item.google)).length) {
|
|
226
|
-
await applyGoogleChange(ctx, link, item, g);
|
|
273
|
+
await (part ? applySegmentChange(ctx, link, item, g) : applyGoogleChange(ctx, link, item, g));
|
|
227
274
|
const [again, fresh] = await Promise.all([ctx.repo.gcal.getItem(link.id, item.googleId), ctx.repo.getEvent(e.id)]);
|
|
228
275
|
if (!again || !fresh || again.gone) return;
|
|
229
|
-
item = again; e = fresh;
|
|
276
|
+
item = again; e = fresh;
|
|
277
|
+
if (part) { part = partNow(e); if (!part || part.cancelled) return; }
|
|
278
|
+
f = siteFields(e, link, ctx.map, part);
|
|
230
279
|
}
|
|
231
280
|
const cur = fieldHashes(f);
|
|
232
281
|
const pending = pendingSite(cur, item, link, gf ? fieldHashes(gf) : {});
|
|
@@ -239,10 +288,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
239
288
|
descGoogle = false;
|
|
240
289
|
notes.push(entry(link, item, e.title, { direction: 'info', action: 'description', summary: 'Description edited on the site: from now on Google gets the site’s text (Q8).' }));
|
|
241
290
|
}
|
|
242
|
-
|
|
243
|
-
const held: string[] = split ? ch.filter((n) => n === 'when' || n === 'repeat') : [];
|
|
244
|
-
if (held.length) notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${held.map(fieldWord).join(' and ')} changed on the site, but Google split this series (“this and following”), so ${held.length > 1 ? 'they weren’t' : 'it wasn’t'} sent. Change the later part in Google, or both by hand.` }));
|
|
245
|
-
const fields = ch.filter((n) => !n.startsWith('date:') && !held.includes(n) && !((n === 'desc' || n === 'link') && descGoogle));
|
|
291
|
+
const fields = ch.filter((n) => !n.startsWith('date:') && !((n === 'desc' || n === 'link') && descGoogle));
|
|
246
292
|
const patch = fields.length ? googlePatch(e, f, link, ctx.map, fields, { desc: !descGoogle, googleSummary: g.summary ?? '' }) : {};
|
|
247
293
|
if (Object.keys(patch).length) g = await ctx.client.patchEvent(link.googleId, item.googleId, patch);
|
|
248
294
|
gf = googleFields(g, link, ctx.settings) ?? gf;
|
|
@@ -252,7 +298,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
252
298
|
|
|
253
299
|
// single dates, once the series itself is right (read in the site event's zone, where its keys are)
|
|
254
300
|
const inSiteZone = googleFields(g, link, ctx.settings, f.when.tz);
|
|
255
|
-
if (
|
|
301
|
+
if (inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
|
|
256
302
|
const key = n.slice(5);
|
|
257
303
|
if (keyStart(key, f.when, ctx.settings) <= ctx.now) continue;
|
|
258
304
|
const want = f.dates[key] ?? 'none';
|
|
@@ -265,8 +311,8 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
|
|
|
265
311
|
for (const n of ch) { if (cur[n] === undefined) delete site[n]; else site[n] = cur[n]; }
|
|
266
312
|
// the clash note stays until the site edits that field again (sending the site's winning value isn't an edit)
|
|
267
313
|
const conflict = !item.conflict || !ch.includes(item.conflict.field) ? item.conflict : item.conflict.sent ? null : { ...item.conflict, sent: true };
|
|
268
|
-
await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: e.rrule ? 'series' : 'single', syncedAt: ctx.now });
|
|
269
|
-
const sent = [...new Set(ch.
|
|
270
|
-
if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')} → ${link.googleName}` }));
|
|
314
|
+
await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: part ? 'segment' : e.rrule ? 'series' : 'single', syncedAt: ctx.now });
|
|
315
|
+
const sent = [...new Set(ch.map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
|
|
316
|
+
if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')}${part ? ` (from ${part.from.replace('T', ' ')} on)` : ''} → ${link.googleName}` }));
|
|
271
317
|
await ctx.repo.gcal.log(notes, ctx.now);
|
|
272
318
|
}
|
|
@@ -93,8 +93,9 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
93
93
|
// A part's rule for the feed: stopped just before the next part's `from`
|
|
94
94
|
// (RFC 5545 UNTIL is inclusive: the instant before it for timed events, the
|
|
95
95
|
// day before for all-day ones). COUNT becomes that UNTIL when the rule would
|
|
96
|
-
// still have dates there; null when the part has none left before it.
|
|
97
|
-
|
|
96
|
+
// still have dates there; null when the part has none left before it. The
|
|
97
|
+
// Google Calendar sync gives each part's Google series the same rule.
|
|
98
|
+
export function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
|
|
98
99
|
const clean = cleanRrule(p.rrule);
|
|
99
100
|
if (!p.until || !clean) return clean;
|
|
100
101
|
const cut = parseWall(p.until);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import { roomName } from '../schema';
|
|
3
4
|
import type { Mail } from '../email/types';
|
|
4
5
|
import { siteLang, siteLocale } from '../dates';
|
|
5
6
|
import { I18N, localeName } from '../i18n';
|
|
@@ -66,7 +67,7 @@ async function personSummary(repo: ContentRepository, q: RequestRecord, settings
|
|
|
66
67
|
const parts = [
|
|
67
68
|
requestWhen(d.start, d.end, settings, locale),
|
|
68
69
|
...(d.repeat === 'weekly' ? [w.weekly] : []),
|
|
69
|
-
d.roomIds.map((id) => rooms.find((r) => r.id === id)
|
|
70
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)).filter((r) => r !== undefined).map((r) => roomName(r, lang)).join(', '),
|
|
70
71
|
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
71
72
|
type ? (lang && type.translations[lang]) || type.label : '',
|
|
72
73
|
].filter(Boolean);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import type { RequestRecord, Settings } from '../schema';
|
|
3
|
+
import { roomName } from '../schema';
|
|
3
4
|
import { requestWhen } from './request-mail';
|
|
4
5
|
import { requestSettings, type NewEventDetails, type RequestInput } from './request-service';
|
|
5
6
|
import { requestStrings } from './request-words';
|
|
@@ -44,7 +45,7 @@ export async function sentSummary(repo: ContentRepository, q: RequestRecord, set
|
|
|
44
45
|
const line = [
|
|
45
46
|
requestWhen(d.start, d.end, settings, locale),
|
|
46
47
|
d.repeat === 'weekly' ? w.weekly : d.repeat === 'other' ? d.repeatText : '',
|
|
47
|
-
d.roomIds.map((id) => rooms.find((r) => r.id === id)
|
|
48
|
+
d.roomIds.map((id) => rooms.find((r) => r.id === id)).filter((r) => r !== undefined).map((r) => roomName(r, lang)).join(', '),
|
|
48
49
|
d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
|
|
49
50
|
type ? (lang && type.translations[lang]) || type.label : '',
|
|
50
51
|
].filter(Boolean).join(' · ');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import { RequestRecord, RoomHold, type CalEvent, type RequestSettings, type Resource, type Settings } from '../schema';
|
|
3
|
+
import { RequestRecord, RoomHold, roomName, type CalEvent, type RequestSettings, type Resource, type Settings } from '../schema';
|
|
4
4
|
import { ApiError, coded } from '../api';
|
|
5
5
|
import { sha256Hex } from '../api-keys';
|
|
6
6
|
import { validAddress } from '../email/config';
|
|
@@ -139,7 +139,7 @@ export async function requestOptions(repo: ContentRepository, locale = '', now =
|
|
|
139
139
|
return {
|
|
140
140
|
open: rq.open,
|
|
141
141
|
types: rq.types.map((t) => ({ id: t.id, label: typeLabel(t, lang) })),
|
|
142
|
-
rooms: (await requestableRooms(repo)).map((r) => ({ slug: r.slug, name: r
|
|
142
|
+
rooms: (await requestableRooms(repo)).map((r) => ({ slug: r.slug, name: roomName(r, lang), color: /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '' })),
|
|
143
143
|
replyTime: words?.replyTime || rq.replyTime,
|
|
144
144
|
notice: words?.notice || rq.notice,
|
|
145
145
|
phone: rq.phone,
|
package/src/lib/display/board.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// bookings of its rooms with the booker's name, the rooms and hosts of every
|
|
7
7
|
// event. Never emails, phone numbers, notes or RSVP lists.
|
|
8
8
|
import type { ContentRepository } from '../repo/types';
|
|
9
|
-
import { roomColor, type Resource, type Settings } from '../schema';
|
|
9
|
+
import { roomColor, roomName, type Resource, type Settings } from '../schema';
|
|
10
10
|
import { collectViewEvents, type ViewEvent } from '../calendar/data';
|
|
11
11
|
import { eventFilterQuery, resolveEventFilter } from '../calendar/filter';
|
|
12
12
|
import { tzParts, zonedToUtc } from '../calendar/ics';
|
|
@@ -53,7 +53,8 @@ export function displayLocale(site: Pick<Settings, 'locale'>, lang: string): str
|
|
|
53
53
|
try { return Intl.DateTimeFormat.supportedLocalesOf([wanted]).length ? wanted : lang; } catch { return lang; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// named in the board's language (its main name without one)
|
|
57
|
+
const asRoom = (lang: string) => (r: Resource): DisplayRoom => ({ id: r.id, slug: r.slug, name: roomName(r, lang), color: roomColor(r) });
|
|
57
58
|
|
|
58
59
|
/** the days a board covers, and (month) its weeks */
|
|
59
60
|
export function boardDays(s: DisplaySettings, today: Iso, siteWeekStart: 0 | 1): { days: Iso[]; weeks: Iso[][] } {
|
|
@@ -115,7 +116,7 @@ export async function loadDisplayBoard(repo: ContentRepository, site: Settings,
|
|
|
115
116
|
});
|
|
116
117
|
// rooms visitors may see: the ones named on public events
|
|
117
118
|
const allowed = resources.filter((r) => req.privateDetails || r.showOnEvents);
|
|
118
|
-
const roomById = new Map(allowed.map((r) => [r.id, asRoom(r)]));
|
|
119
|
+
const roomById = new Map(allowed.map((r) => [r.id, asRoom(lang)(r)]));
|
|
119
120
|
const calById = new Map(shownCalendars.map((c) => [c.id, c]));
|
|
120
121
|
// a person invited without a name has their email as one: never on a TV
|
|
121
122
|
const hostName = req.privateDetails && s.show.includes('host')
|
|
@@ -135,11 +136,11 @@ export async function loadDisplayBoard(repo: ContentRepository, site: Settings,
|
|
|
135
136
|
// every room the board may show (resources of kind "Room", else all)
|
|
136
137
|
let rooms: DisplayRoom[] = [];
|
|
137
138
|
if (s.view === 'rooms') {
|
|
138
|
-
if (filter.roomIds) rooms = filter.rooms.map(asRoom);
|
|
139
|
+
if (filter.roomIds) rooms = filter.rooms.map(asRoom(lang));
|
|
139
140
|
else {
|
|
140
141
|
const sorted = [...allowed].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
141
142
|
const kindRoom = sorted.filter((r) => r.kind.trim().toLowerCase() === 'room');
|
|
142
|
-
rooms = (kindRoom.length ? kindRoom : sorted).map(asRoom);
|
|
143
|
+
rooms = (kindRoom.length ? kindRoom : sorted).map(asRoom(lang));
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
const hours = s.hours === 'auto' ? autoHours(visible, days) : s.hours;
|
|
@@ -177,6 +177,8 @@ export const ResourceInput = z.object({
|
|
|
177
177
|
// '' = no colour; public event data names it only with showOnEvents; requestable answers GET /api/v1/rooms/free
|
|
178
178
|
color: z.union([z.literal(''), z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a colour like #2f6fde.')]).optional(),
|
|
179
179
|
showOnEvents: z.boolean().optional(), requestable: z.boolean().optional(),
|
|
180
|
+
// its name in the site's other languages: { es: { name: 'Salón de jóvenes' } } ('' = the main name)
|
|
181
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().trim().max(120) })).optional(),
|
|
180
182
|
// ICS / Google calendars whose events block this room (fetched like calendar feeds)
|
|
181
183
|
busyFeeds: z.array(z.string().max(2000)).max(10).optional(),
|
|
182
184
|
notifyEmails: z.array(z.string().trim().email().max(200)).max(10).optional(),
|
|
@@ -77,7 +77,7 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
77
77
|
return {
|
|
78
78
|
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
79
79
|
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
80
|
-
...occurrenceDto(o, resources),
|
|
80
|
+
...occurrenceDto(o, resources, locale || siteLang(settings)),
|
|
81
81
|
event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
|
|
82
82
|
})),
|
|
83
83
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
@@ -101,20 +101,20 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
|
|
|
101
101
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public').map(c => [c.id, c]));
|
|
102
102
|
return {
|
|
103
103
|
locale,
|
|
104
|
-
occurrence: o ? occurrenceDto(o, resources) : null,
|
|
104
|
+
occurrence: o ? occurrenceDto(o, resources, locale || siteLang(settings)) : null,
|
|
105
105
|
event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
/** every room by id, for naming the public ones (Resource.showOnEvents) */
|
|
109
109
|
export const roomMap = async (repo: ContentRepository) => new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
110
|
-
// one date: its time, whether it's cancelled, where it moved from, and the public rooms it holds
|
|
111
|
-
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource
|
|
110
|
+
// one date: its time, whether it's cancelled, where it moved from, and the public rooms it holds (named in `locale`)
|
|
111
|
+
export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>, locale?: string | null) {
|
|
112
112
|
const iso = (n: number) => new Date(n).toISOString();
|
|
113
113
|
return { id: `${o.event.id}:${o.start}`, start: iso(o.start), end: iso(o.end), allDay: o.allDay, cancelled: o.cancelled,
|
|
114
114
|
moved: o.moved ? { from: { start: iso(o.moved.from.start), end: iso(o.moved.from.end) }, note: o.moved.note,
|
|
115
115
|
location: o.moved.location ? { name: o.moved.location.name, address: o.moved.location.address, url: o.moved.location.url } : null } : null,
|
|
116
116
|
location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
|
|
117
|
-
rooms: roomsToJson(o.roomIds, resources) };
|
|
117
|
+
rooms: roomsToJson(o.roomIds, resources, false, locale) };
|
|
118
118
|
}
|
|
119
119
|
// e is already localized; publicCalendars limits which calendars are named
|
|
120
120
|
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import { CONTENT } from '../content';
|
|
4
4
|
import { normalizePageLocale } from '../i18n';
|
|
5
5
|
import { siteOrigin } from '../seo';
|
|
6
|
+
import { siteLang } from '../dates';
|
|
6
7
|
import { SEARCH } from '../search-config';
|
|
7
8
|
import { CALENDAR } from '../calendar/config';
|
|
8
9
|
import { eventIsListed, featuredOccurrence } from '../calendar/events';
|
|
@@ -85,7 +86,7 @@ export async function search(repo: ContentRepository, url: URL) {
|
|
|
85
86
|
return {
|
|
86
87
|
snippet: plainSnippet(snippet),
|
|
87
88
|
// the next occurrence, or the latest one when the event is over
|
|
88
|
-
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources)) : null,
|
|
89
|
+
occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources, locale || siteLang(settings))) : null,
|
|
89
90
|
event: publicEventDto(e, origin, listed, settings, locale, resources),
|
|
90
91
|
};
|
|
91
92
|
}),
|
|
@@ -741,6 +741,13 @@ export const MIGRATIONS: Migration[] = [
|
|
|
741
741
|
// a series changed from one date on (CalEvent.segments): a column on the
|
|
742
742
|
// row, so the triggers on events see it, like moved_dates
|
|
743
743
|
SEGMENTS_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'])[2]),
|
|
744
|
+
// a room's name in other languages (Resource.translations): public, so a
|
|
745
|
+
// change refreshes cached pages and API answers
|
|
746
|
+
{ version: 71, statements: [
|
|
747
|
+
`ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
748
|
+
`DROP TRIGGER content_version_resources_update`,
|
|
749
|
+
contentVersionTriggers('resources', [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'])[2],
|
|
750
|
+
] },
|
|
744
751
|
];
|
|
745
752
|
|
|
746
753
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
|
@@ -748,7 +755,7 @@ export const CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
|
748
755
|
...CONTENT_VERSION_COLUMNS,
|
|
749
756
|
settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
|
|
750
757
|
events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
|
|
751
|
-
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
|
|
758
|
+
resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events', 'translations'],
|
|
752
759
|
announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
|
|
753
760
|
};
|
|
754
761
|
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -189,6 +189,11 @@ function rowToEvent(r: Row): CalEvent {
|
|
|
189
189
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
|
+
// a room's names in other languages; a bad entry (or a broken column) reads as untranslated
|
|
193
|
+
function roomTranslations(raw: unknown): Resource['translations'] {
|
|
194
|
+
const parsed = Resource.shape.translations.safeParse(json(raw, {}));
|
|
195
|
+
return parsed.success ? parsed.data : {};
|
|
196
|
+
}
|
|
192
197
|
function rowToResource(r: Row): Resource {
|
|
193
198
|
return Resource.parse({
|
|
194
199
|
id: r.id, slug: r.slug, name: r.name, kind: r.kind ?? 'Room', description: r.description ?? '', photo: r.photo ?? '',
|
|
@@ -196,7 +201,7 @@ function rowToResource(r: Row): Resource {
|
|
|
196
201
|
amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
|
|
197
202
|
busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
|
|
198
203
|
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
|
|
199
|
-
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable),
|
|
204
|
+
color: r.color ?? '', showOnEvents: Boolean(r.show_on_events), requestable: Boolean(r.requestable), translations: roomTranslations(r.translations),
|
|
200
205
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
201
206
|
});
|
|
202
207
|
}
|
|
@@ -1160,10 +1165,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1160
1165
|
private resourceInsert(r: Resource): SqlStatement {
|
|
1161
1166
|
return {
|
|
1162
1167
|
sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
|
|
1163
|
-
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1168
|
+
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id, color, show_on_events, requestable, translations) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1164
1169
|
args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1165
1170
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1166
|
-
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0],
|
|
1171
|
+
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, JSON.stringify(r.translations)],
|
|
1167
1172
|
};
|
|
1168
1173
|
}
|
|
1169
1174
|
private linkInsert(l: BookingLink): SqlStatement {
|
|
@@ -1215,10 +1220,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1215
1220
|
await this.driver.run(
|
|
1216
1221
|
`UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
|
|
1217
1222
|
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?,
|
|
1218
|
-
color = ?, show_on_events = ?, requestable = ?, updated_at = ? WHERE id = ?`,
|
|
1223
|
+
color = ?, show_on_events = ?, requestable = ?, translations = ?, updated_at = ? WHERE id = ?`,
|
|
1219
1224
|
[r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
1220
1225
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
1221
|
-
r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, r.updatedAt, id]
|
|
1226
|
+
r.feedToken, r.placeId, roomColor(r), r.showOnEvents ? 1 : 0, r.requestable ? 1 : 0, JSON.stringify(r.translations), r.updatedAt, id]
|
|
1222
1227
|
);
|
|
1223
1228
|
}
|
|
1224
1229
|
async reorderResources(ids: string[]) {
|
package/src/lib/schema.ts
CHANGED
|
@@ -611,6 +611,9 @@ export const Resource = z.object({
|
|
|
611
611
|
// anyone may ask whether it's free at a time (GET /api/v1/rooms/free: free or
|
|
612
612
|
// busy, never what or who)
|
|
613
613
|
requestable: z.boolean().default(false),
|
|
614
|
+
// its name in the site's other languages ({ es: { name: 'Salón de jóvenes' } });
|
|
615
|
+
// roomName() picks one, else `name`. The slug stays the same in every language.
|
|
616
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().max(120).default('') })).default({}),
|
|
614
617
|
// secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
|
|
615
618
|
feedToken: z.string().default(''),
|
|
616
619
|
// the saved place this room is in ('' = none)
|
|
@@ -622,6 +625,13 @@ export const Resource = z.object({
|
|
|
622
625
|
export type Resource = z.infer<typeof Resource>;
|
|
623
626
|
/** A room's colour as #rrggbb, or '' when it has none (anything else stored reads as none). */
|
|
624
627
|
export const roomColor = (r: Pick<Resource, 'color'>) => (/^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '');
|
|
628
|
+
/** A room's name in `locale` ('es', 'es-MX' → its 'es' name), else its main name. */
|
|
629
|
+
export function roomName(r: Pick<Resource, 'name'> & { translations?: Resource['translations'] }, locale?: string | null): string {
|
|
630
|
+
if (!locale || !r.translations) return r.name;
|
|
631
|
+
const want = locale.toLowerCase();
|
|
632
|
+
const t = r.translations[want] ?? r.translations[want.split('-')[0]];
|
|
633
|
+
return t?.name.trim() || r.name;
|
|
634
|
+
}
|
|
625
635
|
|
|
626
636
|
// A saved place (HQ, the park pavilion…): picked in an event's Where field,
|
|
627
637
|
// it copies its details into the event. Rooms can belong to one (resource.placeId).
|
package/src/lib/sections.ts
CHANGED
|
@@ -128,7 +128,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
128
128
|
}
|
|
129
129
|
const tz = settings.timezone || 'UTC';
|
|
130
130
|
const locale = siteLocale(settings);
|
|
131
|
-
|
|
131
|
+
// in the page's language: the dates' rooms come named in it
|
|
132
|
+
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag, locale: lang || undefined })).map((o): EventItem => {
|
|
132
133
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
133
134
|
const f = formatOccurrence(o, tz, locale);
|
|
134
135
|
const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
|
|
@@ -13,6 +13,7 @@ import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
|
|
|
13
13
|
import { eventHolds } from '../../../../lib/booking/holds';
|
|
14
14
|
import { fmt } from '../../../../lib/booking/view';
|
|
15
15
|
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
16
|
+
import { extraLocales, localeName } from '../../../../lib/i18n';
|
|
16
17
|
|
|
17
18
|
// One resource: details, weekly hours, date overrides, calendars that block
|
|
18
19
|
// time, who gets notified, the staff feed, and its booking links.
|
|
@@ -36,6 +37,12 @@ if (Astro.request.method === 'POST') {
|
|
|
36
37
|
const op = String(form.get('op'));
|
|
37
38
|
if (op === 'save') {
|
|
38
39
|
const name = String(form.get('name') || '').trim().slice(0, 120) || resource.name;
|
|
40
|
+
// its name in each of the site's other languages ('' = the main name); languages no longer configured keep theirs
|
|
41
|
+
const translations = { ...resource.translations };
|
|
42
|
+
for (const l of extraLocales()) {
|
|
43
|
+
const n = String(form.get('name_' + l) || '').trim().slice(0, 120);
|
|
44
|
+
if (n) translations[l] = { name: n }; else delete translations[l];
|
|
45
|
+
}
|
|
39
46
|
const base = slugify(String(form.get('slug') || name));
|
|
40
47
|
let slug = base;
|
|
41
48
|
for (let n = 2; ; n++) { const hit = await repo.getResourceBySlug(slug); if (!hit || hit.id === id) break; slug = `${base}-${n}`; }
|
|
@@ -53,6 +60,7 @@ if (Astro.request.method === 'POST') {
|
|
|
53
60
|
timezone: tz && isValidTimeZone(tz) ? tz : '',
|
|
54
61
|
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
55
62
|
showOnCalendar: Boolean(form.get('showOnCalendar')),
|
|
63
|
+
translations,
|
|
56
64
|
color: form.get('noColor') ? '' : roomColor({ color: String(form.get('color') || '') }),
|
|
57
65
|
showOnEvents: Boolean(form.get('showOnEvents')),
|
|
58
66
|
requestable: Boolean(form.get('requestable')),
|
|
@@ -160,6 +168,9 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
160
168
|
<section class="card card-pad">
|
|
161
169
|
<div class="card-head"><b>Details</b></div>
|
|
162
170
|
<label class="field"><span>Name</span><input type="text" name="name" value={resource.name} required /></label>
|
|
171
|
+
{extraLocales().map((l) => (
|
|
172
|
+
<label class="field"><span>Name in {localeName(l)} <em class="hint">optional; shown on {localeName(l)} pages and in the app</em></span><input type="text" name={'name_' + l} lang={l} maxlength="120" value={resource.translations[l]?.name ?? ''} placeholder={resource.name} /></label>
|
|
173
|
+
))}
|
|
163
174
|
<div class="field-row">
|
|
164
175
|
<label class="field"><span>Kind</span><input type="text" name="kind" list="res-kinds" value={resource.kind} /></label>
|
|
165
176
|
<label class="field"><span>Capacity</span><input type="number" name="capacity" min="1" value={resource.capacity ?? ''} /></label>
|
|
@@ -17,6 +17,7 @@ import { mergeEventDraft } from '../../../lib/drafts';
|
|
|
17
17
|
import { fill } from '../../../lib/live/words';
|
|
18
18
|
import { localizeDoc } from '../../../lib/translations';
|
|
19
19
|
import type { CalEvent, RequestRecord } from '../../../lib/schema';
|
|
20
|
+
import { roomName as nameIn } from '../../../lib/schema';
|
|
20
21
|
import type { MoveInput } from '../../../lib/calendar/moves';
|
|
21
22
|
import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
|
|
22
23
|
|
|
@@ -186,7 +187,8 @@ const wallParts = (e: CalEvent, ms: number) => {
|
|
|
186
187
|
const p = tzParts(ms, e.allDay ? siteTz : eventTimeZone(e, settings));
|
|
187
188
|
return { date: `${p.y}-${pad(p.m)}-${pad(p.d)}`, time: `${pad(p.hh)}:${pad(p.mm)}` };
|
|
188
189
|
};
|
|
189
|
-
|
|
190
|
+
// the rooms in the page's language (their main names without one)
|
|
191
|
+
const roomName = new Map(resources.map((r) => [r.id, nameIn(r, lang)]));
|
|
190
192
|
const requestable = resources.filter((r) => r.requestable).map((r) => r.id);
|
|
191
193
|
/** the rooms a host may pick for a date: the event's own (and its dates'), and the requestable ones */
|
|
192
194
|
const roomChoices = (e: CalEvent) => [...new Set([...eventRoomIds(e), ...requestable])].filter((id) => roomName.has(id)).map((id) => ({ id, name: roomName.get(id)! }));
|