@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.
@@ -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); return; }
100
- await updateCopy(ctx, link, e, main, mine.some((i) => i.kind === 'segment'));
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 from a Google split go with it
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 body = googleBody(e, f, link, ctx.map, { desc: true });
158
- const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0);
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: '', etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
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: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
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, split: boolean) {
212
- let f = siteFields(e, link, ctx.map);
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; f = siteFields(e, link, ctx.map);
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
- // a series Google split ("this and following") keeps its time and rule until the site has "from this date on"
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 (!split && inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
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.filter((n) => !held.includes(n)).map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
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
- function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
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)?.name).filter(Boolean).join(', '),
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)?.name).filter(Boolean).join(', '),
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.name, color: /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '' })),
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,
@@ -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
- const asRoom = (r: Resource): DisplayRoom => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) });
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>) {
@@ -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
 
@@ -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).
@@ -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
- return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
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
- const roomName = new Map(resources.map((r) => [r.id, r.name]));
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)! }));