@acms-subir/core 0.3.34 → 0.3.36
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 +29 -0
- package/cli/migrations.generated.mjs +7 -1
- package/docs/mobile.md +9 -6
- package/docs/theme-authoring.md +5 -4
- package/integration.mjs +3 -1
- package/mobile-template/client/AGENTS.md +2 -1
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
- package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/events/recipe.json +6 -3
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
- package/mobile-template/recipes/rsvp/recipe.json +15 -0
- package/package.json +1 -1
- package/src/components/Announcements.tsx +14 -1
- package/src/components/LiveCard.astro +23 -10
- package/src/components/MobileApp.tsx +54 -4
- package/src/layouts/Site.astro +45 -38
- package/src/lib/announcements/api.ts +8 -4
- package/src/lib/announcements/store.ts +4 -0
- package/src/lib/cache/policy.ts +2 -2
- package/src/lib/calendar/hooks.ts +5 -1
- package/src/lib/legal/app-privacy.ts +5 -3
- package/src/lib/legal/text-en.ts +1 -0
- package/src/lib/legal/text-es.ts +1 -0
- package/src/lib/live/view.ts +28 -24
- package/src/lib/live/words.ts +94 -0
- package/src/lib/mobile/bookings.ts +17 -11
- package/src/lib/mobile/forms.ts +21 -11
- package/src/lib/mobile/manage.ts +2 -0
- package/src/lib/mobile/openapi.ts +8 -3
- package/src/lib/mobile/rsvp.ts +65 -0
- package/src/lib/mobile/schemas.ts +9 -1
- package/src/lib/mobile/site.ts +3 -1
- package/src/lib/push/api.ts +13 -1
- package/src/lib/push/service.ts +211 -24
- package/src/lib/push/store.ts +35 -8
- package/src/lib/push/types.ts +23 -2
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/schema.ts +2 -1
- package/src/lib/search-i18n.ts +41 -4
- package/src/lib/site-chrome.ts +8 -0
- package/src/lib/site-words.ts +62 -0
- package/src/pages/admin/calendar/bookings.astro +3 -1
- package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
- package/src/styles/mobile-app.css +7 -0
- package/src/theme-sdk/index.ts +2 -0
package/src/lib/push/service.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// Push notifications: topics, audiences, the Expo sender pipeline, automatic
|
|
2
|
-
// notices (first publish of a post/event, "starting soon", "we're live"
|
|
2
|
+
// notices (first publish of a post/event, "starting soon", "we're live", event
|
|
3
|
+
// changes) and receipts.
|
|
3
4
|
import type { ContentRepository } from '../repo/types';
|
|
4
5
|
import type { CalEvent, Calendar, Post, Settings } from '../schema';
|
|
5
6
|
import { CONTENT, collectionUrl } from '../content';
|
|
6
7
|
import { CALENDAR, eventUrl } from '../calendar/config';
|
|
7
|
-
import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
|
|
8
|
-
import {
|
|
8
|
+
import { eventIsListed, eventTimeZone, expandEvents, findOccurrence, type Occurrence } from '../calendar/events';
|
|
9
|
+
import { tzParts } from '../calendar/ics';
|
|
10
|
+
import { I18N, stripLocale } from '../i18n';
|
|
9
11
|
import { localizeDoc } from '../translations';
|
|
10
12
|
import { localizedUrl } from '../mobile/locale';
|
|
11
13
|
import { effectiveSnapshot } from '../live/service';
|
|
14
|
+
import { ANNOUNCEMENTS } from '../announcements/config';
|
|
12
15
|
import type { LiveSnapshot } from '../live/types';
|
|
13
16
|
import { PUSH } from './config';
|
|
14
17
|
import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
|
|
15
|
-
import type
|
|
18
|
+
import { EventSnapshot, type PushAuto, type PushNotification, type PushSource, type PushTopic } from './types';
|
|
16
19
|
|
|
17
20
|
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
|
|
18
21
|
/** automatic notices queued longer ago than this are skipped, never sent late */
|
|
@@ -22,13 +25,14 @@ export const RECEIPT_DELAY = 15 * MINUTE;
|
|
|
22
25
|
|
|
23
26
|
// ---- topics ------------------------------------------------------------------
|
|
24
27
|
|
|
25
|
-
// names of the built-in topics; other languages show English
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// names of the built-in topics; other languages show English. `eventsChanges`
|
|
29
|
+
// names `events` while event-change notices go to it.
|
|
30
|
+
const TOPIC_WORDS: Record<string, { news: string; events: string; eventsChanges: string; live: string }> = {
|
|
31
|
+
en: { news: 'News', events: 'Events', eventsChanges: 'Events and changes', live: 'Live video' },
|
|
32
|
+
es: { news: 'Noticias', events: 'Eventos', eventsChanges: 'Eventos y cambios', live: 'Video en vivo' },
|
|
33
|
+
fr: { news: 'Actualités', events: 'Événements', eventsChanges: 'Événements et changements', live: 'Vidéo en direct' },
|
|
34
|
+
de: { news: 'Neuigkeiten', events: 'Veranstaltungen', eventsChanges: 'Veranstaltungen und Änderungen', live: 'Live-Video' },
|
|
35
|
+
pt: { news: 'Notícias', events: 'Eventos', eventsChanges: 'Eventos e mudanças', live: 'Vídeo ao vivo' },
|
|
32
36
|
};
|
|
33
37
|
/** A site topic's words in a language: exact code, its base ("es-MX" → "es"), every language ('*'), the site's main language, then any. */
|
|
34
38
|
export function topicWords(words: Record<string, string>, locale: string): string {
|
|
@@ -46,17 +50,20 @@ export function topicWords(words: Record<string, string>, locale: string): strin
|
|
|
46
50
|
* first registration) choose: a new topic never reaches existing devices on its own.
|
|
47
51
|
*/
|
|
48
52
|
export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
|
|
49
|
-
const
|
|
53
|
+
const settings = await repo.getSettings();
|
|
54
|
+
const lang = locale || I18N.defaultLocale || settings.locale || 'en';
|
|
50
55
|
const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
|
|
51
56
|
const collections = Object.entries(CONTENT.collections);
|
|
52
57
|
const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
|
|
53
58
|
if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
|
|
54
59
|
if (CALENDAR.enabled) {
|
|
55
|
-
|
|
60
|
+
const changes = settings.push.auto.eventChanges;
|
|
61
|
+
// event-change notices go to `events` unless their rule names other topics
|
|
62
|
+
out.push({ id: 'events', label: changes.calendars.length && !changes.topics.length ? w.eventsChanges : w.events, kind: 'general' });
|
|
56
63
|
const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
|
|
57
64
|
if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
|
|
58
65
|
}
|
|
59
|
-
if (
|
|
66
|
+
if (settings.live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
|
|
60
67
|
for (const t of PUSH.topics) {
|
|
61
68
|
const description = topicWords(t.description, lang);
|
|
62
69
|
out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
|
|
@@ -84,12 +91,28 @@ export function deviceLocale(raw: string): string | null {
|
|
|
84
91
|
return match && match !== I18N.defaultLocale ? match : null;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
interface Words {
|
|
95
|
+
startsIn: (m: number) => string; now: string; live: string;
|
|
96
|
+
// event changes: dates come formatted, `more` ends a list of cancelled dates
|
|
97
|
+
moved: (to: string, was: string) => string; movedTo: (to: string, place: string) => string; place: (p: string) => string;
|
|
98
|
+
cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
|
|
99
|
+
}
|
|
100
|
+
const STRINGS: Record<string, Words> = {
|
|
101
|
+
en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
|
|
102
|
+
moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
|
|
103
|
+
cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more` },
|
|
104
|
+
es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
|
|
105
|
+
moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
|
|
106
|
+
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más` },
|
|
107
|
+
fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
|
|
108
|
+
moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
|
|
109
|
+
cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres` },
|
|
110
|
+
de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
|
|
111
|
+
moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
|
|
112
|
+
cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere` },
|
|
113
|
+
pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
|
|
114
|
+
moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
|
|
115
|
+
cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}` },
|
|
93
116
|
};
|
|
94
117
|
export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
|
|
95
118
|
|
|
@@ -97,8 +120,8 @@ const clip = (s: string, n: number) => {
|
|
|
97
120
|
const t = s.replace(/\s+/g, ' ').trim();
|
|
98
121
|
return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t;
|
|
99
122
|
};
|
|
100
|
-
function when(start: number, allDay: boolean, tz: string, locale: string,
|
|
101
|
-
const opts: Intl.DateTimeFormatOptions =
|
|
123
|
+
function when(start: number, allDay: boolean, tz: string, locale: string, only?: 'time' | 'monthDay') {
|
|
124
|
+
const opts: Intl.DateTimeFormatOptions = only === 'time' ? { hour: 'numeric', minute: '2-digit' } : only === 'monthDay' ? { month: 'short', day: 'numeric' }
|
|
102
125
|
: allDay ? { weekday: 'short', month: 'short', day: 'numeric' } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
|
|
103
126
|
try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }).format(start); }
|
|
104
127
|
catch { return new Date(start).toISOString().slice(0, 16).replace('T', ' '); }
|
|
@@ -201,6 +224,11 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
|
|
|
201
224
|
|
|
202
225
|
const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
|
|
203
226
|
const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
|
|
227
|
+
// the change rule: its calendars, and one of its tags when it lists some (any case)
|
|
228
|
+
function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
|
|
229
|
+
const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
|
|
230
|
+
return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
|
|
231
|
+
}
|
|
204
232
|
function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
|
|
205
233
|
return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
|
|
206
234
|
}
|
|
@@ -224,7 +252,7 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
|
|
|
224
252
|
if (soon) {
|
|
225
253
|
const s = pushStrings(locale);
|
|
226
254
|
const mins = Math.max(0, Math.round((soon.start - now) / MINUTE));
|
|
227
|
-
body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale,
|
|
255
|
+
body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale, 'time')}${place}`;
|
|
228
256
|
} else body = `${when(next.start, next.allDay, tz, locale)}${place}`;
|
|
229
257
|
return { title: clip(ev.title, 120), body: clip(body, 240), url: eventUrl(ev.slug) };
|
|
230
258
|
};
|
|
@@ -234,7 +262,159 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
|
|
|
234
262
|
localize: (l) => words(localizeDoc(e, l), l) };
|
|
235
263
|
}
|
|
236
264
|
|
|
237
|
-
|
|
265
|
+
// ---- event changes ---------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Edits wait this long after the last one, so a quick fix sends one notice (or none)… */
|
|
268
|
+
export const CHANGE_QUIET = 10 * MINUTE;
|
|
269
|
+
/** …but only this long when the date they touch starts within CHANGE_SOON. */
|
|
270
|
+
export const CHANGE_QUIET_SOON = MINUTE;
|
|
271
|
+
export const CHANGE_SOON = 3 * HOUR;
|
|
272
|
+
|
|
273
|
+
/** The public fields a change notice compares (staged edits live in `draft` and don't count until applied). */
|
|
274
|
+
export function eventSnapshot(e: CalEvent): EventSnapshot {
|
|
275
|
+
return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
|
|
276
|
+
cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() } };
|
|
277
|
+
}
|
|
278
|
+
function readSnapshot(raw: string | null): EventSnapshot | null {
|
|
279
|
+
try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface Slot { start: number; allDay: boolean }
|
|
283
|
+
export interface EventChange {
|
|
284
|
+
/** the next date (or its time) moved */
|
|
285
|
+
moved?: { from: Slot; to: Slot };
|
|
286
|
+
/** a new place name or address */
|
|
287
|
+
place?: { from: EventSnapshot['location']; to: EventSnapshot['location'] };
|
|
288
|
+
/** the whole event was cancelled: its next date */
|
|
289
|
+
cancelled?: Slot;
|
|
290
|
+
/** single dates of a repeating event newly cancelled, soonest first */
|
|
291
|
+
cancelledDates?: Slot[];
|
|
292
|
+
/** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
|
|
293
|
+
at: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// the next occurrence that hasn't ended and the next that hasn't started
|
|
297
|
+
// (cancelled ones count: cancelling a date isn't a move)
|
|
298
|
+
function slots(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): { next: Slot | null; upcoming: Slot | null } {
|
|
299
|
+
const all = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY });
|
|
300
|
+
const slot = (o: Occurrence | undefined) => (o ? { start: o.start, allDay: o.allDay } : null);
|
|
301
|
+
return { next: slot(all[0]), upcoming: slot(all.find((o) => o.start > now)) };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What people following an event should hear, from what they last knew (`was`)
|
|
306
|
+
* to the event now: its next date or time moved, a new place, the whole event
|
|
307
|
+
* cancelled, or single dates cancelled. Null when nothing counts: other fields,
|
|
308
|
+
* series edits that leave the next date alone, restored dates, un-cancelling,
|
|
309
|
+
* or it's too late (what it touches has started, or nothing is left to come).
|
|
310
|
+
*/
|
|
311
|
+
export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): EventChange | null {
|
|
312
|
+
if (was.cancelled) return null;
|
|
313
|
+
const { upcoming } = slots(e, settings, now);
|
|
314
|
+
if (!upcoming) return null;
|
|
315
|
+
if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
|
|
316
|
+
const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
|
|
317
|
+
// a date taken out of the series (dragged to its own event) or put back isn't a move
|
|
318
|
+
const exdates = [...new Set([...was.exdates, ...e.exdates])];
|
|
319
|
+
const from = slots({ ...then, exdates }, settings, now).next;
|
|
320
|
+
if (!from) return null;
|
|
321
|
+
const to = slots({ ...e, exdates }, settings, now).next;
|
|
322
|
+
const out: Omit<EventChange, 'at'> = {};
|
|
323
|
+
const starts: number[] = [];
|
|
324
|
+
// a move matters until the new time starts, even when the old one has passed
|
|
325
|
+
if (to && to.start > now && (to.start !== from.start || to.allDay !== from.allDay)) { out.moved = { from, to }; starts.push(from.start, to.start); }
|
|
326
|
+
const place = { name: e.location.name.trim(), address: e.location.address.trim() };
|
|
327
|
+
if ((place.name || place.address) && (place.name !== was.location.name || place.address !== was.location.address)) {
|
|
328
|
+
out.place = { from: was.location, to: place };
|
|
329
|
+
starts.push(upcoming.start);
|
|
330
|
+
}
|
|
331
|
+
// only dates people knew as on: one that was already cancelled and moved with its series isn't news
|
|
332
|
+
const dates = e.cancelledDates.filter((k) => !was.cancelledDates.includes(k) && findOccurrence(then, k, settings)).map((k) => findOccurrence(e, k, settings))
|
|
333
|
+
.filter((o): o is Occurrence => o !== null && o.start > now).sort((a, b) => a.start - b.start);
|
|
334
|
+
if (dates.length) {
|
|
335
|
+
out.cancelledDates = dates.map((o) => ({ start: o.start, allDay: o.allDay }));
|
|
336
|
+
starts.push(dates[0].start);
|
|
337
|
+
}
|
|
338
|
+
return starts.length ? { ...out, at: Math.min(...starts) } : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Called by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
|
|
342
|
+
export async function queueEventChange(repo: ContentRepository, before: CalEvent, after: CalEvent, now = Date.now()): Promise<boolean> {
|
|
343
|
+
// publishing is the "new event" notice; unpublishing says nothing
|
|
344
|
+
if (!PUSH.enabled || !CALENDAR.enabled || before.status !== 'published' || after.status !== 'published') return false;
|
|
345
|
+
const was = eventSnapshot(before);
|
|
346
|
+
if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
|
|
347
|
+
const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
348
|
+
if (!inChangeRule(settings.push.auto.eventChanges, after) || !eventIsListed(after, listedCalendars(all))) return false;
|
|
349
|
+
const key = `change:${after.id}`;
|
|
350
|
+
// a change to a date that starts soon waits only a minute (judged from what people last knew)
|
|
351
|
+
const row = await repo.push.queuedChange(key);
|
|
352
|
+
const change = eventChange((row?.status === 'pending' && readSnapshot(row.payload)) || was, after, settings, now);
|
|
353
|
+
const quiet = change && change.at - now < CHANGE_SOON ? CHANGE_QUIET_SOON : CHANGE_QUIET;
|
|
354
|
+
await repo.push.queueChange(key, after.id, JSON.stringify(was), now, now + quiet);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The site path a link opens, without the origin, a non-default locale prefix, query or hash ('' = another site). */
|
|
359
|
+
export function sitePath(link: string, origin: string): string {
|
|
360
|
+
try {
|
|
361
|
+
const u = new URL(link, origin || 'https://site.invalid');
|
|
362
|
+
if (origin && u.origin !== new URL(origin).origin) return '';
|
|
363
|
+
return stripLocale(u.pathname.replace(/(.)\/+$/, '$1'));
|
|
364
|
+
} catch { return ''; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// an announcement pushed in the last hour whose button opens this event already told people
|
|
368
|
+
async function announcedLately(repo: ContentRepository, e: CalEvent, origin: string, now: number): Promise<boolean> {
|
|
369
|
+
if (!ANNOUNCEMENTS.enabled) return false;
|
|
370
|
+
const page = eventUrl(e.slug);
|
|
371
|
+
return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `topics`: the rule's own audience; [] = the event's topics. */
|
|
375
|
+
export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
|
|
376
|
+
const tz = eventTimeZone(e, settings);
|
|
377
|
+
const siteTz = settings.timezone || 'UTC';
|
|
378
|
+
// all-day dates start at midnight in the site's zone (how they were expanded)
|
|
379
|
+
const zone = (x: Slot) => (x.allDay ? siteTz : tz);
|
|
380
|
+
const dates = change.cancelledDates ?? [];
|
|
381
|
+
const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : 'dateCancelled';
|
|
382
|
+
// one cancelled date opens that date (?on= is its day in the site's zone, like calendar links)
|
|
383
|
+
let on = '';
|
|
384
|
+
if (kind === 'dateCancelled' && dates.length === 1) {
|
|
385
|
+
const p = tzParts(dates[0].start, siteTz);
|
|
386
|
+
on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
|
|
387
|
+
}
|
|
388
|
+
const words = (ev: CalEvent, locale: string): Composed => {
|
|
389
|
+
const s = pushStrings(locale);
|
|
390
|
+
const at = (x: Slot) => when(x.start, x.allDay, zone(x), locale);
|
|
391
|
+
const day = (x: Slot) => when(x.start, true, zone(x), locale);
|
|
392
|
+
const parts: string[] = [];
|
|
393
|
+
if (change.cancelled) parts.push(ev.rrule ? s.cancelledFrom(day(change.cancelled)) : s.cancelled(at(change.cancelled)));
|
|
394
|
+
else {
|
|
395
|
+
const loc = { name: ev.location.name.trim(), address: ev.location.address.trim() };
|
|
396
|
+
// a new name is enough; the same name at a new address needs both
|
|
397
|
+
const place = !change.place ? '' : change.place.to.name !== change.place.from.name ? loc.name || loc.address : [loc.name, loc.address].filter(Boolean).join(', ');
|
|
398
|
+
if (change.moved) {
|
|
399
|
+
const { from, to } = change.moved;
|
|
400
|
+
const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
|
|
401
|
+
parts.push(place ? s.movedTo(at(to), place) : s.moved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
|
|
402
|
+
} else if (place) parts.push(s.place(place));
|
|
403
|
+
if (dates.length) {
|
|
404
|
+
const list = dates.length === 1 ? [day(dates[0])] : dates.slice(0, 3).map((x) => when(x.start, true, zone(x), locale, 'monthDay'));
|
|
405
|
+
if (dates.length > 3) list.push(s.more(dates.length - 3));
|
|
406
|
+
let joined: string;
|
|
407
|
+
try { joined = new Intl.ListFormat(locale, { type: 'conjunction' }).format(list); } catch { joined = list.join(', '); }
|
|
408
|
+
parts.push(s.cancelledOn(joined));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { title: clip(ev.title, 120), body: clip(parts.join(' · '), 240), url: `${eventUrl(ev.slug)}${on}` };
|
|
412
|
+
};
|
|
413
|
+
return { ...words(e, displayLocale(settings)), audience: topics.length ? topics : eventTopics(e, calendars), source: 'change', sourceKey: `change:${e.id}`,
|
|
414
|
+
data: { kind: 'event', id: e.id, slug: e.slug, change: kind }, localize: (l) => words(localizeDoc(e, l), l) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Sends (or skips) queued first-publish and event-change notices. Returns how many were sent. */
|
|
238
418
|
export async function processPushQueue(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
|
|
239
419
|
const now = opts.now ?? Date.now();
|
|
240
420
|
if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
|
|
@@ -254,6 +434,13 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
|
|
|
254
434
|
} else if (row.kind === 'event' && CALENDAR.enabled) {
|
|
255
435
|
const e = await repo.getEvent(row.docId);
|
|
256
436
|
if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
|
|
437
|
+
} else if (row.kind === 'change' && CALENDAR.enabled) {
|
|
438
|
+
// compared at send time: edits that cancel out, or come too late, send nothing
|
|
439
|
+
const e = await repo.getEvent(row.docId);
|
|
440
|
+
const was = readSnapshot(row.payload);
|
|
441
|
+
const rule = auto.eventChanges;
|
|
442
|
+
const change = e && was && eventIsListed(e, calendars) && inChangeRule(rule, e) ? eventChange(was, e, settings, now) : null;
|
|
443
|
+
if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, rule.topics);
|
|
257
444
|
}
|
|
258
445
|
if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
|
|
259
446
|
const n = await sendNotification(repo, notice, run);
|
package/src/lib/push/store.ts
CHANGED
|
@@ -96,6 +96,10 @@ export class PushStore {
|
|
|
96
96
|
async receiptFailed(counts: Map<string, { failed: number; removed: number }>) {
|
|
97
97
|
for (const [id, c] of counts) await this.run('UPDATE push_notifications SET ok=MAX(ok-?,0), failed=failed+?, removed=removed+? WHERE id=?', [c.failed, c.failed, c.removed, id]);
|
|
98
98
|
}
|
|
99
|
+
/** Notifications from one source that went out since `since`, newest first. */
|
|
100
|
+
async sentSince(source: PushNotification['source'], since: number) {
|
|
101
|
+
return (await this.all(`SELECT * FROM push_notifications WHERE source=? AND created_at>=? AND status!='failed' ORDER BY created_at DESC LIMIT 100`, [source, since])).map(notification);
|
|
102
|
+
}
|
|
99
103
|
async listNotifications(limit = 50) { return (await this.all('SELECT * FROM push_notifications ORDER BY created_at DESC LIMIT ?', [limit])).map(notification); }
|
|
100
104
|
async getNotification(id: string) { const r = await this.all('SELECT * FROM push_notifications WHERE id=?', [id]); return r[0] ? notification(r[0]) : null; }
|
|
101
105
|
|
|
@@ -120,17 +124,39 @@ export class PushStore {
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
// ---- automatic notification queue ----
|
|
123
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Claims up to `limit` due pending rows (status → sending); rows queued before
|
|
129
|
+
* `staleBefore` are skipped instead. A row with `due_at` waits until then and
|
|
130
|
+
* goes stale from then, so an event still being edited never counts as late.
|
|
131
|
+
*/
|
|
124
132
|
async claimPending(now: number, staleBefore: number, limit: number): Promise<PushQueueRow[]> {
|
|
125
|
-
await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND created_at<?`, [now, staleBefore]);
|
|
126
|
-
const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' ORDER BY created_at LIMIT ?`, [limit]);
|
|
133
|
+
await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND COALESCE(due_at,created_at)<?`, [now, staleBefore]);
|
|
134
|
+
const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' AND (due_at IS NULL OR due_at<=?) ORDER BY created_at LIMIT ?`, [now, limit]);
|
|
127
135
|
const out: PushQueueRow[] = [];
|
|
128
136
|
for (const { key } of due) {
|
|
129
|
-
const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' RETURNING *`, [now, key]);
|
|
130
|
-
if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence),
|
|
137
|
+
const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' AND (due_at IS NULL OR due_at<=?) RETURNING *`, [now, key, now]);
|
|
138
|
+
if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence),
|
|
139
|
+
status: 'sending', createdAt: Number(r[0].created_at), payload: r[0].payload == null ? null : String(r[0].payload), dueAt: r[0].due_at == null ? null : Number(r[0].due_at) });
|
|
131
140
|
}
|
|
132
141
|
return out;
|
|
133
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Debounces an event's change notice (key change:<id>) in one statement, so
|
|
145
|
+
* concurrent saves can't lose an edit: a pending row keeps its snapshot (what
|
|
146
|
+
* people last knew) and waits until `dueAt` again; any other row (sent,
|
|
147
|
+
* skipped, failed, or being sent) starts over with this snapshot.
|
|
148
|
+
*/
|
|
149
|
+
async queueChange(key: string, docId: string, payload: string, now: number, dueAt: number) {
|
|
150
|
+
await this.run(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at,payload,due_at)
|
|
151
|
+
VALUES (?,'change',?,NULL,'pending',NULL,?,?,?,?) ON CONFLICT(key) DO UPDATE SET
|
|
152
|
+
payload=CASE WHEN push_queue.status='pending' THEN push_queue.payload ELSE excluded.payload END,
|
|
153
|
+
created_at=CASE WHEN push_queue.status='pending' THEN push_queue.created_at ELSE excluded.created_at END,
|
|
154
|
+
status='pending', notification_id=NULL, due_at=excluded.due_at, updated_at=excluded.updated_at`, [key, docId, now, now, payload, dueAt]);
|
|
155
|
+
}
|
|
156
|
+
async queuedChange(key: string): Promise<{ status: string; payload: string | null } | null> {
|
|
157
|
+
const r = (await this.all('SELECT status, payload FROM push_queue WHERE key=?', [key]))[0];
|
|
158
|
+
return r ? { status: String(r.status), payload: r.payload == null ? null : String(r.payload) } : null;
|
|
159
|
+
}
|
|
134
160
|
/** Inserts a new key as `sending`; false when it already exists (dedupe for "starting soon"). */
|
|
135
161
|
async claimKey(key: string, kind: PushQueueRow['kind'], docId: string, occurrence: number | null, now: number): Promise<boolean> {
|
|
136
162
|
const r = await this.all(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
|
|
@@ -138,7 +164,8 @@ export class PushStore {
|
|
|
138
164
|
return r.length > 0;
|
|
139
165
|
}
|
|
140
166
|
async markQueue(key: string, status: 'sent' | 'skipped' | 'failed', notificationId: string | null, now: number) {
|
|
141
|
-
|
|
167
|
+
// a change row an edit queued again while it was sending stays pending
|
|
168
|
+
await this.run(`UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=? AND (kind!='change' OR status='sending')`, [status, notificationId, now, key]);
|
|
142
169
|
}
|
|
143
170
|
async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
|
|
144
171
|
/** When a notice of this kind last went out (0 = never). */
|
|
@@ -148,13 +175,13 @@ export class PushStore {
|
|
|
148
175
|
async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
|
|
149
176
|
|
|
150
177
|
// ---- retention ----
|
|
151
|
-
/** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence and
|
|
178
|
+
/** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence, broadcast and change keys. */
|
|
152
179
|
async purge(now: number, devicesBefore: number): Promise<number> {
|
|
153
180
|
const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
|
|
154
181
|
await (await this.driver()).batch([
|
|
155
182
|
{ sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
|
|
156
183
|
{ sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
|
|
157
|
-
{ sql: `DELETE FROM push_queue WHERE kind IN ('soon','live') AND updated_at<?`, args: [now - 30 * 86400000] },
|
|
184
|
+
{ sql: `DELETE FROM push_queue WHERE kind IN ('soon','live','change') AND updated_at<?`, args: [now - 30 * 86400000] },
|
|
158
185
|
]);
|
|
159
186
|
return gone.length;
|
|
160
187
|
}
|
package/src/lib/push/types.ts
CHANGED
|
@@ -11,11 +11,26 @@ export const PushAuto = z.object({
|
|
|
11
11
|
startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
|
|
12
12
|
/** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
|
|
13
13
|
live: z.object({ enabled: z.boolean().default(false) }).default({}),
|
|
14
|
+
/** a published event's new date, time or place, or a cancellation, once its edits settle */
|
|
15
|
+
eventChanges: z.object({
|
|
16
|
+
calendars: keys.default([]),
|
|
17
|
+
/** only events with one of these tags (any case); [] = every event in the calendars */
|
|
18
|
+
tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
|
|
19
|
+
/** sent to these topics instead of the event's own (`events` and its calendars) */
|
|
20
|
+
topics: z.array(z.string().min(1).max(200)).max(10).default([]),
|
|
21
|
+
}).default({}),
|
|
14
22
|
});
|
|
15
23
|
export type PushAuto = z.infer<typeof PushAuto>;
|
|
16
24
|
export const PushSettings = z.object({ auto: PushAuto.default({}) });
|
|
17
25
|
export type PushSettings = z.infer<typeof PushSettings>;
|
|
18
26
|
|
|
27
|
+
/** An event's public schedule and place, as people last knew it (the payload of a queued change notice) */
|
|
28
|
+
export const EventSnapshot = z.object({
|
|
29
|
+
start: z.string(), end: z.string(), allDay: z.boolean(), timezone: z.string(), rrule: z.string(), exdates: z.array(z.string()),
|
|
30
|
+
cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
|
|
31
|
+
});
|
|
32
|
+
export type EventSnapshot = z.infer<typeof EventSnapshot>;
|
|
33
|
+
|
|
19
34
|
export type PushPlatform = 'ios' | 'android';
|
|
20
35
|
export interface PushDevice {
|
|
21
36
|
id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
|
|
@@ -23,7 +38,7 @@ export interface PushDevice {
|
|
|
23
38
|
channels: string[];
|
|
24
39
|
createdAt: number; updatedAt: number; lastSeenAt: number;
|
|
25
40
|
}
|
|
26
|
-
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement';
|
|
41
|
+
export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change';
|
|
27
42
|
export interface PushNotification {
|
|
28
43
|
id: string; title: string; body: string; url: string;
|
|
29
44
|
/** ['*'] = everyone */
|
|
@@ -35,7 +50,13 @@ export interface PushNotification {
|
|
|
35
50
|
removed: number;
|
|
36
51
|
error: string; author: string; createdAt: number; finishedAt: number | null;
|
|
37
52
|
}
|
|
38
|
-
export interface PushQueueRow {
|
|
53
|
+
export interface PushQueueRow {
|
|
54
|
+
key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change'; docId: string; occurrence: number | null; status: string; createdAt: number;
|
|
55
|
+
/** change rows: the EventSnapshot JSON from before the first edit */
|
|
56
|
+
payload: string | null;
|
|
57
|
+
/** change rows: not sent before this (null = as soon as possible) */
|
|
58
|
+
dueAt: number | null;
|
|
59
|
+
}
|
|
39
60
|
export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
|
|
40
61
|
export interface PushStats {
|
|
41
62
|
devices: number;
|
|
@@ -679,6 +679,12 @@ export const MIGRATIONS: Migration[] = [
|
|
|
679
679
|
] },
|
|
680
680
|
// announcements show on public pages, the banner and the app API
|
|
681
681
|
{ version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
|
|
682
|
+
// event-change notices (PushAuto.eventChanges) wait in push_queue until the
|
|
683
|
+
// edits settle: payload = the schedule people last knew, due_at = send time
|
|
684
|
+
{ version: 59, statements: [
|
|
685
|
+
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
686
|
+
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`,
|
|
687
|
+
] },
|
|
682
688
|
];
|
|
683
689
|
|
|
684
690
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|
package/src/lib/schema.ts
CHANGED
|
@@ -672,7 +672,8 @@ export const Booking = z.object({
|
|
|
672
672
|
formResponse: FormResponse.optional(),
|
|
673
673
|
notes: z.string().default(''), // staff-only
|
|
674
674
|
manageToken: z.string().default(''),
|
|
675
|
-
|
|
675
|
+
// web: the website; app: the public app API (/api/v1/bookings)
|
|
676
|
+
source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
|
|
676
677
|
reminderSentAt: z.number().nullable().default(null),
|
|
677
678
|
notificationVersion: z.number().default(0),
|
|
678
679
|
createdAt: z.number(),
|
package/src/lib/search-i18n.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// UI strings for the search modal and /search page
|
|
2
|
-
//
|
|
3
|
-
// per-locale chrome, so the dict lives here until one exists. Locales fall
|
|
4
|
-
// back to English.
|
|
1
|
+
// UI strings for the search modal and /search page, in the same languages as
|
|
2
|
+
// the rest of the layout (site-words.ts). Locales fall back to English.
|
|
5
3
|
export interface SearchStrings {
|
|
6
4
|
label: string;
|
|
7
5
|
placeholder: string;
|
|
@@ -43,6 +41,45 @@ const STRINGS: Record<string, SearchStrings> = {
|
|
|
43
41
|
results: 'resultados',
|
|
44
42
|
button: 'Buscar',
|
|
45
43
|
},
|
|
44
|
+
fr: {
|
|
45
|
+
label: 'Rechercher',
|
|
46
|
+
placeholder: 'Rechercher des articles, pages, étiquettes et auteurs',
|
|
47
|
+
posts: 'Articles',
|
|
48
|
+
pages: 'Pages',
|
|
49
|
+
events: 'Événements',
|
|
50
|
+
tags: 'Étiquettes',
|
|
51
|
+
authors: 'Auteurs',
|
|
52
|
+
empty: 'Aucun résultat pour',
|
|
53
|
+
result: 'résultat',
|
|
54
|
+
results: 'résultats',
|
|
55
|
+
button: 'Rechercher',
|
|
56
|
+
},
|
|
57
|
+
de: {
|
|
58
|
+
label: 'Suchen',
|
|
59
|
+
placeholder: 'Beiträge, Seiten, Schlagwörter und Autoren durchsuchen',
|
|
60
|
+
posts: 'Beiträge',
|
|
61
|
+
pages: 'Seiten',
|
|
62
|
+
events: 'Veranstaltungen',
|
|
63
|
+
tags: 'Schlagwörter',
|
|
64
|
+
authors: 'Autoren',
|
|
65
|
+
empty: 'Nichts gefunden für',
|
|
66
|
+
result: 'Ergebnis',
|
|
67
|
+
results: 'Ergebnisse',
|
|
68
|
+
button: 'Suchen',
|
|
69
|
+
},
|
|
70
|
+
pt: {
|
|
71
|
+
label: 'Buscar',
|
|
72
|
+
placeholder: 'Busque posts, páginas, tags e autores',
|
|
73
|
+
posts: 'Posts',
|
|
74
|
+
pages: 'Páginas',
|
|
75
|
+
events: 'Eventos',
|
|
76
|
+
tags: 'Tags',
|
|
77
|
+
authors: 'Autores',
|
|
78
|
+
empty: 'Nada encontrado para',
|
|
79
|
+
result: 'resultado',
|
|
80
|
+
results: 'resultados',
|
|
81
|
+
button: 'Buscar',
|
|
82
|
+
},
|
|
46
83
|
};
|
|
47
84
|
|
|
48
85
|
export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
|
package/src/lib/site-chrome.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
// [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
|
|
9
9
|
// [data-menu-open] opens the phone menu sheet (built from site.links)
|
|
10
10
|
// [data-live-header] (hidden) becomes the "Live" link during a stream
|
|
11
|
+
//
|
|
12
|
+
// `words` are core's own labels in the page's language (skip link, menu and
|
|
13
|
+
// dark-mode buttons, landmark and footer labels), so a custom header labels
|
|
14
|
+
// its controls the same way the built-in one does.
|
|
15
|
+
import type { SiteStrings } from './site-words';
|
|
16
|
+
|
|
11
17
|
export interface ChromeLink {
|
|
12
18
|
label: string;
|
|
13
19
|
url: string;
|
|
@@ -32,4 +38,6 @@ export interface SiteChrome {
|
|
|
32
38
|
liveHeader: boolean;
|
|
33
39
|
headerStyle: string; // theme token `header`
|
|
34
40
|
footerStyle: string; // theme token `footer`
|
|
41
|
+
lang: string; // the page's language, as on <html lang>
|
|
42
|
+
words: SiteStrings;
|
|
35
43
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Words the public layout (Site.astro) shows around every page: the skip
|
|
2
|
+
// link, header and phone-menu controls, the footer's built-in columns and
|
|
3
|
+
// links. Owner-written labels (menus, collections) keep their own words;
|
|
4
|
+
// search, announcements and live video have their own lists. Other languages
|
|
5
|
+
// fall back to English.
|
|
6
|
+
export interface SiteStrings {
|
|
7
|
+
skip: string;
|
|
8
|
+
/** landmark labels */
|
|
9
|
+
main: string;
|
|
10
|
+
menu: string;
|
|
11
|
+
language: string;
|
|
12
|
+
social: string;
|
|
13
|
+
openMenu: string;
|
|
14
|
+
closeMenu: string;
|
|
15
|
+
/** the light/dark button */
|
|
16
|
+
scheme: string;
|
|
17
|
+
/** footer column headings (columns footer) */
|
|
18
|
+
read: string;
|
|
19
|
+
site: string;
|
|
20
|
+
/** built-in legal links, added when the site has no menu item for them */
|
|
21
|
+
privacy: string;
|
|
22
|
+
terms: string;
|
|
23
|
+
/** the header's automatic list, when there's no header menu */
|
|
24
|
+
home: string;
|
|
25
|
+
links: string;
|
|
26
|
+
/** code block copy button */
|
|
27
|
+
copy: string;
|
|
28
|
+
copied: string;
|
|
29
|
+
/** gallery lightbox */
|
|
30
|
+
close: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STRINGS: Record<string, SiteStrings> = {
|
|
34
|
+
en: {
|
|
35
|
+
skip: 'Skip to content', main: 'Main', menu: 'Menu', language: 'Language', social: 'Social', openMenu: 'Open menu', closeMenu: 'Close menu',
|
|
36
|
+
scheme: 'Toggle dark mode', read: 'Read', site: 'Site', privacy: 'Privacy', terms: 'Terms', home: 'Home', links: 'Links',
|
|
37
|
+
copy: 'Copy', copied: 'Copied', close: 'Close',
|
|
38
|
+
},
|
|
39
|
+
es: {
|
|
40
|
+
skip: 'Saltar al contenido', main: 'Principal', menu: 'Menú', language: 'Idioma', social: 'Redes sociales', openMenu: 'Abrir menú', closeMenu: 'Cerrar menú',
|
|
41
|
+
scheme: 'Cambiar entre modo claro y oscuro', read: 'Leer', site: 'Sitio', privacy: 'Privacidad', terms: 'Términos', home: 'Inicio', links: 'Enlaces',
|
|
42
|
+
copy: 'Copiar', copied: 'Copiado', close: 'Cerrar',
|
|
43
|
+
},
|
|
44
|
+
fr: {
|
|
45
|
+
skip: 'Aller au contenu', main: 'Principal', menu: 'Menu', language: 'Langue', social: 'Réseaux sociaux', openMenu: 'Ouvrir le menu', closeMenu: 'Fermer le menu',
|
|
46
|
+
scheme: 'Basculer entre mode clair et sombre', read: 'Lire', site: 'Site', privacy: 'Confidentialité', terms: 'Conditions', home: 'Accueil', links: 'Liens',
|
|
47
|
+
copy: 'Copier', copied: 'Copié', close: 'Fermer',
|
|
48
|
+
},
|
|
49
|
+
de: {
|
|
50
|
+
skip: 'Zum Inhalt springen', main: 'Hauptmenü', menu: 'Menü', language: 'Sprache', social: 'Soziale Medien', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen',
|
|
51
|
+
scheme: 'Hell- und Dunkelmodus umschalten', read: 'Lesen', site: 'Website', privacy: 'Datenschutz', terms: 'Nutzungsbedingungen', home: 'Startseite', links: 'Links',
|
|
52
|
+
copy: 'Kopieren', copied: 'Kopiert', close: 'Schließen',
|
|
53
|
+
},
|
|
54
|
+
pt: {
|
|
55
|
+
skip: 'Pular para o conteúdo', main: 'Principal', menu: 'Menu', language: 'Idioma', social: 'Redes sociais', openMenu: 'Abrir menu', closeMenu: 'Fechar menu',
|
|
56
|
+
scheme: 'Alternar entre modo claro e escuro', read: 'Ler', site: 'Site', privacy: 'Privacidade', terms: 'Termos', home: 'Início', links: 'Links',
|
|
57
|
+
copy: 'Copiar', copied: 'Copiado', close: 'Fechar',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const siteStrings = (locale: string | null | undefined): SiteStrings =>
|
|
62
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|