@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.
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { publicTags, tagFilter, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
3
+ import { type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
4
4
  import { ApiError } from '../api';
5
5
  import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl, publicEventFields } from '../calendar/config';
6
6
  import { calendarToJson, eventToJson, roomsToJson } from '../calendar/api';
@@ -17,6 +17,7 @@ import { localizeDoc } from '../translations';
17
17
  import { localeQuery, mobileLocale, localizedUrl } from './locale';
18
18
  import { confirmsInstantly, linkApproval } from '../booking/approval';
19
19
  import { changeTopics } from '../push/service';
20
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../calendar/filter';
20
21
 
21
22
  export function requireCalendar(bookings = false) {
22
23
  if (!CALENDAR.enabled || (bookings && !bookingsEnabled())) throw new ApiError(404, 'This feature is disabled.');
@@ -27,14 +28,24 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
27
28
  const origin = siteOrigin(await repo.getSettings(), url);
28
29
  return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
29
30
  }
30
- // tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
31
- export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
32
- tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
31
+ // The website calendar's filters (calendar/filter.ts). Each takes one or more
32
+ // values, comma-separated or repeated. Tags are a public tag's name or slug
33
+ // (internal "#" tags never match, as they are never shown).
34
+ const FILTER_LISTS = ['calendar', 'cal', 'tag', 'excludeTag', 'notag', 'room'] as const;
35
+ const filterList = (what: string) => z.string().min(1).max(2100)
36
+ .refine((v) => { const parts = v.split(','); return parts.length <= 20 && parts.every((p) => p.trim().length <= 100); }, 'Use up to 20 values of up to 100 characters each.')
37
+ .optional().describe(`${what} One or more, comma-separated (a,b) or repeated.`);
38
+ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(),
39
+ calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
40
+ tag: filterList('Only events with one of these tags (a name in any case, or a slug).'), excludeTag: filterList('Leave out events with any of these tags.'), notag: filterList('Alias of excludeTag.'),
41
+ room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
42
+ q: z.string().max(100).optional() }).strict();
33
43
  export async function listEvents(repo: ContentRepository, url: URL) {
34
44
  requireCalendar();
35
- const q = eventsQuery.parse(queryParams(url));
45
+ const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
36
46
  const locale = mobileLocale(q.locale);
37
47
  const term = q.q?.trim() ?? '';
48
+ const filter = readEventFilter(url.searchParams);
38
49
  const [settings, calendars, events, resources, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), roomMap(repo),
39
50
  // the search index's matches (body text, word prefixes), so "more" from /search continues here
40
51
  term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
@@ -47,14 +58,18 @@ export async function listEvents(repo: ContentRepository, url: URL) {
47
58
  const to = q.to ? at(q.to) : fromDay.length === 10 ? localMidnight(addDaysYmd(fromDay, 30), tz) : from + 30 * 86400000;
48
59
  if (to <= from || to - from > 90 * 86400000 + 3600000) throw new ApiError(400, 'Use an event window of 1 to 90 days.');
49
60
  const all = new Map(calendars.map(c => [c.id, c]));
50
- const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
51
- if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
52
- const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
53
- const tagged = tagFilter(q.tag, q.excludeTag);
54
- const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
55
- && tagged(publicTags(e.tags))
61
+ // private calendars and rooms not shown on public events answer like missing ones
62
+ const f = resolveEventFilter(filter, { calendars, resources: [...resources.values()] });
63
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Calendar not found.');
64
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
65
+ const picked = f.calendarIds;
66
+ const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
67
+ const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (picked ? e.calendarIds.some(id => picked.includes(id)) && eventIsPublic(e, all) : eventIsListed(e, all))
68
+ && tagsMatch(f, e.tags)
56
69
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
57
- const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
70
+ const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
71
+ // rooms are per date (a moved date can change them)
72
+ .filter(o => !f.roomIds || occurrenceMatches(f, o));
58
73
  occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
59
74
  const start = (q.page - 1) * q.limit;
60
75
  const origin = siteOrigin(settings, url);
@@ -43,11 +43,13 @@ export async function limitRequest(repo: ContentRepository, address: string, wri
43
43
  const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
44
44
  if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
45
45
  }
46
- export function queryParams(url: URL) {
46
+ // lists: parameters that may repeat (tag=a&tag=b); their values are joined
47
+ // with commas, the same as tag=a,b
48
+ export function queryParams(url: URL, lists: readonly string[] = []) {
47
49
  const result: Record<string, string> = {};
48
50
  for (const [key, value] of url.searchParams) {
49
- if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
50
- result[key] = value;
51
+ if (key in result && !lists.includes(key)) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
52
+ result[key] = key in result ? `${result[key]},${value}` : value;
51
53
  }
52
54
  return result;
53
55
  }
@@ -14,16 +14,20 @@ import { readJson } from '../../../../lib/api';
14
14
  import { runAgentTool } from '../../../../lib/agent';
15
15
  import { waitUntilFrom } from '../../../../lib/webhooks';
16
16
  import { fail } from '../../../../lib/calendar/http';
17
- import { publicTags, tagFilter } from '../../../../lib/schema';
17
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../../../../lib/calendar/filter';
18
18
 
19
19
  // Public read API for other sites, apps and lobby screens.
20
20
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
21
21
  // GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
22
+ // GET /api/calendar/events?calendar=youth,events&notag=Class&room=chapel
23
+ // (the /calendar page's filters: each takes several values, comma-separated
24
+ // or repeated; cal and notag are aliases of calendar and excludeTag)
22
25
  // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
23
26
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
24
27
  // Anonymous callers see public calendars (and an unlisted one when asked by
25
28
  // slug). A key with events:read also sees private calendars and private
26
- // custom fields, and its tag filters also match internal "#" tags.
29
+ // custom fields, its tag filters also match internal "#" tags, and its room
30
+ // filter matches every room (anonymous callers: rooms shown on public events).
27
31
  const CORS = {
28
32
  'Access-Control-Allow-Origin': '*',
29
33
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -43,28 +47,28 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
43
47
  const principal = await authenticate(request, repo);
44
48
  if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
45
49
  const full = principal ? hasScope(principal, 'events:read') : false;
50
+ const filter = readEventFilter(url.searchParams);
46
51
  const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
47
52
  const tz = settings.timezone || 'UTC';
48
53
  const map = new Map(calendars.map((c) => [c.id, c]));
49
54
  const resources = new Map(rooms.map((r) => [r.id, r]));
50
55
  const origin = siteOrigin(settings, url);
51
56
 
52
- const slug = url.searchParams.get('calendar');
53
- const cal = slug ? calendars.find((c) => c.slug === slug) : null;
54
- if (slug && (!cal || (cal.visibility === 'private' && !full))) throw new ApiError(404, 'Unknown calendar.');
57
+ const f = resolveEventFilter(filter, { calendars, resources: rooms, allowPrivate: full });
58
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Unknown calendar.');
59
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Unknown room.');
60
+ const picked = f.calendarIds;
55
61
  const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
56
62
  let events = published.filter((e) => {
57
- if (cal) return e.calendarIds.includes(cal.id) && (full || eventIsPublic(e, map));
63
+ if (picked) return e.calendarIds.some((id) => picked.includes(id)) && (full || eventIsPublic(e, map));
58
64
  return full ? true : eventIsListed(e, map);
59
65
  });
60
66
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
61
- const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
62
- if (tag || excludeTag) {
63
- const tagged = tagFilter(tag, excludeTag);
64
- events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
65
- }
67
+ events = events.filter((e) => tagsMatch(f, e.tags, { internal: full }));
66
68
 
67
69
  if (url.searchParams.get('expand') === '0') {
70
+ // rules, not dates: a room filter matches the series' own rooms
71
+ if (f.roomIds) events = events.filter((e) => occurrenceMatches(f, { event: e }));
68
72
  return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
69
73
  }
70
74
  const day = (param: string, fallback: number) => {
@@ -76,7 +80,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
76
80
  const to = Math.min(day('to', from + 90 * 86_400_000), from + 400 * 86_400_000);
77
81
  if (to <= from) throw new ApiError(400, '"to" must be after "from".');
78
82
  const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit')) || 200));
79
- const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
83
+ const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to })
84
+ .filter((o) => !f.roomIds || occurrenceMatches(f, o))
85
+ .slice(0, limit);
80
86
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
81
87
  const toJson = (id: string) => {
82
88
  if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
@@ -4,10 +4,13 @@ import { getRepo } from '../../lib/repo';
4
4
  import { siteOrigin } from '../../lib/seo';
5
5
  import { CALENDAR } from '../../lib/calendar/config';
6
6
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
7
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
7
8
 
8
9
  // One calendar's subscribe feed. Public and unlisted calendars are open;
9
10
  // private ones need the secret ?t= token from Calendars → Copy link (staff
10
11
  // subscriptions). A wrong token looks exactly like a missing calendar.
12
+ // ?tag=, ?notag= and ?room= narrow it like the /calendar page (a `cal` here
13
+ // is ignored: the address names the calendar).
11
14
  const sameToken = (a: string, b: string) =>
12
15
  a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
13
16
 
@@ -18,8 +21,11 @@ export const GET: APIRoute = async ({ locals, url, params }) => {
18
21
  if (!calendar || calendar.kind !== 'native') return new Response(null, { status: 404 });
19
22
  const isPrivate = calendar.visibility === 'private';
20
23
  if (isPrivate && !sameToken(url.searchParams.get('t') ?? '', calendar.feedToken)) return new Response(null, { status: 404 });
21
- const settings = await repo.getSettings();
22
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate });
24
+ const filter = { ...readEventFilter(url.searchParams), cal: [] };
25
+ const [settings, resources] = await Promise.all([repo.getSettings(), filter.room.length ? repo.listResources() : []]);
26
+ const resolved = resolveEventFilter(filter, { calendars: [], resources, allowPrivate: isPrivate });
27
+ if (resolved.unknown) return new Response(null, { status: 404 });
28
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate, filter: resolved });
23
29
  // private feeds must never sit in shared caches
24
30
  const res = icsResponse(body, `${calendar.slug}.ics`, isPrivate ? 0 : 300);
25
31
  if (isPrivate) res.headers.set('Cache-Control', 'private, no-store');
@@ -3,13 +3,20 @@ import { getRepo } from '../../lib/repo';
3
3
  import { siteOrigin } from '../../lib/seo';
4
4
  import { CALENDAR } from '../../lib/calendar/config';
5
5
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
6
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
6
7
 
7
8
  // Every public calendar in one subscribe feed (/calendar.ics). Feed
8
9
  // calendars are not re-published — subscribers can add those at the source.
10
+ // The /calendar page's filters narrow it: /calendar.ics?tag=Class,
11
+ // ?cal=youth,events&notag=Class, ?room=chapel. A calendar or room that isn't
12
+ // public answers 404, like one that doesn't exist.
9
13
  export const GET: APIRoute = async ({ locals, url }) => {
10
14
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
11
15
  const repo = await getRepo(locals);
12
- const settings = await repo.getSettings();
13
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), {});
16
+ const filter = readEventFilter(url.searchParams);
17
+ const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
18
+ const resolved = resolveEventFilter(filter, { calendars, resources });
19
+ if (resolved.unknown) return new Response(null, { status: 404 });
20
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { filter: resolved });
14
21
  return icsResponse(body, 'calendar.ics');
