@acms-subir/core 0.3.45 → 0.3.47

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/cli/migrations.generated.mjs +117 -16
  3. package/docs/environment-sync.md +6 -0
  4. package/package.json +1 -1
  5. package/src/actions/index.ts +2 -4
  6. package/src/lib/calendar/agent.ts +3 -4
  7. package/src/lib/calendar/cards.ts +9 -6
  8. package/src/lib/calendar/change-migration.ts +65 -0
  9. package/src/lib/calendar/data.ts +25 -9
  10. package/src/lib/calendar/event-words.ts +124 -0
  11. package/src/lib/calendar/feeds.ts +11 -3
  12. package/src/lib/calendar/filter.ts +140 -0
  13. package/src/lib/calendar/hooks.ts +76 -31
  14. package/src/lib/calendar/render.ts +20 -16
  15. package/src/lib/calendar/requests.ts +2 -3
  16. package/src/lib/calendar/schedule.ts +4 -4
  17. package/src/lib/content-service.ts +15 -12
  18. package/src/lib/mobile/calendar.ts +27 -12
  19. package/src/lib/mobile/http.ts +5 -3
  20. package/src/lib/push/service.ts +1 -1
  21. package/src/lib/repo/migrations.ts +4 -0
  22. package/src/lib/repo/sql.ts +29 -0
  23. package/src/lib/repo/types.ts +21 -0
  24. package/src/lib/scheduler.ts +3 -0
  25. package/src/lib/sync/store.ts +4 -0
  26. package/src/middleware.ts +13 -2
  27. package/src/pages/admin/calendar/events/index.astro +4 -9
  28. package/src/pages/api/calendar/docs.ts +2 -0
  29. package/src/pages/api/calendar/events/[id].ts +3 -3
  30. package/src/pages/api/calendar/events/index.ts +18 -12
  31. package/src/pages/api/webhooks/docs.ts +5 -0
  32. package/src/pages/calendar/[slug].ics.ts +8 -2
  33. package/src/pages/calendar/feed.ics.ts +9 -2
  34. package/src/pages/calendar/index.astro +104 -48
  35. package/src/pages/embed/calendar/[slug].astro +29 -9
  36. package/src/styles/site.css +10 -0
@@ -4,6 +4,8 @@
4
4
  // describeRepeat). Plain strings with {date} and {place} slots (live/words.ts
5
5
  // `fill`); the repeat line is built by functions, since word order differs
6
6
  // per language. No server imports: the event editor shares the repeat line.
7
+ // `calendar` holds the words of the /calendar page, embeds and the calendar
8
+ // card (render.ts).
7
9
  // The event's own words (title, button, fields) stay as written. Spanish
8
10
  // matches the push notices ("Cancelado el …"). Other languages fall back to
9
11
  // English.
@@ -35,6 +37,58 @@ export interface EventStrings {
35
37
  fullCalendar: string;
36
38
  allDay: string;
37
39
  repeat: RepeatWords;
40
+ calendar: CalendarWords;
41
+ }
42
+
43
+ /** The calendar page, its filters, embeds and the calendar card. */
44
+ export interface CalendarWords {
45
+ title: string;
46
+ /** under the title; {site} is the site's name */
47
+ lede: string;
48
+ /** landmark labels */
49
+ calendars: string;
50
+ filters: string;
51
+ views: string;
52
+ nextTwoWeeks: string;
53
+ agenda: string;
54
+ /** the chip for every calendar */
55
+ all: string;
56
+ month: string;
57
+ week: string;
58
+ list: string;
59
+ /** a filter chip: "Without Class", "Room: Chapel" (a tag chip is the tag alone) */
60
+ without: string;
61
+ room: string;
62
+ /** the × on a chip, for screen readers */
63
+ remove: string;
64
+ clear: string;
65
+ subscribe: string;
66
+ subscribeAll: string;
67
+ subscribeNamed: string;
68
+ subscribeFiltered: string;
69
+ copyLink: string;
70
+ comingUp: string;
71
+ /** the footer: {zone} is "Eastern Daylight Time" in the page's language */
72
+ timesIn: string;
73
+ feedErrors: string;
74
+ empty: string;
75
+ emptySoon: string;
76
+ noMatch: string;
77
+ /** a confirmed booking on the public calendar: "Reserved · {room}" */
78
+ reserved: string;
79
+ untitled: string;
80
+ /** the grid's toolbar and pop-up */
81
+ today: string;
82
+ previous: string;
83
+ next: string;
84
+ upcoming: string;
85
+ noUpcoming: string;
86
+ details: string;
87
+ close: string;
88
+ /** the calendar card's own messages */
89
+ noSource: string;
90
+ unavailable: string;
91
+ openGoogle: string;
38
92
  }
39
93
 
