@acms-subir/core 0.3.57 → 0.3.58

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.58
4
+
5
+ ### Patch Changes
6
+
7
+ - c26c7c1: Rooms can have a name in each of the site's other languages. Admin → Calendar → Rooms → a room shows **Name in Español** (one field per extra language) under its name, and the editor API takes `translations: { es: { name } }`. Each place that names a room uses the name in its language, and falls back to the main name:
8
+
9
+ - public event data: `rooms` in `/api/v1/events`, `/api/v1/events/:slug` and search, following `?locale=` (else the site's main language);
10
+ - calendar views and cards, including "Reserved" blocks, and section event items on Spanish pages;
11
+ - the `/request` form and `GET /api/v1/requests/options?locale=`;
12
+ - the TV display (its room columns and legend);
13
+ - the hosts' My events;
14
+ - request emails and the request status page, in the requester's language;
15
+ - booking emails, in the booker's language.
16
+
17
+ The slug stays the same in every language. Staff screens, staff emails and `GET /api/v1/rooms/free` keep the main name. `roomName(room, locale)` in `lib/schema.ts` picks the name, and `es-MX` uses the `es` name. Migration 71 adds `resources.translations` (`'{}'` for existing rooms); changing it refreshes cached pages and API answers. Backups and environment sync carry it.
18
+
3
19
  ## 0.3.57
4
20
 
5
21
  ### Patch Changes
@@ -1363,13 +1363,20 @@ var MIGRATIONS = [
1363
1363
  GCAL_MIGRATION,
1364
1364
  // a series changed from one date on (CalEvent.segments): a column on the
1365
1365
  // row, so the triggers on events see it, like moved_dates
1366
- SEGMENTS_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"])[2])
1366
+ SEGMENTS_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"])[2]),
1367
+ // a room's name in other languages (Resource.translations): public, so a
1368
+ // change refreshes cached pages and API answers
1369
+ { version: 71, statements: [
1370
+ `ALTER TABLE resources ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
1371
+ `DROP TRIGGER content_version_resources_update`,
1372
+ contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"])[2]
1373
+ ] }
1367
1374
  ];
1368
1375
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1369
1376
  ...CONTENT_VERSION_COLUMNS,
1370
1377
  settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1371
1378
  events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates", "segments"],
1372
- resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
1379
+ resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events", "translations"],
1373
1380
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
1374
1381
  };
1375
1382
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
package/docs/calendar.md CHANGED
@@ -283,6 +283,20 @@ Each room also has:
283
283
  names the room. When it's off, only staff see that an event uses the room.
284
284
  - **Anyone can check if it’s free** (`requestable`, off by default): the room answers
285
285
  the public free/busy check below.
286
+ - **Name in Español** (and each of the site's other languages; `translations: { es: {
287
+ name } }`): the room's name on pages, app answers, TV screens and emails in that
288
+ language. Without one, the main name shows. The slug stays the same in every
289
+ language. Site code reads it with `roomName(room, locale)` from the schema.
290
+
291
+ Where a room's name follows the language:
292
+ - public event data (`rooms`, with `?locale=` in the app API);
293
+ - calendar views, cards and section items;
294
+ - the request form and `GET /api/v1/requests/options`;
295
+ - the TV display;
296
+ - My events;
297
+ - request emails and booking emails, in the person's language.
298
+
299
+ The admin and staff emails use the main name.
286
300
 
287
301
  ### Rooms in event data
288
302
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.57",
3
+ "version": "0.3.58",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -5,6 +5,7 @@ import { collectResponse, displayAnswers } from '../forms/service';
5
5
  // calendar immediately); the rest wait as "pending" for staff.
6
6
  import type { ContentRepository } from '../repo/types';
7
7
  import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
8
+ import { roomName } from '../schema';
8
9
  import { loadFeed, expandVEvents } from '../calendar/ics';
9
10
  import { emitWebhook } from '../webhooks';
10
11
  import { parseFrom } from '../email/config';
@@ -108,8 +109,9 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
108
109
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
109
110
  return {
110
111
  resource, link, event: null as CalEvent | null, tz,
111
- title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
112
- location: resource?.name ?? '',
112
+ // the room in the booking's language (its main name without one)
113
+ title: [resource && roomName(resource, locale), link?.name].filter(Boolean).join(' · ') || 'Booking',
114
+ location: resource ? roomName(resource, locale) : '',
113
115
  when: whenText(b, tz, settings, link?.mode === 'day'),
114
116
  };
115
117
  }
@@ -3,7 +3,7 @@
3
3
  // everywhere. Drafts, internal ids of private calendars, staging data and
4
4
  // private custom fields (unless a staff caller asks for them) never appear
5
5
  // here. Rooms are named only when set to show on public events (or for staff).
6
- import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
6
+ import { roomColor, roomName, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
  import { localizeForm } from '../forms/localize';
@@ -67,11 +67,11 @@ export function movedDatesToJson(e: Pick<CalEvent, 'movedDates'>, includePrivate
67
67
  return Object.fromEntries(Object.entries(e.movedDates ?? {}).map(([k, m]) => [k, includePrivate ? m : { start: m.start, end: m.end, note: m.note }]));
68
68
  }
69
69
 
70
- /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
71
- export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
70
+ /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff); named in `locale` when they have a name in it. Unknown ids are skipped. */
71
+ export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false, locale?: string | null): RoomJson[] {
72
72
  if (!resources) return [];
73
73
  return ids.map((id) => resources.get(id)).filter((r): r is Resource => Boolean(r) && (includePrivate || r!.showOnEvents))
74
- .map((r) => ({ slug: r.slug, name: r.name, color: roomColor(r) || null }));
74
+ .map((r) => ({ slug: r.slug, name: roomName(r, locale), color: roomColor(r) || null }));
75
75
  }
76
76
 
77
77
  const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
@@ -84,7 +84,7 @@ const untilDate = (locale: string) => (ymd: string) => {
84
84
  };
85
85
 
86
86
  // resources: every room by id (rooms stay [] without it); locale: the
87
- // language of the repeat line ("Cada semana el sábado"; English without it)
87
+ // language of the repeat line and the room names ("Cada semana el sábado"; English without it)
88
88
  export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource>; locale?: string } = {}): EventJson {
89
89
  return {
90
90
  id: e.id,
@@ -106,7 +106,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
106
106
  segments: seriesParts(e).slice(1).map((p) => ({
107
107
  from: p.from, start: p.start, end: p.end, rrule: p.rrule, repeats: describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale), cancelled: p.cancelled, note: p.note,
108
108
  location: p.location ? { name: p.location.name, address: p.location.address, url: p.location.url } : null,
109
- rooms: roomsToJson(p.roomIds, opts.resources, opts.includePrivate),
109
+ rooms: roomsToJson(p.roomIds, opts.resources, opts.includePrivate, opts.locale),
110
110
  })), exdates: e.exdates,
111
111
  cancelledDates: e.cancelledDates ?? [],
112
112
  movedDates: movedDatesToJson(e, opts.includePrivate),
@@ -121,7 +121,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
121
121
  .map((id) => calendars.get(id))
122
122
  .filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
123
123
  .map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
124
- rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate),
124
+ rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate, opts.locale),
125
125
  fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
126
126
  updatedAt: new Date(e.updatedAt).toISOString(),
127
127
  };
@@ -7,7 +7,7 @@
7
7
  // details, lib/display: published events only, private calendars when it
8
8
  // names them, bookings with the booker's name, never emails, phones or notes).
9
9
  import type { ContentRepository } from '../repo/types';
10
- import { roomColor, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
10
+ import { roomColor, roomName, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
11
11
  import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
12
12
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
13
13
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -107,7 +107,7 @@ export async function collectViewEvents(
107
107
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
108
108
  // staff views and screens with private details name every room
109
109
  .filter((r): r is Resource => Boolean(r) && (scope.audience !== 'public' || r!.showOnEvents))
110
- .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
110
+ .map((r) => ({ id: r.id, slug: r.slug, name: roomName(r, scope.locale), color: roomColor(r) }));
111
111
  const words = eventStrings(scope.locale).calendar;
112
112
  for (const o of expandEvents(natives, settings, win)) {
113
113
  // rooms are per date (a moved date can change them)
@@ -176,14 +176,16 @@ export async function collectViewEvents(
176
176
  const r = byId.get(b.resourceId);
177
177
  if (!r) continue;
178
178
  const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
179
+ // the room in the view's language (its main name without one)
180
+ const room = roomName(r, scope.locale);
179
181
  const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
180
- : scope.audience === 'screen' ? [linkName.get(b.linkId) || r.name, b.name].filter(Boolean).join(' · ')
181
- : `${words.reserved} · ${r.name}`;
182
+ : scope.audience === 'screen' ? [linkName.get(b.linkId) || room, b.name].filter(Boolean).join(' · ')
183
+ : `${words.reserved} · ${room}`;
182
184
  events.push({
183
185
  start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
184
- title, location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
186
+ title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
185
187
  url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
186
- rooms: [{ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }],
188
+ rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
187
189
  });
188
190
  }
189
191
  }
@@ -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)! }));