15
22
  };
@@ -3,50 +3,69 @@ import Site from '../../layouts/Site.astro';
3
3
  import { getRepo } from '../../lib/repo';
4
4
  import { effectiveSettings } from '../../lib/designPreview';
5
5
  import { siteOrigin } from '../../lib/seo';
6
- import { siteLocale } from '../../lib/dates';
7
- import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
6
+ import { siteLang, siteLocale } from '../../lib/dates';
7
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
8
+ import { isExtraLocale } from '../../lib/translations';
9
+ import { fill } from '../../lib/live/words';
10
+ import { eventStrings } from '../../lib/calendar/event-words';
11
+ import { CALENDAR, calendarFeedUrl, eventUrl } from '../../lib/calendar/config';
8
12
  import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
9
13
  import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
10
14
  import { eventCardHtml } from '../../lib/calendar/cards';
11
15
  import { safeColor } from '../../lib/calendar/events';
12
16
  import { tzParts } from '../../lib/calendar/ics';
17
+ import { eventFilterQuery, hasEventFilter, readEventFilter, resolveEventFilter, type EventFilter } from '../../lib/calendar/filter';
13
18
 
14
19
  // The main calendar page: filter chips per public calendar, Month / Week /
15
20
  // List views (the same renderer as the calendar card), image cards for