40
94
  /** Weekdays are 0 = Sunday; `n` is the interval (every n days/weeks/months/years). */
@@ -90,6 +144,20 @@ const STRINGS: Record<string, EventStrings> = {
90
144
  until: (d) => `, until ${d}`,
91
145
  other: 'Repeats',
92
146
  },
147
+ calendar: {
148
+ title: 'Calendar', lede: 'What’s happening at {site}. Subscribe to keep it in your own calendar.',
149
+ calendars: 'Calendars', filters: 'Filters', views: 'View', nextTwoWeeks: 'Next two weeks', agenda: 'Agenda',
150
+ all: 'All', month: 'Month', week: 'Week', list: 'List',
151
+ without: 'Without {tag}', room: 'Room: {room}', remove: 'Remove {filter}', clear: 'Clear filters',
152
+ subscribe: 'Subscribe', subscribeAll: 'Add this calendar to your calendar app. New events show up on their own.',
153
+ subscribeNamed: 'Add “{name}” to your calendar app. New events show up on their own.',
154
+ subscribeFiltered: 'Add these events to your calendar app. New ones show up on their own.',
155
+ copyLink: 'Copy link', comingUp: 'Coming up', timesIn: 'Times in {zone}', feedErrors: 'Some linked calendars couldn’t be loaded right now.',
156
+ empty: 'Nothing on the calendar yet.', emptySoon: 'Nothing in the next two months.', noMatch: 'Nothing matches these filters.',
157
+ reserved: 'Reserved', untitled: 'Untitled event',
158
+ today: 'Today', previous: 'Previous', next: 'Next', upcoming: 'Upcoming', noUpcoming: 'No upcoming events.', details: 'Details', close: 'Close',
159
+ noSource: 'No calendar connected.', unavailable: 'Calendar unavailable right now.', openGoogle: 'Open in Google Calendar ↗',
160
+ },
93
161
  },
94
162
  es: {
95
163
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
@@ -108,6 +176,20 @@ const STRINGS: Record<string, EventStrings> = {
108
176
  until: (d) => `, hasta el ${d}`,
109
177
  other: 'Se repite',
110
178
  },
179
+ calendar: {
180
+ title: 'Calendario', lede: 'Lo que pasa en {site}. Suscríbete para tenerlo en tu propio calendario.',
181
+ calendars: 'Calendarios', filters: 'Filtros', views: 'Vista', nextTwoWeeks: 'Próximas dos semanas', agenda: 'Agenda',
182
+ all: 'Todos', month: 'Mes', week: 'Semana', list: 'Lista',
183
+ without: 'Sin {tag}', room: 'Sala: {room}', remove: 'Quitar {filter}', clear: 'Quitar filtros',
184
+ subscribe: 'Suscribirse', subscribeAll: 'Agrega este calendario a tu app de calendario. Los eventos nuevos aparecen solos.',
185
+ subscribeNamed: 'Agrega “{name}” a tu app de calendario. Los eventos nuevos aparecen solos.',
186
+ subscribeFiltered: 'Agrega estos eventos a tu app de calendario. Los nuevos aparecen solos.',
187
+ copyLink: 'Copiar enlace', comingUp: 'Próximamente', timesIn: 'Horario de {zone}', feedErrors: 'Algunos calendarios vinculados no se pudieron cargar en este momento.',
188
+ empty: 'Todavía no hay nada en el calendario.', emptySoon: 'Nada en los próximos dos meses.', noMatch: 'Nada coincide con estos filtros.',
189
+ reserved: 'Reservado', untitled: 'Evento sin título',
190
+ today: 'Hoy', previous: 'Anterior', next: 'Siguiente', upcoming: 'Próximos', noUpcoming: 'No hay eventos próximos.', details: 'Detalles', close: 'Cerrar',
191
+ noSource: 'No hay ningún calendario conectado.', unavailable: 'El calendario no está disponible en este momento.', openGoogle: 'Abrir en Google Calendar ↗',
192
+ },
111
193
  },
