@acms-subir/core 0.3.33 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,14 +8,14 @@ import { siteOrigin } from '../seo';
8
8
  import { pageQuery, queryParams } from '../mobile/http';
9
9
  import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
10
10
  import { PUSH } from '../push/config';
11
- import { deviceLocale, knownTopics, pushTopics, siteOriginOf } from '../push/service';
11
+ import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
12
12
  import { linkTargets } from '../push/api';
13
13
  import { ANNOUNCEMENTS } from './config';
14
14
  import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
15
15
  import { announcementStrings } from './words';
16
16
  import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
17
17
 
18
- const DAY = 86_400_000;
18
+ const HOUR = 3_600_000, DAY = 24 * HOUR;
19
19
  export function requireAnnouncements() {
20
20
  if (!ANNOUNCEMENTS.enabled) throw new ApiError(404, 'Announcements are off on this site.');
21
21
  }
@@ -70,18 +70,20 @@ export function defaultAudience(known: Set<string>): Record<AnnouncementKind, st
70
70
  export async function adminAnnouncementsState(repo: ContentRepository, url: URL) {
71
71
  const now = Date.now();
72
72
  const settings = await repo.getSettings();
73
- const [announcements, topics, known, stats, links, sentThisWeek] = await Promise.all([
73
+ const [announcements, topics, known, stats, links, sentThisWeek, changes] = await Promise.all([
74
74
  repo.announcements.list(),
75
75
  PUSH.enabled ? pushTopics(repo) : Promise.resolve([]),
76
76
  PUSH.enabled ? knownTopics(repo) : Promise.resolve(new Set<string>()),
77
77
  PUSH.enabled ? repo.push.stats(now) : Promise.resolve(null),
78
78
  linkTargets(repo),
79
79
  PUSH.enabled ? repo.push.countSent(['manual', 'announcement'], now - 7 * DAY) : Promise.resolve(0),
80
+ PUSH.enabled ? repo.push.sentSince('change', now - 2 * HOUR) : Promise.resolve([]),
80
81
  ]);
81
82
  const main = I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en');
83
+ const origin = siteOriginOf(settings, url.origin);
82
84
  return {
83
85
  now,
84
- site: { name: settings.siteTitle, icon: settings.siteFavicon, origin: siteOriginOf(settings, url.origin), timezone: settings.timezone || 'UTC' },
86
+ site: { name: settings.siteTitle, icon: settings.siteFavicon, origin, timezone: settings.timezone || 'UTC' },
85
87
  languages: { main: { code: main, name: localeName(main) }, extra: extraLanguages().map((code) => ({ code, name: localeName(code) })) },
86
88
  kinds: ANNOUNCEMENT_KINDS.map((id) => ({ id, label: announcementStrings('en').kinds[id] })),
87
89
  limits: LIMITS,
@@ -98,6 +100,8 @@ export async function adminAnnouncementsState(repo: ContentRepository, url: URL)
98
100
  // the push audience each kind starts with in the composer (['*'] = everyone)
99
101
  defaultAudience: defaultAudience(known),
100
102
  links,
103
+ // automatic event-change notices of the last 2 hours: a push whose button opens the same event repeats one
104
+ recentChanges: changes.map((n) => ({ path: sitePath(n.url, origin), at: n.createdAt, title: n.title })).filter((c) => c.path),
101
105
  announcements,
102
106
  };
103
107
  }
@@ -68,6 +68,10 @@ export class AnnouncementStore {
68
68
  async due(now: number, limit = 10): Promise<Announcement[]> {
69
69
  return (await this.all(`SELECT * FROM announcements WHERE status='scheduled' AND COALESCE(publish_at,0)<=? ORDER BY publish_at LIMIT ?`, [now, limit])).map(announcement);
70
70
  }
71
+ /** Ones that went up with a push since `since` (an event-change notice about the same event is skipped). */
72
+ async pushedSince(since: number): Promise<Announcement[]> {
73
+ return (await this.all(`SELECT * FROM announcements WHERE notification_id IS NOT NULL AND published_at>=? ORDER BY published_at DESC LIMIT 50`, [since])).map(announcement);
74
+ }
71
75
  /** Published ones past their end: they come down. */
72
76
  async endExpired(now: number): Promise<number> {
73
77
  return (await this.all(`UPDATE announcements SET status='ended', updated_at=? WHERE status='published' AND show_until IS NOT NULL AND show_until<=? RETURNING id`, [now, now])).length;
@@ -8,6 +8,12 @@ import { I18N } from '../i18n';
8
8
 
9
9
  export const PRIVATE_CACHE_CONTROL = 'private, no-store';
10
10
  export const publicCacheControl = () => `public, s-maxage=${CACHE.maxAge}, stale-while-revalidate=${CACHE.staleWhileRevalidate}`;
11
+ // The public app API: browsers (Expo web, web apps) revalidate by ETag every
12
+ // time, since stale-while-revalidate would hand them an old /site without a
13
+ // feature the app just got. CDNs that read CDN-Cache-Control (RFC 9213, e.g.
14
+ // Cloudflare) still serve it stale while they refresh, like pages; others fall
15
+ // back to s-maxage.
16
+ export const apiCacheControl = () => `public, max-age=0, s-maxage=${CACHE.maxAge}`;
11
17
  // publicResponse's own header: browsers/apps always revalidate by ETag. The
12
18
  // policy upgrades it with s-maxage so a CDN can share it.
13
19
  export const API_REVALIDATE = 'public, max-age=0, must-revalidate';
@@ -64,12 +70,13 @@ export function applyCachePolicy(request: Request, response: Response, pathname
64
70
  if (!CACHE.enabled) return response;
65
71
  if (OWN_HEADERS.test(pathname)) return response;
66
72
  const current = response.headers.get('cache-control');
67
- const set = (value: string) => {
68
- try { response.headers.set('Cache-Control', value); return response; }
73
+ const set = (value: string, cdn?: string) => {
74
+ const write = (h: Headers) => { h.set('Cache-Control', value); if (cdn) h.set('CDN-Cache-Control', cdn); };
75
+ try { write(response.headers); return response; }
69
76
  catch {
70
77
  // immutable headers (a proxied/fetched response): copy it
71
78
  const copy = new Response(response.body, response);
72
- copy.headers.set('Cache-Control', value);
79
+ write(copy.headers);
73
80
  return copy;
74
81
  }
75
82
  };
@@ -85,5 +92,6 @@ export function applyCachePolicy(request: Request, response: Response, pathname
85
92
  warnCookie(pathname);
86
93
  return set(PRIVATE_CACHE_CONTROL);
87
94
  }
95
+ if (pathname.startsWith('/api/v1/')) return set(apiCacheControl(), publicCacheControl());
88
96
  return set(publicCacheControl());
89
97
  }
@@ -1,10 +1,12 @@
1
1
  // Side effects when an event changes live: a 301 when a published event's
2
- // slug moves, and the matching webhook (event.published / event.updated /
2
+ // slug moves, a queued "event changed" app notice when its schedule or place
3
+ // moves, and the matching webhook (event.published / event.updated /
3
4
  // event.unpublished). Shared by the editor actions, the list's bulk actions
4
5
  // and the agent tools so every path behaves the same.
5
6
  import type { ContentRepository } from '../repo/types';
6
7
  import type { CalEvent } from '../schema';
7
8
  import { emitWebhook } from '../webhooks';
9
+ import { queueEventChange } from '../push/service';
8
10
  import { eventToJson } from './api';
9
11
  import { eventUrl } from './config';
10
12
 
@@ -18,6 +20,8 @@ export async function eventChanged(
18
20
  await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
19
21
  opts.onRedirect?.();
20
22
  }
23
+ // the scheduler sends it once the edits settle, if the rule still applies
24
+ await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
21
25
  let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
22
26
  if (before.status !== 'published' && after.status === 'published') type = 'event.published';
23
27
  else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
@@ -29,7 +29,7 @@ export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacy
29
29
  google: 'Personal info → Email address. Collected, not shared. Purpose: App functionality, Account management. Optional.',
30
30
  });
31
31
  if (f.push.enabled) rows.push({
32
- data: 'Push token, platform, language and chosen topics', when: 'When someone turns on notifications',
32
+ data: 'Push token, platform, language, chosen topics and Android notification channels', when: 'When someone turns on notifications',
33
33
  apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
34
34
  google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
35
35
  });
@@ -3,6 +3,7 @@ import type { ContentRepository } from '../repo/types';
3
3
  import type { Settings } from '../schema';
4
4
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
5
5
  import { PUSH } from '../push/config';
6
+ import { ANNOUNCEMENTS } from '../announcements/config';
6
7
  import { AUTH } from '../auth/config';
7
8
  import { EDITOR } from '../editor/config';
8
9
  import { SITE_ROLES } from '../roles';
@@ -39,6 +40,8 @@ export interface LegalFacts {
39
40
  /** maps provider name, or '' */
40
41
  maps: string;
41
42
  live: boolean;
43
+ /** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
44
+ announcements: boolean;
42
45
  analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
43
46
  ai: boolean;
44
47
  offsiteBackups: boolean;
@@ -79,6 +82,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
79
82
  email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
80
83
  maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
81
84
  live: settings.live.mode !== 'off',
85
+ announcements: ANNOUNCEMENTS.enabled,
82
86
  analytics: {
83
87
  umami: a.umami.enabled && !!a.umami.websiteId,
84
88
  replay: a.umami.enabled && !!a.umami.websiteId && a.umami.replay.enabled,
@@ -52,8 +52,8 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
52
52
  'To see your bookings in our app, you can sign in with your email address. We email you a six-digit code that expires after 10 minutes. We keep your email address and that sign-in on your device until you sign out or don’t use it for 90 days.',
53
53
  ] });
54
54
  if (f.push.enabled) s.push({ id: 'push', title: 'Our app and notifications', text: [
55
- 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language and the topics you choose. We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
56
- `You choose the topics in the app and can change them at any time; we never add a topic without asking you. To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
55
+ 'If you turn on notifications in our app, we store a push token for your device (an identifier that lets Apple or Google deliver notifications to it), whether it’s iOS or Android, your language, the topics you choose and, on Android, which notification channels the app created (such as one for urgent notices). We use them only to send the notifications you asked for, in your language. Notifications are delivered through Expo’s push service (expo.dev), Apple Push Notification service and Firebase Cloud Messaging.',
56
+ `You choose the topics in the app and can change them at any time; we never add a topic without asking you. ${f.announcements ? 'Urgent announcements, such as cancellations, go to every device with notifications turned on, whatever topics it chose.' : 'Important notices, such as cancellations, may go to every device with notifications turned on, whatever topics it chose.'} To stop, turn notifications off in the app or in your phone’s settings. Turning them off in the app removes your device from our list right away. Devices that haven’t opened the app for ${days(f.push.retentionDays)}, and devices whose app was removed, are deleted automatically. We keep a record of each notification sent (its text, link and number of recipients, not who received it) for one year. The app works fully without notifications.`,
57
57
  ] });
58
58
  if (f.email) s.push({ id: 'email', title: 'Email', text: [
59
59
  `We send email through ${f.email}, which processes your email address and the message. We keep a delivery record (recipient and status) for 30 days, and keep addresses that bounce or report our email as spam on a list so we stop writing to them.`,
@@ -79,7 +79,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
79
79
  ] });
80
80
  s.push({ id: 'cookies', title: 'Cookies and storage on your device', text: [
81
81
  `${f.auth === 'clerk' ? 'Clerk’s sign-in cookies' : 'A sign-in cookie (up to seven days)'} for people who sign in; a preview cookie only for staff previews and design share links (up to 30 days); and, in your browser, your light or dark mode choice, the announcements you close and, for one visit, the live video notices you close.${pixels ? ` ${pixelNames} ${f.analytics.meta && f.analytics.tiktok ? 'set their' : 'sets its'} own advertising cookies.` : ' We don’t use cookies for advertising.'}`,
82
- ...(app ? ['Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
82
+ ...(app ? [f.announcements
83
+ ? 'Our app may keep the content it last loaded on your phone so it works offline, and remembers there your notification choice and which announcements you’ve read or closed. That stays on your phone and is never sent to us.'
84
+ : 'Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
83
85
  ] });
84
86
  s.push({ id: 'sharing', title: 'Who receives your information', text: [
85
87
  `We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
@@ -53,8 +53,8 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
53
53
  'Para ver sus reservaciones en nuestra app, puede iniciar sesión con su correo electrónico. Le enviamos un código de seis dígitos que vence a los 10 minutos. Guardamos su correo y esa sesión en su dispositivo hasta que la cierre o pasen 90 días sin usarla.',
54
54
  ] });
55
55
  if (f.push.enabled) s.push({ id: 'push', title: 'Nuestra app y las notificaciones', text: [
56
- 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma y los temas que elija. Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
57
- `Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
56
+ 'Si activa las notificaciones en nuestra app, guardamos un identificador de notificaciones de su dispositivo (que permite a Apple o Google entregárselas), si es iOS o Android, su idioma, los temas que elija y, en Android, qué canales de notificación creó la app (por ejemplo, uno para avisos urgentes). Solo los usamos para enviarle las notificaciones que pidió, en su idioma. Las notificaciones se entregan a través del servicio de Expo (expo.dev), Apple Push Notification service y Firebase Cloud Messaging.',
57
+ `Usted elige los temas en la app y puede cambiarlos cuando quiera; nunca agregamos un tema sin preguntarle. ${f.announcements ? 'Los anuncios urgentes, como una cancelación, llegan a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.' : 'Los avisos importantes, como una cancelación, pueden llegar a todos los dispositivos con las notificaciones activadas, sin importar los temas que eligieron.'} Para dejar de recibirlas, desactive las notificaciones en la app o en los ajustes de su teléfono. Al desactivarlas en la app, su dispositivo se elimina de nuestra lista de inmediato. Los dispositivos que no han abierto la app en ${days(f.push.retentionDays)}, y aquellos en los que se borró la app, se eliminan automáticamente. Guardamos un registro de cada notificación enviada (su texto, su enlace y el número de destinatarios, no quiénes la recibieron) durante un año. La app funciona por completo sin notificaciones.`,
58
58
  ] });
59
59
  if (f.email) s.push({ id: 'email', title: 'Correo electrónico', text: [
60
60
  `Enviamos correos a través de ${f.email}, que trata su dirección de correo y el mensaje. Guardamos un registro de entrega (destinatario y estado) durante 30 días, y mantenemos en una lista las direcciones que rebotan o marcan nuestros correos como spam para dejar de escribirles.`,
@@ -80,7 +80,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
80
80
  ] });
81
81
  s.push({ id: 'cookies', title: 'Cookies y almacenamiento en su dispositivo', text: [
82
82
  `${f.auth === 'clerk' ? 'Las cookies de inicio de sesión de Clerk' : 'Una cookie de inicio de sesión (hasta siete días)'} para quienes inician sesión; una cookie de vista previa solo para las vistas previas del personal y los enlaces para compartir diseños (hasta 30 días); y, en su navegador, su preferencia de modo claro u oscuro, los anuncios que cierra y, durante una visita, los avisos de video en vivo que cierra.${pixels ? ` ${pixelNames} ${both ? 'guardan sus propias' : 'guarda sus propias'} cookies de publicidad.` : ' No usamos cookies con fines publicitarios.'}`,
83
- ...(app ? ['Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
83
+ ...(app ? [f.announcements
84
+ ? 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones y qué anuncios leyó o cerró. Eso se queda en su teléfono y nunca se nos envía.'
85
+ : 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
84
86
  ] });
85
87
  s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
86
88
  `No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
@@ -1,8 +1,9 @@
1
1
  import type { PublicLiveSnapshot } from './types';
2
+ import { fill, liveStrings, type LiveStrings } from './words';
2
3
 
3
4
  // Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
4
5
  // the server and the browser re-applies it on every status change, so both
5
- // always agree.
6
+ // always agree. Both pass the page's words and language (English by default).
6
7
 
7
8
  export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
8
9
 
@@ -23,6 +24,7 @@ export interface LiveView {
23
24
  }
24
25
 
25
26
  const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
27
+ const EN = liveStrings('en');
26
28
 
27
29
  export function compactCount(n: number): string {
28
30
  if (n < 1000) return String(n);
@@ -30,49 +32,51 @@ export function compactCount(n: number): string {
30
32
  return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
31
33
  }
32
34
 
33
- export function ago(ms: number): string {
34
- if (ms < MIN) return 'just now';
35
- if (ms < HOUR) return `${Math.floor(ms / MIN)} min ago`;
35
+ export function ago(ms: number, w: LiveStrings = EN): string {
36
+ if (ms < MIN) return w.justNow;
37
+ if (ms < HOUR) return fill(w.ago, { time: `${Math.floor(ms / MIN)} min` });
36
38
  const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
37
- return m ? `${h} h ${m} min ago` : `${h} h ago`;
39
+ return fill(w.ago, { time: m ? `${h} h ${m} min` : `${h} h` });
38
40
  }
39
41
 
40
- export function until(ms: number): string {
41
- if (ms < MIN) return 'starting now';
42
- if (ms < HOUR) return `in ${Math.ceil(ms / MIN)} min`;
43
- if (ms < DAY) return `in ${Math.round(ms / HOUR)} h`;
42
+ export function until(ms: number, w: LiveStrings = EN): string {
43
+ if (ms < MIN) return w.startingNow;
44
+ if (ms < HOUR) return fill(w.in, { time: `${Math.ceil(ms / MIN)} min` });
45
+ if (ms < DAY) return fill(w.in, { time: `${Math.round(ms / HOUR)} h` });
44
46
  const d = Math.round(ms / DAY);
45
- return `in ${d} day${d === 1 ? '' : 's'}`;
47
+ return fill(w.in, { time: fill(d === 1 ? w.day : w.days, { n: String(d) }) });
46
48
  }
47
49
 
48
- export function when(ts: number, now: number): string {
49
- const d = new Date(ts);
50
- const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
51
- if (ts - now < 6 * DAY) return `${d.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
52
- return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
50
+ // a bad language tag formats like the runtime default rather than failing the page
51
+ export function when(ts: number, now: number, locale?: string): string {
52
+ const format = (o: Intl.DateTimeFormatOptions) => {
53
+ try { return new Intl.DateTimeFormat(locale, o).format(ts); } catch { return new Intl.DateTimeFormat(undefined, o).format(ts); }
54
+ };
55
+ const time = format({ hour: 'numeric', minute: '2-digit' });
56
+ return `${format(ts - now < 6 * DAY ? { weekday: 'short' } : { month: 'short', day: 'numeric' })} ${time}`;
53
57
  }
54
58
 
55
- export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
59
+ export function liveView(s: PublicLiveSnapshot | null, now: number, w: LiveStrings = EN, locale?: string): LiveView | null {
56
60
  if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
57
61
  const name = PROVIDER_NAMES[s.provider] ?? '';
58
62
  const channel = s.channel?.trim() ?? '';
59
63
  const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
60
64
  if (s.status === 'offline') {
61
- return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: 'Watch our channel ↗' };
65
+ return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: w.watchChannel };
62
66
  }
63
- const watchLabel = name ? `Watch on ${name} ↗` : 'Watch now ↗';
67
+ const watchLabel = name ? fill(w.watchOn, { provider: name }) : w.watchNow;
64
68
  if (s.status === 'upcoming') {
65
- const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
69
+ const start = s.startedAt > 0 ? `${when(s.startedAt, now, locale)} · ${until(s.startedAt - now, w)}` : '';
66
70
  return {
67
- ...base, tag: start || 'UPCOMING', kicker: name ? `UPCOMING ON ${name.toUpperCase()}` : 'UPCOMING',
68
- meta: [channel, name && 'scheduled'].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? `Open on ${name} ↗` : 'Open ↗',
71
+ ...base, tag: start || w.upcoming, kicker: name ? fill(w.upcomingOn, { provider: name.toUpperCase() }) : w.upcoming,
72
+ meta: [channel, name && w.scheduled].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? fill(w.openOn, { provider: name }) : w.open,
69
73
  };
70
74
  }
71
- const started = s.startedAt > 0 && s.startedAt <= now ? `started ${ago(now - s.startedAt)}` : '';
75
+ const started = s.startedAt > 0 && s.startedAt <= now ? fill(w.started, { time: ago(now - s.startedAt, w) }) : '';
72
76
  return {
73
- ...base, tag: 'LIVE', kicker: name ? `LIVE ON ${name.toUpperCase()}` : 'LIVE NOW',
77
+ ...base, tag: w.tag, kicker: name ? fill(w.liveOn, { provider: name.toUpperCase() }) : w.liveNow,
74
78
  meta: [channel, started].filter(Boolean).join(' · '),
75
- viewers: typeof s.viewers === 'number' ? `${compactCount(s.viewers)} watching` : '',
79
+ viewers: typeof s.viewers === 'number' ? fill(w.watching, { count: compactCount(s.viewers) }) : '',
76
80
  watchLabel,
77
81
  };
78
82
  }
@@ -0,0 +1,94 @@
1
+ // Words the website's live surfaces show: the live card (view.ts, LiveCard)
2
+ // and the site-wide bar, header marker and dock (Site.astro). Plain strings
3
+ // with {provider}, {time}, {count}, {n} and {title} slots, so LiveCard can
4
+ // hand them to its browser script as JSON. Spanish matches the app's words
5
+ // (packages/native strings.ts) where they overlap. Other languages fall back
6
+ // to English.
7
+ export interface LiveStrings {
8
+ /** poster tag while live */
9
+ tag: string;
10
+ /** above the title: without a provider name, then with it (upper-cased) */
11
+ liveNow: string;
12
+ liveOn: string;
13
+ upcoming: string;
14
+ upcomingOn: string;
15
+ watchOn: string;
16
+ watchNow: string;
17
+ openOn: string;
18
+ open: string;
19
+ watchChannel: string;
20
+ /** after the channel name of an upcoming broadcast */
21
+ scheduled: string;
22
+ /** after the channel name while live: "started {time}" with {time} from `ago` */
23
+ started: string;
24
+ justNow: string;
25
+ ago: string;
26
+ startingNow: string;
27
+ in: string;
28
+ day: string;
29
+ days: string;
30
+ watching: string;
31
+ notLive: string;
32
+ /** the card's label and the player's title when the stream has none */
33
+ video: string;
34
+ playHere: string;
35
+ play: string;
36
+ /** under the in-page player button; {provider} falls back to `platform` */
37
+ privacy: string;
38
+ platform: string;
39
+ /** site-wide signals */
40
+ bar: string;
41
+ watch: string;
42
+ dismissBar: string;
43
+ marker: string;
44
+ dismissDock: string;
45
+ }
46
+
47
+ const STRINGS: Record<string, LiveStrings> = {
48
+ en: {
49
+ tag: 'LIVE', liveNow: 'LIVE NOW', liveOn: 'LIVE ON {provider}', upcoming: 'UPCOMING', upcomingOn: 'UPCOMING ON {provider}',
50
+ watchOn: 'Watch on {provider} ↗', watchNow: 'Watch now ↗', openOn: 'Open on {provider} ↗', open: 'Open ↗', watchChannel: 'Watch our channel ↗',
51
+ scheduled: 'scheduled', started: 'started {time}', justNow: 'just now', ago: '{time} ago', startingNow: 'starting now', in: 'in {time}', day: '{n} day', days: '{n} days',
52
+ watching: '{count} watching', notLive: 'Not live right now', video: 'Live video', playHere: 'Play here', play: 'Play {title} here',
53
+ privacy: 'Playing loads video from {provider}.', platform: 'the platform',
54
+ bar: 'Live now', watch: 'Watch →', dismissBar: 'Dismiss live notice', marker: 'Live', dismissDock: 'Dismiss live player',
55
+ },
56
+ es: {
57
+ tag: 'EN VIVO', liveNow: 'EN VIVO AHORA', liveOn: 'EN VIVO EN {provider}', upcoming: 'PRÓXIMAMENTE', upcomingOn: 'PRÓXIMAMENTE EN {provider}',
58
+ watchOn: 'Ver en {provider} ↗', watchNow: 'Ver ahora ↗', openOn: 'Abrir en {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nuestro canal ↗',
59
+ scheduled: 'programado', started: 'empezó {time}', justNow: 'hace un momento', ago: 'hace {time}', startingNow: 'empieza ahora', in: 'en {time}', day: '{n} día', days: '{n} días',
60
+ watching: '{count} viendo', notLive: 'No estamos en vivo ahora', video: 'Video en vivo', playHere: 'Reproducir aquí', play: 'Reproducir {title} aquí',
61
+ privacy: 'Al reproducir, el video se carga desde {provider}.', platform: 'la plataforma',
62
+ bar: 'En vivo', watch: 'Ver →', dismissBar: 'Cerrar aviso', marker: 'En vivo', dismissDock: 'Cerrar reproductor',
63
+ },
64
+ fr: {
65
+ tag: 'EN DIRECT', liveNow: 'EN DIRECT', liveOn: 'EN DIRECT SUR {provider}', upcoming: 'À VENIR', upcomingOn: 'BIENTÔT SUR {provider}',
66
+ watchOn: 'Regarder sur {provider} ↗', watchNow: 'Regarder ↗', openOn: 'Ouvrir sur {provider} ↗', open: 'Ouvrir ↗', watchChannel: 'Voir notre chaîne ↗',
67
+ scheduled: 'programmé', started: 'a commencé {time}', justNow: 'à l’instant', ago: 'il y a {time}', startingNow: 'commence maintenant', in: 'dans {time}', day: '{n} jour', days: '{n} jours',
68
+ watching: '{count} spectateurs', notLive: 'Pas en direct pour le moment', video: 'Vidéo en direct', playHere: 'Lire ici', play: 'Lire {title} ici',
69
+ privacy: 'La lecture charge la vidéo depuis {provider}.', platform: 'la plateforme',
70
+ bar: 'En direct', watch: 'Regarder →', dismissBar: 'Fermer l’avis', marker: 'En direct', dismissDock: 'Fermer le lecteur',
71
+ },
72
+ de: {
73
+ tag: 'LIVE', liveNow: 'JETZT LIVE', liveOn: 'LIVE AUF {provider}', upcoming: 'DEMNÄCHST', upcomingOn: 'DEMNÄCHST AUF {provider}',
74
+ watchOn: 'Auf {provider} ansehen ↗', watchNow: 'Jetzt ansehen ↗', openOn: 'Auf {provider} öffnen ↗', open: 'Öffnen ↗', watchChannel: 'Zu unserem Kanal ↗',
75
+ scheduled: 'geplant', started: 'begann {time}', justNow: 'gerade eben', ago: 'vor {time}', startingNow: 'beginnt jetzt', in: 'in {time}', day: '{n} Tag', days: '{n} Tagen',
76
+ watching: '{count} Zuschauer', notLive: 'Gerade nicht live', video: 'Livevideo', playHere: 'Hier abspielen', play: '{title} hier abspielen',
77
+ privacy: 'Beim Abspielen wird das Video von {provider} geladen.', platform: 'der Plattform',
78
+ bar: 'Jetzt live', watch: 'Ansehen →', dismissBar: 'Hinweis schließen', marker: 'Live', dismissDock: 'Player schließen',
79
+ },
80
+ pt: {
81
+ tag: 'AO VIVO', liveNow: 'AO VIVO AGORA', liveOn: 'AO VIVO NO {provider}', upcoming: 'EM BREVE', upcomingOn: 'EM BREVE NO {provider}',
82
+ watchOn: 'Assistir no {provider} ↗', watchNow: 'Assistir agora ↗', openOn: 'Abrir no {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nosso canal ↗',
83
+ scheduled: 'agendado', started: 'começou {time}', justNow: 'agora mesmo', ago: 'há {time}', startingNow: 'começando agora', in: 'em {time}', day: '{n} dia', days: '{n} dias',
84
+ watching: '{count} assistindo', notLive: 'Não estamos ao vivo agora', video: 'Vídeo ao vivo', playHere: 'Reproduzir aqui', play: 'Reproduzir {title} aqui',
85
+ privacy: 'Ao reproduzir, o vídeo é carregado de {provider}.', platform: 'uma plataforma externa',
86
+ bar: 'Ao vivo', watch: 'Assistir →', dismissBar: 'Fechar aviso', marker: 'Ao vivo', dismissDock: 'Fechar player',
87
+ },
88
+ };
89
+
90
+ export const liveStrings = (locale: string | null | undefined): LiveStrings =>
91
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
92
+
93
+ /** "Watch on {provider}" → "Watch on YouTube"; unknown slots stay as written */
94
+ export const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);
@@ -101,10 +101,11 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
101
101
  const now = Date.now();
102
102
  const settings = await repo.getSettings();
103
103
  const expo = expoOptions(locals);
104
- const [stats, topics, calendars, links, log] = await Promise.all([
104
+ const [stats, topics, calendars, links, log, tags] = await Promise.all([
105
105
  repo.push.stats(now), pushTopics(repo),
106
106
  CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
107
107
  linkTargets(repo), repo.push.listNotifications(50),
108
+ CALENDAR.enabled ? repo.listTags() : Promise.resolve([]),
108
109
  ]);
109
110
  return {
110
111
  enabled: PUSH.enabled, retentionDays: PUSH.retentionDays,
@@ -115,6 +116,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
115
116
  // "we're live" needs live video on (Integrations → Live video)
116
117
  liveMode: settings.live.mode,
117
118
  calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
119
+ // suggestions for the event-change rule's "Only events tagged"
120
+ tags: tags.map((t) => t.name),
118
121
  expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
119
122
  };
120
123
  }
@@ -152,11 +155,20 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
152
155
  const collections = new Set(Object.keys(CONTENT.collections));
153
156
  const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
154
157
  const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
158
+ const changes = input.auto.eventChanges;
159
+ const tags = changes?.tags ?? [];
160
+ const known = await knownTopics(repo);
155
161
  const auto = {
156
162
  newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
157
163
  newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
158
164
  startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
159
165
  live: { enabled: input.auto.live?.enabled === true },
166
+ eventChanges: CALENDAR.enabled ? {
167
+ calendars: cals(changes?.calendars ?? []),
168
+ // one of each tag, whatever its case (matching ignores case too)
169
+ tags: tags.filter((t, i) => tags.findIndex((x) => x.toLowerCase() === t.toLowerCase()) === i),
170
+ topics: [...new Set(changes?.topics ?? [])].filter((t) => known.has(t)),
171
+ } : { calendars: [], tags: [], topics: [] },
160
172
  };
161
173
  await repo.updateSettings({ push: { auto } });
162
174
  return { auto };