16
21
  // what's coming up, and subscribe links for every calendar app. On phones the
17
22
  // month grid gives way to a two-week day strip over the agenda.
23
+ // The address carries the filters (filter.ts): ?cal=youth,events, ?tag=Class,
24
+ // ?notag=Class, ?room=chapel. Active ones show as removable chips, and the
25
+ // subscribe links carry them. /es/calendar speaks Spanish (the same languages
26
+ // as event pages) and keeps its links under /es.
18
27
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
19
28
 
20
29
  const repo = await getRepo(Astro.locals);
21
- const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
30
+ const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
31
+ const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
32
+ // dates and the renderer's words follow the page's language, like event pages
33
+ const settings = isExtraLocale(reqLocale) ? { ...baseSettings, locale: reqLocale } : baseSettings;
34
+ const lang = reqLocale || siteLang(settings);
35
+ const w = eventStrings(lang);
36
+ const cw = w.calendar;
37
+ const here = (path: string) => localePath(path, reqLocale);
22
38
  const now = Date.now();
23
39
  const tz = settings.timezone || 'UTC';
24
40
  const locale = siteLocale(settings);
25
41
  const origin = siteOrigin(settings, Astro.url);
26
42
 
27
- const all = await repo.listCalendars();
43
+ const filter = readEventFilter(Astro.url.searchParams);
44
+ const filtered = hasEventFilter(filter);
45
+ const [all, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
46
+ const resolved = resolveEventFilter(filter, { calendars: all, resources });
28
47
  const listed = all.filter((c) => c.visibility === 'public');
29
- const calSlug = Astro.url.searchParams.get('cal') ?? '';
30
- // unlisted calendars work when linked directly, they just aren't offered
31
- const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
48
+ // exactly one calendar picked: its name, description and own feed
49
+ const active = filter.cal.length === 1 && resolved.calendars.length === 1 ? resolved.calendars[0] : null;
50
+ const picked = new Set(resolved.calendarIds ?? []);
32
51
  const viewParam = Astro.url.searchParams.get('view');
33
52
  const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
34
- const explicitView = Boolean(viewParam);
35
- const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
53
+ const explicitView = viewParam === view;
54
+ const monthRaw = Astro.url.searchParams.get('m') ?? '';
55
+ const monthParam = /^(\d{4})-(\d{2})$/.exec(monthRaw);
36
56
 
37
57
  const win = calendarWindow(settings, now);
38
- const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
39
- audience: 'public',
40
- ...(active ? { calendarIds: [active.id] } : {}),
41
- });
58
+ const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'public', filter: resolved, locale: lang });
42
59
 
