@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 +16 -0
- package/cli/migrations.generated.mjs +9 -2
- package/docs/calendar.md +14 -0
- 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/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
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
|
@@ -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
|
-
|
|
112
|
-
|
|
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
|
}
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -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.
|
|
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) ||
|
|
181
|
-
: `${words.reserved} · ${
|
|
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:
|
|
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:
|
|
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)
|
|
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)! }));
|