@acms-subir/core 0.3.34 → 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.
@@ -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';
@@ -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 };
@@ -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") and receipts.
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 { I18N } from '../i18n';
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 { PushNotification, PushSource, PushTopic } from './types';
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
- const TOPIC_WORDS: Record<string, { news: string; events: string; live: string }> = {
27
- en: { news: 'News', events: 'Events', live: 'Live video' },
28
- es: { news: 'Noticias', events: 'Eventos', live: 'Video en vivo' },
29
- fr: { news: 'Actualités', events: 'Événements', live: 'Vidéo en direct' },
30
- de: { news: 'Neuigkeiten', events: 'Veranstaltungen', live: 'Live-Video' },
31
- pt: { news: 'Notícias', events: 'Eventos', live: 'Vídeo ao vivo' },
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 lang = locale || I18N.defaultLocale || (await repo.getSettings()).locale || 'en';
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
- out.push({ id: 'events', label: w.events, kind: 'general' });
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 ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
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
- const STRINGS: Record<string, { startsIn: (m: number) => string; now: string; live: string }> = {
88
- en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live' },
89
- es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo' },
90
- fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct' },
91
- de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live' },
92
- pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo' },
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, timeOnly = false) {
101
- const opts: Intl.DateTimeFormatOptions = timeOnly ? { hour: 'numeric', minute: '2-digit' }
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, true)}${place}`;
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
- /** Sends (or skips) queued first-publish notices. Returns how many were sent. */
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);