43
- const calendarHtml = renderCalendarView(events, settings, now, {
60
+ const gridHtml = renderCalendarView(events, settings, now, {
44
61
  view: view === 'list' ? 'upcoming' : view,
45
62
  limit: 50,
46
- multi: !active && calendars.length > 1,
47
- emptyText: 'Nothing on the calendar yet.',
63
+ multi: calendars.length > 1,
64
+ emptyText: filtered ? cw.noMatch : cw.empty,
48
65
  focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
49
66
  });
67
+ // an empty month or week grid says why (the list says it itself)
68
+ const calendarHtml = (filtered && !events.length && view !== 'list' ? '<p class="pc-cal-empty">' + cw.noMatch + '</p>' : '') + gridHtml;
50
69
 
51
70
  // coming up: the next occurrence of each distinct native event
52
71
  const seen = new Set<string>();
@@ -79,19 +98,44 @@ const stripDays = Array.from({ length: 14 }, (_, i) => {
79
98
  return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
80
99
  });
81
100
 
82
- const feed = origin + calendarFeedUrl(active?.slug);
101
+ // links keep the filters, the view, the month and the page's language
102
+ const pageHref = (f: EventFilter, opts: { view?: string | null; m?: string | null } = {}) => {
103
+ const v = opts.view === undefined ? (explicitView ? view : null) : opts.view;
104
+ const m = opts.m === undefined ? (monthParam ? monthRaw : null) : opts.m;
105
+ const q = [eventFilterQuery(f), v ? `view=${v}` : '', m ? `m=${m}` : ''].filter(Boolean).join('&');
106
+ return here(CALENDAR.base) + (q ? `?${q}` : '');
107
+ };
108
+ const viewHref = (v: string) => pageHref(filter, { view: v, m: null });
109
+ const without = (key: keyof EventFilter, value: string) => pageHref({ ...filter, [key]: filter[key].filter((x) => x !== value) });
110
+ // calendar chips switch a calendar on or off; "All" drops the calendar filter
111
+ const toggleCal = (slug: string) => {
112
+ const on = filter.cal.some((s) => s.toLowerCase() === slug);
113
+ return pageHref({ ...filter, cal: on ? filter.cal.filter((s) => s.toLowerCase() !== slug) : [...filter.cal, slug] });
114
+ };
115
+ const offered = [...listed, ...resolved.calendars.filter((c) => c.visibility !== 'public')];
116
+ const roomName = (slug: string) => resolved.rooms.find((r) => r.slug === slug.toLowerCase())?.name ?? slug;
117
+ // the removable chips: tags, left-out tags, rooms, and calendars that didn't resolve
118
+ const chips = [
119
+ ...filter.tag.map((t) => ({ label: t, href: without('tag', t) })),
120
+ ...filter.notag.map((t) => ({ label: fill(cw.without, { tag: t }), href: without('notag', t) })),
121
+ ...filter.room.map((r) => ({ label: fill(cw.room, { room: roomName(r) }), href: without('room', r) })),
122
+ ...filter.cal.filter((s) => !resolved.calendars.some((c) => c.slug === s.toLowerCase())).map((s) => ({ label: s, href: without('cal', s) })),
123
+ ];
124
+
125
+ // subscribe: one calendar alone keeps its own feed; anything else is the
126
+ // site feed with the same filters
127
+ const ownFeed = active && !filter.tag.length && !filter.notag.length && !filter.room.length;
128
+ const feedQuery = eventFilterQuery(filter);
129
+ const feed = origin + (ownFeed ? calendarFeedUrl(active.slug) : calendarFeedUrl() + (feedQuery ? `?${feedQuery}` : ''));
83
130
  const webcal = feed.replace(/^https?:\/\//, 'webcal://');
84
131
  const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
85
132
  const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
86
- const viewHref = (v: string) => {
87
- const p = new URLSearchParams(Astro.url.search);
88
- p.set('view', v);
89
- p.delete('m');
90
- return `${CALENDAR.base}?${p}`;
91
- };
92
- const title = active ? active.name : 'Calendar';
93
- const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
133
+ const subscribeNote = ownFeed ? fill(cw.subscribeNamed, { name: active.name }) : filtered ? cw.subscribeFiltered : cw.subscribeAll;
134
+ const title = active ? active.name : cw.title;
135
+ const lede = active?.description || fill(cw.lede, { site: settings.siteTitle });
94
136
  const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
137
+ const agendaEmpty = filtered && !events.length ? cw.noMatch : cw.emptySoon;
138
+ const cardHref = (v: ViewEvent) => here(eventUrl(v.event!.slug));
95
139
  ---
96
140
 
97
141
  <Site title={title} description={lede}>
@@ -102,40 +146,51 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
102
146
  </header>
103
147
 
104
148
  <div class="pc-calpage-bar">
105
- {(listed.length > 1 || active) && (
106
- <nav class="pc-calpage-chips" aria-label="Calendars">
107
- <a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
108
- {listed.map((c) => (
109
- <a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
149
+ {(offered.length > 1 || picked.size > 0) && (
150
+ <nav class="pc-calpage-chips" aria-label={cw.calendars}>
151
+ <a class={!filter.cal.length ? 'is-on' : ''} href={pageHref({ ...filter, cal: [] })} aria-current={!filter.cal.length ? 'page' : undefined}>{cw.all}</a>
152
+ {offered.map((c) => (
153
+ <a class={picked.has(c.id) ? 'is-on' : ''} href={toggleCal(c.slug)} aria-current={picked.has(c.id) ? 'true' : undefined}>
110
154
  <i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
111
155
  </a>
112
156
  ))}
113
- {active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
114
157
  </nav>
115
158
  )}
116
159
  <div class="pc-calpage-tools">
117
- <nav class="pc-calpage-views" aria-label="View">
118
- <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
119
- <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
120
- <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
160
+ <nav class="pc-calpage-views" aria-label={cw.views}>
161
+ <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>{cw.month}</a>
162
+ <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>{cw.week}</a>
163
+ <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>{cw.list}</a>
121
164
  </nav>
122
165
  <details class="pc-calpage-sub">
123
166
  <summary>
124
167
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
125
- Subscribe
168
+ {cw.subscribe}
126
169
  </summary>
127
170
  <div class="pc-calpage-sub-menu">
128
- <p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</p>
171
+ <p>{subscribeNote}</p>
129
172
  <a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
130
173
  <a href={webcal}>Apple Calendar</a>
131
174
  <a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
132
- <button type="button" data-copy={feed}>Copy link</button>
175
+ <button type="button" data-copy={feed} data-copied={w.linkCopied}>{cw.copyLink}</button>
133
176
  </div>
134
177
  </details>
135
178
  </div>
136
179
  </div>
137
180
 
138
- <div class="pc-calpage-strip" aria-label="Next two weeks">
181
+ {chips.length > 0 && (
182
+ <nav class="pc-calpage-filters" aria-label={cw.filters}>
183
+ {chips.map((c) => (
184
+ <a class="pc-calpage-filter" href={c.href} aria-label={fill(cw.remove, { filter: c.label })}>
185
+ <span>{c.label}</span>
186
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
187
+ </a>
188
+ ))}
189
+ {chips.length > 1 && <a class="pc-calpage-clear" href={pageHref({ cal: [], tag: [], notag: [], room: [] })}>{cw.clear}</a>}
190
+ </nav>
191
+ )}
192
+
193
+ <div class="pc-calpage-strip" aria-label={cw.nextTwoWeeks}>
139
194
  {stripDays.map((d) => (
140
195
  <a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
141
196
  <small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
@@ -145,13 +200,13 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
145
200
 
146
201
  <div class="pc-calpage-view" set:html={calendarHtml} />
147
202
 
148
- <section class="pc-calpage-agenda" aria-label="Agenda">
149
- {agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
203
+ <section class="pc-calpage-agenda" aria-label={cw.agenda}>
204
+ {agenda.size === 0 ? <p class="pc-cal-empty">{agendaEmpty}</p> : [...agenda.entries()].map(([k, list]) => (
150
205
  <div class="pc-agenda-day" id={`d-${k}`}>
151
206
  <h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
152
207
  {list.map((v) => (
153
208
  <a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
154
- <span class="t">{v.allDay ? 'All day' : timeLabel.format(v.start)}</span>
209
+ <span class="t">{v.allDay ? w.allDay : timeLabel.format(v.start)}</span>
155
210
  <span class="ti">
156
211
  {v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
157
212
  <b>{v.title}</b>
@@ -165,14 +220,14 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
165
220
 
166
221
  {view !== 'list' && comingUp.length > 0 && (
167
222
  <section class="pc-calpage-up">
168
- <h2>Coming up</h2>
169
- <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
223
+ <h2>{cw.comingUp}</h2>
224
+ <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true, href: cardHref(v) })).join('')} />
170
225
  </section>
171
226
  )}
172
227
 
173
228
  <footer class="pc-calpage-foot">
174
- <span>Times in {zoneName}</span>
175
- {errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</span>}
229
+ <span>{fill(cw.timesIn, { zone: zoneName })}</span>
230
+ {errors.length > 0 && <span>{cw.feedErrors}</span>}
176
231
  </footer>
177
232
  </div>
178
233
  <script>
@@ -180,8 +235,9 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
180
235
  const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
181
236
  if (b) {
182
237
  await navigator.clipboard.writeText(b.dataset.copy ?? '');
183
- b.textContent = 'Link copied';
184
- setTimeout(() => (b.textContent = 'Copy link'), 1400);
238
+ const label = (b.dataset.label ??= b.textContent ?? '');
239
+ b.textContent = b.dataset.copied ?? label;
240
+ setTimeout(() => (b.textContent = label), 1400);
185
241
  }
186
242
  document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
187
243
  if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;