@acms-subir/core 0.3.46 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.47
4
+
5
+ ### Patch Changes
6
+
7
+ - 26f2060: The public calendar can be filtered from its address, and speaks the page's language.
8
+
9
+ **Filters.** `/calendar?tag=Class` shows only classes, `/calendar?notag=Class&cal=events` shows the Events calendar without them, and `/calendar?room=chapel` shows what happens in a room. `tag` keeps events with any of its tags, `notag` (alias `excludeTag`) leaves them out, `cal` (alias `calendar`) now takes several calendars, and `room` takes room slugs. Each can repeat (`tag=a&tag=b`) or be comma-separated (`tag=a,b`); different filters must all match. Tags keep their rules (a name in any case, or its slug; internal `#` tags never match publicly). Only rooms shown on public events match, and a private calendar or a hidden room answers exactly like one that doesn't exist, so a link can't probe for them. Events from linked feed calendars and "Reserved" booking blocks have no tags: `tag` leaves them out, `notag` keeps them, and a room filter keeps a room's own Reserved blocks.
10
+
11
+ On `/calendar` the calendar chips now switch calendars on and off, so several can be picked; tags, left-out tags and rooms show as removable chips with **Clear filters**; and view links, chips and **Subscribe** keep the filters. A filtered page subscribes to `/calendar.ics` with the same filters (one calendar alone keeps its own feed). The same parameters work on `/embed/calendar/<slug|all>` (`cal` adds calendars to the one in the address), `/calendar.ics`, `/calendar/<slug>.ics` (`tag`, `notag`, `room`), `/api/v1/events` (now documented in the OpenAPI document; repeating them is allowed, other parameters still may not repeat) and `/api/calendar/events`, where a key with `events:read` also matches private calendars, every room and `#` tags. An unknown, private or hidden calendar or room is a 404 on the APIs and feeds, and an empty calendar with its chip on the page.
12
+
13
+ **Languages.** `/es/calendar` is in Spanish like event pages: the headings, chips, views, Subscribe menu, agenda, empty states, the grid's Today/Upcoming/Details and the dates and time zone. Event titles use their translations, and every link on the page (views, chips, events, cards) stays under `/es`. French, German and Portuguese work the same way. Embeds take `?lang=es` (one of the site's languages). The calendar card ("No calendar connected", "Calendar unavailable right now", "Open in Google Calendar") and event cards ("Untitled event", "No upcoming events") also speak their dates' language.
14
+
3
15
  ## 0.3.46
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.46",
3
+ "version": "0.3.47",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -9,6 +9,7 @@ import { eventImage } from './images';
9
9
  import { eventUrl } from './config';
10
10
  import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
11
11
  import { collectViewEvents } from './data';
12
+ import type { ResolvedEventFilter } from './filter';
12
13
 