112
194
  fr: {
113
195
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
@@ -126,6 +208,20 @@ const STRINGS: Record<string, EventStrings> = {
126
208
  until: (d) => `, jusqu’au ${d}`,
127
209
  other: 'Récurrent',
128
210
  },
211
+ calendar: {
212
+ title: 'Calendrier', lede: 'Ce qui se passe à {site}. Abonnez-vous pour le retrouver dans votre propre calendrier.',
213
+ calendars: 'Calendriers', filters: 'Filtres', views: 'Affichage', nextTwoWeeks: 'Les deux prochaines semaines', agenda: 'Agenda',
214
+ all: 'Tous', month: 'Mois', week: 'Semaine', list: 'Liste',
215
+ without: 'Sans {tag}', room: 'Salle : {room}', remove: 'Retirer {filter}', clear: 'Effacer les filtres',
216
+ subscribe: 'S’abonner', subscribeAll: 'Ajoutez ce calendrier à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
217
+ subscribeNamed: 'Ajoutez « {name} » à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
218
+ subscribeFiltered: 'Ajoutez ces événements à votre application de calendrier. Les nouveaux s’y ajoutent d’eux-mêmes.',
219
+ copyLink: 'Copier le lien', comingUp: 'À venir', timesIn: 'Heures : {zone}', feedErrors: 'Certains calendriers liés n’ont pas pu être chargés pour le moment.',
220
+ empty: 'Rien au calendrier pour l’instant.', emptySoon: 'Rien dans les deux prochains mois.', noMatch: 'Rien ne correspond à ces filtres.',
221
+ reserved: 'Réservé', untitled: 'Événement sans titre',
222
+ today: 'Aujourd’hui', previous: 'Précédent', next: 'Suivant', upcoming: 'À venir', noUpcoming: 'Aucun événement à venir.', details: 'Détails', close: 'Fermer',
223
+ noSource: 'Aucun calendrier connecté.', unavailable: 'Calendrier indisponible pour le moment.', openGoogle: 'Ouvrir dans Google Agenda ↗',
224
+ },
129
225
  },
130
226
  de: {
131
227
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
@@ -144,6 +240,20 @@ const STRINGS: Record<string, EventStrings> = {
144
240
  until: (d) => `, bis zum ${d}`,
145
241
  other: 'Wiederkehrend',
146
242
  },
243
+ calendar: {
244
+ title: 'Kalender', lede: 'Was bei {site} los ist. Abonnieren Sie den Kalender, um alles in Ihrem eigenen Kalender zu haben.',
245
+ calendars: 'Kalender', filters: 'Filter', views: 'Ansicht', nextTwoWeeks: 'Die nächsten zwei Wochen', agenda: 'Terminliste',
246
+ all: 'Alle', month: 'Monat', week: 'Woche', list: 'Liste',
247
+ without: 'Ohne {tag}', room: 'Raum: {room}', remove: '{filter} entfernen', clear: 'Filter löschen',
248
+ subscribe: 'Abonnieren', subscribeAll: 'Fügen Sie diesen Kalender Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
249
+ subscribeNamed: 'Fügen Sie „{name}“ Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
250
+ subscribeFiltered: 'Fügen Sie diese Veranstaltungen Ihrer Kalender-App hinzu. Neue erscheinen automatisch.',
251
+ copyLink: 'Link kopieren', comingUp: 'Demnächst', timesIn: 'Zeiten in {zone}', feedErrors: 'Einige verknüpfte Kalender konnten gerade nicht geladen werden.',
252
+ empty: 'Noch nichts im Kalender.', emptySoon: 'In den nächsten zwei Monaten steht nichts an.', noMatch: 'Nichts passt zu diesen Filtern.',
253
+ reserved: 'Reserviert', untitled: 'Unbenannte Veranstaltung',
254
+ today: 'Heute', previous: 'Zurück', next: 'Weiter', upcoming: 'Demnächst', noUpcoming: 'Keine anstehenden Veranstaltungen.', details: 'Details', close: 'Schließen',
255
+ noSource: 'Kein Kalender verbunden.', unavailable: 'Kalender gerade nicht verfügbar.', openGoogle: 'In Google Kalender öffnen ↗',
256
+ },
147
257
  },
148
258
  pt: {
149
259
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
@@ -164,6 +274,20 @@ const STRINGS: Record<string, EventStrings> = {
164
274
  until: (d) => `, até ${d}`,
165
275
  other: 'Repete',
166
276
  },
277
+ calendar: {
278
+ title: 'Calendário', lede: 'O que acontece em {site}. Assine para ter tudo no seu próprio calendário.',
279
+ calendars: 'Calendários', filters: 'Filtros', views: 'Visualização', nextTwoWeeks: 'Próximas duas semanas', agenda: 'Agenda',
280
+ all: 'Todos', month: 'Mês', week: 'Semana', list: 'Lista',
281
+ without: 'Sem {tag}', room: 'Sala: {room}', remove: 'Remover {filter}', clear: 'Limpar filtros',
282
+ subscribe: 'Assinar', subscribeAll: 'Adicione este calendário ao seu app de calendário. Os novos eventos aparecem sozinhos.',
283
+ subscribeNamed: 'Adicione “{name}” ao seu app de calendário. Os novos eventos aparecem sozinhos.',
284
+ subscribeFiltered: 'Adicione estes eventos ao seu app de calendário. Os novos aparecem sozinhos.',
285
+ copyLink: 'Copiar link', comingUp: 'Em breve', timesIn: 'Horário de {zone}', feedErrors: 'Alguns calendários vinculados não puderam ser carregados agora.',
286
+ empty: 'Ainda não há nada no calendário.', emptySoon: 'Nada nos próximos dois meses.', noMatch: 'Nada corresponde a estes filtros.',
287
+ reserved: 'Reservado', untitled: 'Evento sem título',
288
+ today: 'Hoje', previous: 'Anterior', next: 'Próximo', upcoming: 'Próximos', noUpcoming: 'Nenhum evento programado.', details: 'Detalhes', close: 'Fechar',
289
+ noSource: 'Nenhum calendário conectado.', unavailable: 'Calendário indisponível no momento.', openGoogle: 'Abrir no Google Agenda ↗',
290
+ },
167
291
  },