13
14
  export interface EventCardAttrs {
14
15
  mode: 'single' | 'upcoming';
@@ -56,7 +57,7 @@ export function eventCardHtml(
56
57
  return (
57
58
  `<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
58
59
  `<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
59
- `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
60
+ `<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || eventStrings(siteLang(settings)).calendar.untitled)}</a></${h}>` +
60
61
  (e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
61
62
  (loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
62
63
  cta +
@@ -65,18 +66,19 @@ export function eventCardHtml(
65
66
  }
66
67
 
67
68
  // next occurrence of each distinct event, soonest first; tag/excludeTag
68
- // narrow by event tag (name or slug, any case)
69
+ // narrow by event tag (name or slug, any case), and filter by an address's
70
+ // cal/tag/notag/room (filter.ts); locale: titles and links in that language
69
71
  export async function upcomingEvents(
70
72
  repo: ContentRepository,
71
73
  settings: Settings,
72
74
  now: number,
73
- opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
75
+ opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string; filter?: ResolvedEventFilter; locale?: string }
74
76
  ) {
75
77
  const tz = settings.timezone || 'UTC';
76
78
  const tagged = tagFilter(opts.tag, opts.excludeTag);
77
79
  const { events } = await collectViewEvents(repo, settings, {
78
80
  displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
79
- }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
81
+ }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
80
82
  const seen = new Set<string>();
81
83
  const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
82
84
  for (const v of events) {
@@ -98,13 +100,14 @@ export async function renderEventCards(a: EventCardAttrs, repo: ContentRepositor
98
100
  if (!o) return wrap('<p class="pc-ev-empty">This event isn’t available.</p>', ' is-empty');
99
101
  return wrap(eventCardHtml(o, settings, { showImage: a.showImage, showCta: true }), ' is-single');
100
102
  }
103
+ const noUpcoming = eventStrings(siteLang(settings)).calendar.noUpcoming;
101
104
  let calendarIds: string[] | undefined;
102
105
  if (a.calendar) {
103
106
  const c = await repo.getCalendarBySlug(a.calendar);
104
- if (!c || c.visibility === 'private') return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
107
+ if (!c || c.visibility === 'private') return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
105
108
  calendarIds = [c.id];
106
109
  }
107
110
  const items = await upcomingEvents(repo, settings, now, { calendarIds, limit: a.limit });
108
- if (!items.length) return wrap('<p class="pc-ev-empty">No upcoming events.</p>', ' is-empty');
111
+ if (!items.length) return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
109
112
  return wrap(items.map((o) => eventCardHtml(o, settings, { showImage: a.showImage })).join(''));
110
113
  }
@@ -7,6 +7,10 @@ import { roomColor, type CalEvent, type Calendar, type Resource, type Settings }
7
7
  import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
9
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
10
+ import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
11
+ import { eventStrings } from './event-words';
12
+ import { localePath } from '../i18n';
13
+ import { localizeDoc } from '../translations';
10
14
 
11
15
  export interface ViewEvent extends CalendarEvent {
12
16
  url?: string;
@@ -34,6 +38,12 @@ export interface ViewScope {
34
38
  includeFeeds?: boolean;
35
39
  // "Reserved" blocks from resources set to show on the calendar
36
40
  includeBookings?: boolean;
41
+ // the address's cal/tag/notag/room (filter.ts); its calendars narrow
42
+ // calendarIds when both are given
43
+ filter?: ResolvedEventFilter;
44
+ // the request's language (requestLocale): translated titles, event links
45
+ // under its /<locale> prefix, and "Reserved" in its words
46
+ locale?: string;
37
47
  }
38
48
 
39
49
  const pad = (n: number) => String(n).padStart(2, '0');
@@ -46,7 +56,9 @@ export async function collectViewEvents(
46
56
  ): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
47
57
  const all = await repo.listCalendars();
48
58
  const byId = new Map(all.map((c) => [c.id, c]));
49
- const requested = scope.calendarIds ? new Set(scope.calendarIds) : null;
59
+ const f = scope.filter;
60
+ const ids = scope.calendarIds && f?.calendarIds ? scope.calendarIds.filter((id) => f.calendarIds!.includes(id)) : scope.calendarIds ?? f?.calendarIds;
61
+ const requested = ids ? new Set(ids) : null;
50
62
  const visible = all.filter((c) => {
51
63
  if (scope.audience === 'admin') return true;
52
64
  // a directly requested calendar may be unlisted; private never is
@@ -60,6 +72,7 @@ export async function collectViewEvents(
60
72
  const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
61
73
  const natives = source.filter((e) => {
62
74
  if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
75
+ if (f && !tagsMatch(f, e.tags, { internal: f.internal })) return false;
63
76
  if (!e.calendarIds.length) {
64
77
  if (requested) return false;
65
78
  return scope.includeUncategorized ?? true;
@@ -73,39 +86,42 @@ export async function collectViewEvents(
73
86
  const shown = new Map<string, CalEvent>();
74
87
  const view = (e: CalEvent) => {
75
88
  if (scope.audience === 'admin') return e;
76
- if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
89
+ if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
77
90
  return shown.get(e.id)!;
78
91
  };
79
92
  const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
80
93
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
81
94
  .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
82
95
  .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
96
+ const words = eventStrings(scope.locale).calendar;
83
97
  for (const o of expandEvents(natives, settings, win)) {
84
- const e = o.event;
98
+ // rooms are per date (a moved date can change them)
99
+ if (f?.roomIds && !occurrenceMatches(f, o)) continue;
100
+ const e = view(o.event);
85
101
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
86
102
  const p = tzParts(o.start, tz);
87
103
  const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
88
104
  events.push({
89
105
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
90
- title: e.title || 'Untitled event',
106
+ title: e.title || words.untitled,
91
107
  location: [e.location.name, e.location.address].filter(Boolean).join(', '),
92
108
  description: e.excerpt,
93
109
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
94
110
  ref: e.id,
95
- url: eventUrl(e.slug) + on,
111
+ url: localePath(eventUrl(e.slug), scope.locale ?? '') + on,
96
112
  color: cal ? safeColor(cal.color) : undefined,
97
113
  cancelled: o.cancelled,
98
114
  image: e.cover || undefined,
99
115
  imageAlt: e.coverAlt || undefined,
100
116
  calendarName: cal?.name,
101
- event: view(e),
117
+ event: e,
102
118
  rooms: rooms(o.roomIds),
103
119
  });
104
120
  }
105
121
 
106
122
  const errors: string[] = [];
107
123
  if (scope.includeFeeds ?? true) {
108
- const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
124
+ const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl && (!f || untaggedMatches(f, { calendarId: c.id })));
109
125
  const results = await Promise.all(
110
126
  feeds.map(async (c) => {
111
127
  try {
@@ -125,7 +141,7 @@ export async function collectViewEvents(
125
141
  // resources marked "show on calendar": confirmed bookings appear as
126
142
  // Reserved blocks (no guest names on public views) — only on unscoped views
127
143
  if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
128
- const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
144
+ const resources = (await repo.listResources()).filter((r) => r.showOnCalendar && (!f || untaggedMatches(f, { roomId: r.id })));
129
145
  if (resources.length) {
130
146
  const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
131
147
  const byId = new Map(resources.map((r) => [r.id, r]));
@@ -135,7 +151,7 @@ export async function collectViewEvents(
135
151
  const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
136
152
  events.push({
137
153
  start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
138
- title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `Reserved · ${r.name}`,
154
+ title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `${words.reserved} · ${r.name}`,
139
155
  location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
140
156
  url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
141
157
  });
@@ -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
+ }