168
292
  };
169
293
 
@@ -5,6 +5,7 @@ import type { CalEvent, Calendar, Settings } from '../schema';
5
5
  import { buildFeed } from './ics-write';
6
6
  import { eventIsListed, eventIsPublic } from './events';
7
7
  import { eventUrl } from './config';
8
+ import { eventMatches, type ResolvedEventFilter } from './filter';
8
9
 
9
10
  export function icsResponse(body: string, filename: string, cacheSeconds = 300): Response {
10
11
  return new Response(body, {
@@ -21,15 +22,22 @@ export async function feedFor(
21
22
  repo: ContentRepository,
22
23
  settings: Settings,
23
24
  origin: string,
24
- scope: { calendar?: Calendar; allowPrivate?: boolean }
25
+ // filter: the address's cal/tag/notag/room (filter.ts); its calendars
26
+ // stand in for `calendar` on the site-wide feed
27
+ scope: { calendar?: Calendar; allowPrivate?: boolean; filter?: ResolvedEventFilter }
25
28
  ): Promise<{ events: CalEvent[]; body: string }> {
26
29
  const [published, calendars] = await Promise.all([repo.listPublishedEvents(), repo.listCalendars()]);
27
30
  const map = new Map(calendars.map((c) => [c.id, c]));
28
31
  const c = scope.calendar;
32
+ const f = scope.filter;
29
33
  const events = published.filter((e) =>
30
- c
34
+ (c
31
35
  ? e.calendarIds.includes(c.id) && (scope.allowPrivate || eventIsPublic(e, map) || c.visibility !== 'private')
32
- : eventIsListed(e, map)
36
+ : f?.calendarIds
37
+ ? eventIsPublic(e, map)
38
+ : eventIsListed(e, map))
39
+ // a feed lists series, so rooms are the series' own
40
+ && (!f || eventMatches(f, e))
33
41
  );
34
42
  const host = new URL(origin).host;
35
43
  const body = buildFeed(events, settings.timezone, {
@@ -0,0 +1,140 @@
1
+ // Which events a calendar view shows, read from its address. The /calendar
2
+ // page, embeds, /api/v1/events, /api/calendar/events, the subscribe feeds
3
+ // and the TV display all take the same parameters and apply the same rules:
4
+ // cal=youth,events only these calendars (slugs); `calendar` is an alias
5
+ // tag=Class only events with one of these tags
6
+ // notag=Class never events with any of these; `excludeTag` is an alias
7
+ // room=chapel only events held in one of these rooms (resource slugs)
8
+ // Each can repeat (tag=a&tag=b) or be comma-separated (tag=a,b). A list
9
+ // matches any of its values; different parameters must all match. Tags keep
10
+ // the hasTag rules (a name in any case, or its slug); internal "#" tags only
11
+ // match with allowPrivate. Publicly, private calendars and rooms not shown on
12
+ // public events resolve exactly like slugs that don't exist, so a link can't
13
+ // probe for them.
14
+ import { hasTag, publicTags, type CalEvent, type Calendar, type Resource } from '../schema';
15
+
16
+ export interface EventFilter {
17
+ cal: string[];
18
+ tag: string[];
19
+ notag: string[];
20
+ room: string[];
21
+ }
22
+
23
+ export const NO_FILTER: EventFilter = Object.freeze({ cal: [], tag: [], notag: [], room: [] }) as EventFilter;
24
+
25
+ const NAMES: Record<keyof EventFilter, string[]> = {
26
+ cal: ['cal', 'calendar'],
27
+ tag: ['tag'],
28
+ notag: ['notag', 'excludeTag'],
29
+ room: ['room'],
30
+ };
31
+ const MAX_VALUES = 20;
32
+ const MAX_LENGTH = 100;
33
+
34
+ /** the filter in an address's query (`Astro.url.searchParams`, `url.searchParams`) */
35
+ export function readEventFilter(params: URLSearchParams): EventFilter {
36
+ const read = (names: string[]) => {
37
+ const out: string[] = [];
38
+ for (const name of names) for (const raw of params.getAll(name)) for (const part of raw.split(',')) {
39
+ const v = part.trim().slice(0, MAX_LENGTH);
40
+ if (v && out.length < MAX_VALUES && !out.some((o) => o.toLowerCase() === v.toLowerCase())) out.push(v);
41
+ }
42
+ return out;
43
+ };
44
+ return { cal: read(NAMES.cal), tag: read(NAMES.tag), notag: read(NAMES.notag), room: read(NAMES.room) };
45
+ }
46
+
47
+ export const hasEventFilter = (f: EventFilter) => f.cal.length + f.tag.length + f.notag.length + f.room.length > 0;
48
+
49
+ /**
50
+ * The filter as a query string without the "?" ('' when empty), in the
51
+ * canonical names with comma-separated values: "cal=events&notag=Class".
52
+ * For links, subscribe feeds and API calls that should show the same events.
53
+ */
54
+ export function eventFilterQuery(f: EventFilter): string {
55
+ return (Object.keys(NAMES) as (keyof EventFilter)[])
56
+ .filter((k) => f[k].length)
57
+ .map((k) => `${k}=${f[k].map(encodeURIComponent).join(',')}`)
58
+ .join('&');
59
+ }
60
+
61
+ export interface ResolvedEventFilter {
62
+ /** null: no calendar filter; [] when `cal` was given but none resolved (the view is then empty) */
63
+ calendarIds: string[] | null;
64
+ /** null: no room filter; [] when `room` was given but none resolved */
65
+ roomIds: string[] | null;
66
+ /** the calendars and rooms that resolved, in the order the address gave them */
67
+ calendars: Calendar[];
68
+ rooms: Resource[];
69
+ tag: string[];
70
+ notag: string[];
71
+ /** internal "#" tags match (allowPrivate) */
72
+ internal: boolean;
73
+ /** some `cal` or `room` value didn't resolve (unknown, private or hidden) */
74
+ unknown: boolean;
75
+ }
76
+
77
+
78
+ /**
79
+ * Turns slugs into ids. Publicly, unlisted calendars resolve (as a direct
80
+ * `?cal=` link always has) but private ones don't, and only rooms shown on
81
+ * public events resolve. allowPrivate (API keys with events:read, saved
82
+ * screens) resolves every calendar and room and lets "#" tags match.
83
+ */
84
+ export function resolveEventFilter(
85
+ f: EventFilter,
86
+ ctx: { calendars: Calendar[]; resources?: Resource[]; allowPrivate?: boolean }
87
+ ): ResolvedEventFilter {
88
+ const allowPrivate = ctx.allowPrivate ?? false;
89
+ const pick = <T extends { id: string; slug: string }>(slugs: string[], items: T[], visible: (item: T) => boolean) => {
90
+ const found: T[] = [];
91
+ for (const s of slugs) {
92
+ const item = items.find((i) => i.slug === s.toLowerCase() && visible(i));
93
+ if (item && !found.includes(item)) found.push(item);
94
+ }
95
+ return found;
96
+ };
97
+ const calendars = pick(f.cal, ctx.calendars, (c) => allowPrivate || c.visibility !== 'private');
98
+ const rooms = pick(f.room, ctx.resources ?? [], (r) => allowPrivate || r.showOnEvents);
99
+ return {
100
+ calendarIds: f.cal.length ? calendars.map((c) => c.id) : null,
101
+ roomIds: f.room.length ? rooms.map((r) => r.id) : null,
102
+ calendars, rooms,
103
+ tag: f.tag, notag: f.notag,
104
+ internal: allowPrivate,
105
+ unknown: calendars.length < f.cal.length || rooms.length < f.room.length,
106
+ };
107
+ }
108
+
109
+ /** `tag` (any of them, when given) and no `notag`; "#" tags only count when internal */
110
+ export function tagsMatch(f: Pick<EventFilter, 'tag' | 'notag'>, names: string[], opts: { internal?: boolean } = {}): boolean {
111
+ const shown = opts.internal ? names : publicTags(names);
112
+ return (!f.tag.length || f.tag.some((t) => hasTag(shown, t))) && !f.notag.some((t) => hasTag(shown, t));
113
+ }
114
+
115
+ const overlaps = (wanted: string[] | null, have: string[]) => wanted === null || have.some((id) => wanted.includes(id));
116
+
117
+ /**
118
+ * Does one event pass? `roomIds` are the rooms the date holds: the event's
119
+ * room hold unless a moved date changed them. Visibility (drafts, private
120
+ * calendars) is the caller's job; this only applies the filter.
121
+ */
122
+ export function eventMatches(r: ResolvedEventFilter, e: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>, roomIds: string[] = e.roomHold.resourceIds): boolean {
123
+ return overlaps(r.calendarIds, e.calendarIds) && overlaps(r.roomIds, roomIds) && tagsMatch(r, e.tags, { internal: r.internal });
124
+ }
125
+
126
+ /** eventMatches for one occurrence, using the rooms that date holds */
127
+ export function occurrenceMatches(r: ResolvedEventFilter, o: { event: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>; roomIds?: string[] }): boolean {
128
+ return eventMatches(r, o.event, o.roomIds ?? o.event.roomHold.resourceIds);
129
+ }
130
+
131
+ /**
132
+ * Entries without an event (feed calendars, Reserved blocks) have no tags:
133
+ * `tag` leaves them out and `notag` keeps them. A Reserved block passes a
134
+ * room filter for its own room; feed entries never do.
135
+ */
136
+ export function untaggedMatches(r: ResolvedEventFilter, opts: { calendarId?: string; roomId?: string } = {}): boolean {
137
+ if (r.tag.length) return false;
138
+ if (r.roomIds !== null && !(opts.roomId && r.roomIds.includes(opts.roomId))) return false;
139
+ return r.calendarIds === null || Boolean(opts.calendarId && r.calendarIds.includes(opts.calendarId));
140
+ }
@@ -1,42 +1,87 @@
1
- // Side effects when an event changes live: a 301 when a published event's
2
- // slug moves, a queued "event changed" app notice when its schedule or place
3
- // moves, and the matching webhook (event.published / event.updated /
4
- // event.unpublished). Shared by the editor actions, the list's bulk actions
5
- // and the agent tools so every path behaves the same.
6
- import type { ContentRepository } from '../repo/types';
7
- import type { CalEvent } from '../schema';
1
+ // Side effects when a published event changes: a 301 when its slug moves, a
2
+ // queued "event changed" app notice when its schedule or place moves, and the
3
+ // matching webhook (event.published / event.updated / event.unpublished /
4
+ // event.deleted). Every write path records its changes in event_changes
5
+ // (triggers, ./change-migration.ts) in the same transaction, so this is the
6
+ // one place they become side effects, whoever made them. Editor paths flush
7
+ // at once (the redirect works on the very next request); the flush after
8
+ // every mutating request and the scheduler catch the rest, raw SQL included
9
+ // (scheduled publishing, environment sync, place refreshes).
10
+ import type { ContentRepository, EventChange } from '../repo/types';
11
+ import type { Calendar, Resource, Settings } from '../schema';
8
12
  import { emitWebhook } from '../webhooks';
9
- import { queueEventChange } from '../push/service';
13
+ import { queueEventChange, siteOriginOf } from '../push/service';
10
14
  import { eventToJson } from './api';
11
15
  import { eventUrl } from './config';
12
16
 
13
- export async function eventChanged(
14
- repo: ContentRepository,
15
- before: CalEvent,
16
- after: CalEvent,
17
- opts: { origin: string; waitUntil?: (p: Promise<unknown>) => void; applied?: boolean; onRedirect?: () => void }
18
- ): Promise<void> {
19
- if (before.status === 'published' && after.status === 'published' && before.slug !== after.slug) {
17
+ export interface FlushOptions {
18
+ /** this request's origin, for webhook links while the site URL isn't set */
19
+ origin?: string;
20
+ waitUntil?: (p: Promise<unknown>) => void;
21
+ /** called when a slug redirect was added (the middleware drops its cached rules) */
22
+ onRedirect?: () => void;
23
+ now?: number;
24
+ }
25
+
26
+ const BATCH = 50;
27
+ const ROUNDS = 20;
28
+ /** how long a claim holds its changes; one that failed is retried after it */
29
+ const LEASE = 5 * 60_000;
30
+ /** a change that failed this many times is dropped */
31
+ const MAX_ATTEMPTS = 3;
32
+
33
+ interface Site { origin: string; settings: Settings; calendars: Map<string, Calendar>; resources: Map<string, Resource> }
34
+
35
+ // One write, as webhooks for posts report them: several writes in one save
36
+ // send one webhook each. The push queue keeps what people last knew, so
37
+ // several changes close together still send one notice.
38
+ async function apply(repo: ContentRepository, change: EventChange, now: number, site: () => Promise<Site>, opts: FlushOptions) {
39
+ const { before, after } = change;
40
+ const was = before?.status === 'published';
41
+ if (was && after?.status === 'published' && before.slug !== after.slug) {
20
42
  await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
21
43
  opts.onRedirect?.();
22
44
  }
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));
25
- let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
26
- if (before.status !== 'published' && after.status === 'published') type = 'event.published';
27
- else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
28
- else if (after.status === 'published' && (opts.applied || before.updatedAt !== after.updatedAt)) type = 'event.updated';
45
+ // the scheduler sends it once the edits settle, if the rule still applies;
46
+ // a bulk copy (quiet) sends webhooks and redirects, never pushes
47
+ if (before && after && !change.quiet) await queueEventChange(repo, before, after, now).catch((e) => console.error('acms push: event change not queued', e));
48
+ if (!after) {
49
+ if (was) await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, { waitUntil: opts.waitUntil }).catch(() => {});
50
+ return;
51
+ }
52
+ const type = was ? (after.status === 'published' ? 'event.updated' : 'event.unpublished') : after.status === 'published' ? 'event.published' : null;
29
53
  if (!type) return;
30
- const [settings, calendars, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]);
31
- const data = eventToJson(after, opts.origin, new Map(calendars.map((c) => [c.id, c])), settings.timezone, { resources: new Map(rooms.map((r) => [r.id, r])) });
32
- await emitWebhook(repo, type, { event: data }, { waitUntil: opts.waitUntil }).catch(() => {});
54
+ const { origin, settings, calendars, resources } = await site();
55
+ await emitWebhook(repo, type, { event: eventToJson(after, origin, calendars, settings.timezone, { resources }) }, { waitUntil: opts.waitUntil }).catch(() => {});
33
56
  }
34
57
 
35
- export async function eventDeleted(
36
- repo: ContentRepository,
37
- before: CalEvent | null,
38
- opts: { waitUntil?: (p: Promise<unknown>) => void }
39
- ): Promise<void> {
40
- if (before?.status !== 'published') return;
41
- await emitWebhook(repo, 'event.deleted', { event: { id: before.id, slug: before.slug, title: before.title } }, opts).catch(() => {});
58
+ /** Runs the side effects of every recorded change to a published event, oldest first. Returns how many changes it handled. */
59
+ export async function flushEventChanges(repo: ContentRepository, opts: FlushOptions = {}): Promise<number> {
60
+ let site: Promise<Site> | null = null;
61
+ const load = () => (site ??= Promise.all([repo.getSettings(), repo.listCalendars(), repo.listResources()]).then(([settings, calendars, rooms]) => ({
62
+ origin: siteOriginOf(settings, opts.origin), settings, calendars: new Map(calendars.map((c) => [c.id, c])), resources: new Map(rooms.map((r) => [r.id, r])) })));
63
+ let handled = 0;
64
+ for (let round = 0; round < ROUNDS; round++) {
65
+ const now = opts.now ?? Date.now();
66
+ const claimed = await repo.claimEventChanges(now, BATCH, LEASE);
67
+ if (!claimed.length) break;
68
+ const done: number[] = [];
69
+ const failed = new Set<string>();
70
+ for (const change of claimed) {
71
+ // a later change to an event whose earlier one failed waits with it, in order
72
+ if (failed.has(change.eventId)) continue;
73
+ try {
74
+ // a snapshot that no longer parses has nothing to report
75
+ if (change.before || change.after) await apply(repo, change, now, load, opts);
76
+ } catch (e) {
77
+ console.error('acms calendar: event change side effects failed', e);
78
+ if (change.attempts < MAX_ATTEMPTS) { failed.add(change.eventId); continue; }
79
+ }
80
+ done.push(change.seq);
81
+ }
82
+ await repo.finishEventChanges(done);
83
+ handled += done.length;
84
+ if (claimed.length < BATCH) break;
85
+ }
86
+ return handled;
42
87
  }
@@ -11,7 +11,7 @@ import { siteLocale } from '../dates';
11
11
  import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
12
12
  import { calendarPageUrl } from './src';
13
13
  import { collectViewEvents, type ViewEvent } from './data';
14
- import { eventStrings } from './event-words';
14
+ import { eventStrings, type EventStrings } from './event-words';
15
15
 
16
16
  // Card sources are ICS feed URLs or native site calendars:
17
17
  // 'acms:all' every public calendar (+ uncategorized events)
@@ -65,6 +65,7 @@ function compactTime(s: string): string {
65
65
  }
66
66
 
67
67
  function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
68
+ const allDay = eventStrings(locale).allDay;
68
69
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
69
70
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
70
71
  return events.map((e) => {
@@ -73,7 +74,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
73
74
  let tm = '';
74
75
  let rg: string;
75
76
  if (e.allDay) {
76
- rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · All day`;
77
+ rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · ${allDay}`;
77
78
  } else {
78
79
  tm = compactTime(timeFmt.format(e.start));
79
80
  rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
@@ -132,11 +133,11 @@ function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]
132
133
  return `<div class="pc-cal-week">${h}</div>`;
133
134
  }
134
135
 
135
- function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
136
- if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
136
+ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w: EventStrings): string {
137
+ if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
137
138
  const items = order.slice(0, limit).map((i) => {
138
139
  const e = events[i];
139
- return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || 'All day'}</span></button></li>`;
140
+ return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
140
141
  });
141
142
  return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
142
143
  }
@@ -189,7 +190,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
189
190
  var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
190
191
  closePop();
191
192
  pop=document.createElement('div');pop.className='pc-cal-pop';
192
- pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
193
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
193
194
  frame.appendChild(pop);
194
195
  var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
195
196
  pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
@@ -234,7 +235,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
234
235
  (opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
235
236
  (opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
236
237
  const tz = settings.timezone || 'UTC';
238
+ // a page in another language passes its settings with that locale
237
239
  const locale = siteLocale(settings);
240
+ const w = eventStrings(locale);
238
241
  const today = tzParts(now, tz);
239
242
  const [minY, minM] = addMonths(today.y, today.m, -6);
240
243
  const [maxY, maxM] = addMonths(today.y, today.m, 6);
@@ -263,10 +266,10 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
263
266
  titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
264
267
  bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
265
268
  } else if (opts.view === 'upcoming') {
266
- titleText = 'Upcoming';
269
+ titleText = w.calendar.upcoming;
267
270
  const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
268
271
  const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
269
- bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
272
+ bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)), w);
270
273
  if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
271
274
  } else {
272
275
  titleText = `${months[fm - 1]} ${fy}`;
@@ -276,9 +279,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
276
279
  const navButtons =
277
280
  opts.view === 'upcoming'
278
281
  ? ''
279
- : `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
280
- `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
281
- `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
282
+ : `<button type="button" class="pc-cal-btn" data-nav="today">${esc(w.calendar.today)}</button>` +
283
+ `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="${esc(w.calendar.previous)}">‹</button>` +
284
+ `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="${esc(w.calendar.next)}">›</button>`;
282
285
 
283
286
  const footer = opts.footer
284
287
  ? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(opts.footer.href)}" target="_blank" rel="noopener">${esc(opts.footer.label)}</a></div>`
@@ -291,8 +294,8 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
291
294
  min: [minY, minM],
292
295
  max: [maxY, maxM],
293
296
  months, monthsShort, dows, events, ws,
294
- // the pop-up's flag in the dates' language, like event cards
295
- details: 'Details', cancelled: eventStrings(locale).cancelled,
297
+ // the pop-up's words in the dates' language, like event cards
298
+ details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close,
296
299
  };
297
300
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
298
301
 
@@ -319,11 +322,12 @@ export async function renderCalendarBlock(
319
322
  const heading =
320
323
  (a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
321
324
  (a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
325
+ const w = eventStrings(siteLocale(settings)).calendar;
322
326
 
323
327
  const feeds = a.sources.filter((s) => s.startsWith('http'));
324
328
  const natives = repo ? a.sources.filter(isNativeSource) : [];
325
329
  if (!feeds.length && !natives.length) {
326
- return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
330
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.noSource)}</p></div></section>`;
327
331
  }
328
332
 
329
333
  const win = calendarWindow(settings, now);
@@ -346,7 +350,7 @@ export async function renderCalendarBlock(
346
350
  const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
347
351
 
348
352
  if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
349
- return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">Calendar unavailable right now.</p></div></section>`;
353
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.unavailable)}</p></div></section>`;
350
354
  }
351
355
 
352
356
  const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
@@ -355,7 +359,7 @@ export async function renderCalendarBlock(
355
359
  title: a.title,
356
360
  subtitle: a.subtitle,
357
361
  limit: a.limit,
358
- footer: pageUrl ? { href: pageUrl, label: 'Open in Google Calendar ↗' } : undefined,
362
+ footer: pageUrl ? { href: pageUrl, label: w.openGoogle } : undefined,
359
363
  // dots only mean something with 2+ calendars
360
364
  multi: feeds.length + nativeCalendars > 1,
361
365
  });
@@ -2,10 +2,9 @@ import type { ContentRepository } from '../repo/types';
2
2
  import type { CalEvent, EventRequest } from '../schema';
3
3
  import { EventLocation } from '../schema';
4
4
  import { ApiError } from '../api';
5
- import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
5
+ import { applyEventUpdate, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
6
6
  import { CALENDAR } from './config';
7
7
  import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
8
- import { eventDeleted } from './hooks';
9
8
  import { waitUntilFrom } from '../webhooks';
10
9
  import { kickEmail } from '../email/service';
11
10
 
@@ -191,7 +190,7 @@ export async function withdrawEventRequest(repo: ContentRepository, who: Actor |
191
190
  // only an event the member's own request made is deleted; a draft staff wrote stays
192
191
  if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
193
192
  await repo.deleteEvent(id);
194
- await eventDeleted(repo, e, { waitUntil: waitUntilFrom(hooks.locals) });
193
+ await flushEventHooks(repo, hooks);
195
194
  return { deleted: true };
196
195
  }
197
196
  if (e.request.kind === 'changes') await repo.discardEventDraft(id);