@acms-subir/core 0.3.61 → 0.3.62

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.62
4
+
5
+ ### Patch Changes
6
+
7
+ - 729834a: Unlisted calendars work with app notifications and search.
8
+
9
+ - **A rule that names an event covers it in an Unlisted calendar.** Under Admin → Mobile app, the new event, starting soon and event change rules sent notices only for events in Public calendars. A "Class changes" rule (every calendar, tag `Class`) never fired for classes in an Unlisted rooms calendar. A rule now covers an Unlisted calendar's events when it names them, either by listing that calendar or, for an event change rule, by listing a tag the event has. "All public calendars" with no tags still covers only Public calendars, so Unlisted items don't start notifying on their own. Each rule is judged on its own, so a class goes only to the topics of the rule that names it. Private calendars never notify, and neither do appointments from Google booking pages (`#google-booking`). An event's `notify.changes` in the app API follows the same rules.
10
+ - **The Mobile app page lists Unlisted calendars.** They appear next to the Public ones, marked "unlisted", and saving keeps them in a rule (it used to drop them). In an event change rule that lists tags, "All public calendars" reads "All calendars" and covers the Unlisted ones too.
11
+ - **One search rule for the app and the website.** App search (`/api/v1/search`) now finds events in Unlisted calendars, as the website's `/search` and `/api/search` already did. None of them return Google booking-page appointments ("Booked") any more. `/api/v1/events?q=`, which continues an app search's "more", finds the same events; without `q`, `/api/v1/events` still lists Public calendars only.
12
+
13
+ There's no migration, and nothing to change after updating. To get class change notices from an Unlisted calendar, give the rule the `Class` tag (or tick that calendar) in Admin → Mobile app.
14
+
3
15
  ## 0.3.61
4
16
 
5
17
  ### Patch Changes
package/docs/mobile.md CHANGED
@@ -257,7 +257,10 @@ For a search screen across everything, `useSearch(text)` returns groups
257
257
  (posts, pages, events) with snippets. Group `posts` by `collection` for
258
258
  headings, and when `more.posts` is true link to the collection list with that
259
259
  `q`. Search matches the main-language text, so Spanish words find content only
260
- where the original is Spanish.
260
+ where the original is Spanish. Events include unlisted calendars (a rooms
261
+ calendar's classes, say), never private ones or Google booking-page
262
+ appointments; when `more.events` is true, `useEvents({ q })` lists the same
263
+ events by date (without `q` it lists public calendars only).
261
264
 
262
265
  **A native version of a coded section.** From the site folder:
263
266
  `npx acms section new visit-banner --theme <slug>` (new) or, for an existing
@@ -281,6 +284,9 @@ keyed by `locale`.
281
284
  Optional; the owner sends them from **Admin → Mobile app** (manual messages
282
285
  plus automatic ones: new post, new event, "starting soon", event changes,
283
286
  "we're live"). The app only registers and chooses topics; it never sends.
287
+ Events in an unlisted calendar notify only when a rule names that calendar or a
288
+ tag they have; an event's `notify.changes` says which topics hear about its
289
+ changes.
284
290
 
285
291
  1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
286
292
  default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -53,7 +53,7 @@ function MobileAppView({ state, setState }: { state: AdminPushState; setState: (
53
53
  const topicLabel = useMemo(() => {
54
54
  const map = new Map(state.topics.map((t) => [t.id, t.label]));
55
55
  for (const c of state.collections) if (!map.has(`collection:${c.key}`)) map.set(`collection:${c.key}`, c.label);
56
- for (const c of state.calendars) if (!map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
56
+ for (const c of state.calendars) if (c.visibility === 'public' && !map.has(`calendar:${c.slug}`)) map.set(`calendar:${c.slug}`, c.name);
57
57
  map.set('*', 'Everyone');
58
58
  return (id: string) => map.get(id) ?? id;
59
59
  }, [state]);
@@ -282,14 +282,19 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
282
282
  const setRule = (i: number, patch: Partial<ChangeRule>) => setAuto({ ...auto, eventChanges: rules.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
283
283
  // focus moves to "Add a rule" (enabled again once the tenth rule goes)
284
284
  const removeRule = (i: number) => { setAuto({ ...auto, eventChanges: rules.filter((_, j) => j !== i) }); requestAnimationFrame(() => addRule.current?.focus()); };
285
- const calendarChoices = (list: string[], set: (calendars: string[]) => void) => {
285
+ // '*' covers the public calendars; an unlisted one only when it's ticked, or
286
+ // (`tagged`: a change rule that lists tags) for events with those tags
287
+ const calendarChoices = (list: string[], set: (calendars: string[]) => void, tagged = false) => {
286
288
  const all = list.includes('*');
289
+ const unlisted = new Set(state.calendars.filter((c) => c.visibility !== 'public').map((c) => c.id));
290
+ const covered = (c: AdminPushState['calendars'][number]) => all && (c.visibility === 'public' || tagged);
287
291
  return (
288
292
  <div className="ma-choices">
289
- <label className="ma-check"><input type="checkbox" checked={all} onChange={() => set(all ? [] : ['*'])} /><span>All public calendars</span></label>
293
+ <label className="ma-check"><input type="checkbox" checked={all}
294
+ onChange={() => set(all ? list.filter((id) => id !== '*') : ['*', ...list.filter((id) => unlisted.has(id))])} /><span>{tagged ? 'All calendars' : 'All public calendars'}</span></label>
290
295
  {state.calendars.map((c) => (
291
- <label key={c.id} className="ma-check"><input type="checkbox" disabled={all} checked={all || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
292
- <span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}</span></label>
296
+ <label key={c.id} className="ma-check"><input type="checkbox" disabled={covered(c)} checked={covered(c) || list.includes(c.id)} onChange={() => set(flip(list, c.id))} />
297
+ <span><i className="ma-dot" style={{ background: c.color || 'var(--faint)' }} />{c.name}{c.visibility !== 'public' && <span className="hint"> · unlisted</span>}</span></label>
293
298
  ))}
294
299
  </div>
295
300
  );
@@ -337,7 +342,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
337
342
  <b id={`${uid}-rule-${i}`}>Rule {i + 1}</b>
338
343
  <button type="button" className="btn ghost sm" aria-label={`Remove rule ${i + 1}`} onClick={() => removeRule(i)}>Remove</button>
339
344
  </div>
340
- {calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }))}
345
+ {calendarChoices(r.calendars, (calendars) => setRule(i, { calendars }), r.tags.length > 0)}
341
346
  <div className="ma-fields">
342
347
  <div className="field ma-lead">
343
348
  <span>Only events tagged</span>
@@ -398,8 +398,8 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
398
398
 
399
399
  // ---- visibility ----------------------------------------------------------
400
400
  // An event with no calendars counts as public. Otherwise it follows its most
401
- // open calendar: any public/unlisted calendar makes the event page public;
402
- // only public calendars put it on the main calendar page and site feed.
401
+ // open calendar: any public/unlisted calendar makes the event page public (and
402
+ // searchable); only public calendars put it on the main calendar page and site feed.
403
403
 
404
404
  export function eventIsPublic(e: CalEvent, calendars: Map<string, Calendar>): boolean {
405
405
  if (e.status !== 'published') return false;
@@ -416,6 +416,16 @@ export function eventIsListed(e: CalEvent, calendars: Map<string, Calendar>): bo
416
416
  return e.calendarIds.some((id) => calendars.get(id)?.visibility === 'public');
417
417
  }
418
418
 
419
+ /** The internal tag on appointments booked through a Google booking page (they come in titled "Booked"). */
420
+ export const BOOKING_TAG = '#google-booking';
421
+ /** A Google booking-page appointment: search never finds it and push never announces it. */
422
+ export const isGoogleBooking = (e: Pick<CalEvent, 'tags'>) => e.tags.some((t) => t.trim().toLowerCase() === BOOKING_TAG);
423
+
424
+ /** What search finds, on the website and in the app: events with a public page (unlisted calendars too), but no booking-page appointments. */
425
+ export function eventIsSearchable(e: CalEvent, calendars: Map<string, Calendar>): boolean {
426
+ return eventIsPublic(e, calendars) && !isGoogleBooking(e);
427
+ }
428
+
419
429
  // ---- formatting ----------------------------------------------------------
420
430
 
421
431
  export function formatOccurrence(
@@ -27,7 +27,7 @@ const DAY = 86_400_000;
27
27
  const pad = (n: number) => String(n).padStart(2, '0');
28
28
 
29
29
  /** The internal tag on booked appointments from Google booking pages (Q16): never shown publicly. */
30
- export const BOOKING_TAG = '#google-booking';
30
+ export { BOOKING_TAG } from '../events';
31
31
 
32
32
  /** The few words the sync writes, in the site's main language (Q9). */
33
33
  export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string }
@@ -4,7 +4,7 @@ import { type BookingLink, type CalEvent, type Calendar, type Resource, type Set
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';
7
- import { eventIsListed, eventIsPublic, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
7
+ import { eventIsListed, eventIsPublic, eventIsSearchable, expandEvents, featuredOccurrence, type Occurrence } from '../calendar/events';
8
8
  import { availabilityContext } from '../booking/service';
9
9
  import { roomBusy } from '../booking/holds';
10
10
  import { sha256Hex } from '../api-keys';
@@ -40,7 +40,7 @@ export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: i
40
40
  calendar: filterList('Only events in these calendars (slugs).'), cal: filterList('Alias of calendar.'),
41
41
  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.'),
42
42
  room: filterList('Only events held in one of these rooms (slugs of rooms shown on public events).'),
43
- q: z.string().max(100).optional() }).strict();
43
+ q: z.string().max(100).optional().describe('Search: the events /search finds (unlisted calendars too, never Google booking-page appointments), listed by date. Without it, only public calendars and the ones named in calendar.') }).strict();
44
44
  export async function listEvents(repo: ContentRepository, url: URL) {
45
45
  requireCalendar();
46
46
  const q = eventsQuery.parse(queryParams(url, FILTER_LISTS));
@@ -65,7 +65,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
65
65
  if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Room not found.');
66
66
  const picked = f.calendarIds;
67
67
  const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || picked?.includes(c.id)).map(c => [c.id, c]));
68
- 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
+ // q continues /search, so it finds what search finds (unlisted calendars too, no booking-page appointments)
69
+ const shown = (e: CalEvent) => (term ? eventIsSearchable(e, all) : picked ? eventIsPublic(e, all) : eventIsListed(e, all));
70
+ const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (!picked || e.calendarIds.some(id => picked.includes(id))) && shown(e)
69
71
  && tagsMatch(f, e.tags)
70
72
  && (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
71
73
  const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to })
@@ -78,7 +80,7 @@ export async function listEvents(repo: ContentRepository, url: URL) {
78
80
  locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
79
81
  occurrences: occurrences.slice(start, start + q.limit).map(o => ({
80
82
  ...occurrenceDto(o, resources, locale || siteLang(settings)),
81
- event: publicEventDto(o.event, origin, publicCalendars, settings, locale, resources),
83
+ event: publicEventDto(o.event, origin, publicCalendars, all, settings, locale, resources),
82
84
  })),
83
85
  pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
84
86
  };
@@ -102,7 +104,7 @@ export async function getEventDetail(repo: ContentRepository, url: URL, ref: str
102
104
  return {
103
105
  locale,
104
106
  occurrence: o ? occurrenceDto(o, resources, locale || siteLang(settings)) : null,
105
- event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, settings, locale, resources),
107
+ event: publicEventDto(e, siteOrigin(settings, url), publicCalendars, all, settings, locale, resources),
106
108
  };
107
109
  }
108
110
  /** every room by id, for naming the public ones (Resource.showOnEvents) */
@@ -116,8 +118,9 @@ export function occurrenceDto(o: Occurrence, resources: Map<string, Resource>, l
116
118
  location: o.location ? { name: o.location.name, address: o.location.address, url: o.location.url } : null,
117
119
  rooms: roomsToJson(o.roomIds, resources, false, locale) };
118
120
  }
119
- // e is already localized; publicCalendars limits which calendars are named
120
- export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
121
+ // e is already localized; publicCalendars limits which calendars are named,
122
+ // `all` holds every calendar (their visibility decides which changes notify)
123
+ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, all: Map<string, Calendar>, settings: Settings, locale: string | null, resources?: Map<string, Resource>) {
121
124
  // the repeat line in the request's language, else the site's main one
122
125
  const event = eventToJson(e, origin, publicCalendars, settings.timezone, { resources, locale: locale || siteLang(settings) });
123
126
  event.url = localizedUrl(event.url, origin, locale);
@@ -126,7 +129,7 @@ export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map
126
129
  event.image = safeUrl(e.cover, origin);
127
130
  event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
128
131
  // which push topics hear about date, time or place changes ([] = none do)
129
- return { ...event, notify: { changes: changeTopics(e, settings, publicCalendars) } };
132
+ return { ...event, notify: { changes: changeTopics(e, settings, all) } };
130
133
  }
131
134
  export async function listResources(repo: ContentRepository, url: URL) {
132
135
  requireCalendar(true);
@@ -6,7 +6,7 @@ import { siteOrigin } from '../seo';
6
6
  import { siteLang } from '../dates';
7
7
  import { SEARCH } from '../search-config';
8
8
  import { CALENDAR } from '../calendar/config';
9
- import { eventIsListed, featuredOccurrence } from '../calendar/events';
9
+ import { eventIsSearchable, featuredOccurrence } from '../calendar/events';
10
10
  import { localizeDoc } from '../translations';
11
11
  import { ApiError } from '../api';
12
12
  import { contentDto } from './content';
@@ -68,12 +68,13 @@ export async function search(repo: ContentRepository, url: URL) {
68
68
  const pages = want.has('page') && !q.collection ? found.pages : [];
69
69
  const all = new Map(calendars.map((c) => [c.id, c]));
70
70
  const listed = new Map(calendars.filter((c) => c.visibility === 'public').map((c) => [c.id, c]));
71
- let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsListed(doc, all)) : [];
72
- // the index ranks before visibility is known: if unlisted events filled the
73
- // first batch, look deeper once (the repo's 200-row cap) for listed ones
71
+ // the website's rule: any calendar that isn't private, no booking-page appointments
72
+ let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsSearchable(doc, all)) : [];
73
+ // the index ranks before visibility is known: if private events or bookings
74
+ // filled the first batch, look deeper once (the repo's 200-row cap)
74
75
  if (want.has('event') && CALENDAR.enabled && !q.collection && events.length <= q.limit && found.events.length >= q.limit + 1) {
75
76
  const deeper = await repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: 200, collections: [] });
76
- events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
77
+ events = deeper.events.filter(({ doc }) => eventIsSearchable(doc, all));
77
78
  }
78
79
  const now = Date.now();
79
80
  return {
@@ -87,7 +88,7 @@ export async function search(repo: ContentRepository, url: URL) {
87
88
  snippet: plainSnippet(snippet),
88
89
  // the next occurrence, or the latest one when the event is over
89
90
  occurrence: o ? (({ id: _, ...rest }) => rest)(occurrenceDto(o, resources, locale || siteLang(settings))) : null,
90
- event: publicEventDto(e, origin, listed, settings, locale, resources),
91
+ event: publicEventDto(e, origin, listed, all, settings, locale, resources),
91
92
  };
92
93
  }),
93
94
  more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
@@ -136,7 +136,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
136
136
  calendarEnabled: CALENDAR.enabled,
137
137
  // "we're live" needs live video on (Integrations → Live video)
138
138
  liveMode: settings.live.mode,
139
- calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
139
+ // a rule can name an unlisted calendar too (a catch-all covers only public ones)
140
+ calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color, visibility: c.visibility })),
140
141
  // suggestions for the event-change rule's "Only events tagged"
141
142
  tags: tags.map((t) => t.name),
142
143
  expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
@@ -182,7 +183,8 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
182
183
  }
183
184
  case 'saveAuto': {
184
185
  const collections = new Set(Object.keys(CONTENT.collections));
185
- const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility === 'public').map((c) => c.id));
186
+ // private calendars never notify: a rule can't name one
187
+ const calendars = new Set((CALENDAR.enabled ? await repo.listCalendars() : []).filter((c) => c.visibility !== 'private').map((c) => c.id));
186
188
  const cals = (ids: string[]) => [...new Set(ids)].filter((id) => id === '*' || calendars.has(id));
187
189
  const known = await knownTopics(repo);
188
190
  const auto = {
@@ -5,7 +5,7 @@ import type { ContentRepository } from '../repo/types';
5
5
  import type { CalEvent, Calendar, Post, Settings } from '../schema';
6
6
  import { CONTENT, collectionUrl } from '../content';
7
7
  import { CALENDAR, eventUrl } from '../calendar/config';
8
- import { cutsAt, eventIsListed, eventTimeZone, expandEvents, findOccurrence, partOf, type Occurrence } from '../calendar/events';
8
+ import { cutsAt, eventIsListed, eventIsPublic, eventTimeZone, expandEvents, findOccurrence, isGoogleBooking, partOf, type Occurrence } from '../calendar/events';
9
9
  import { describeRepeat } from '../calendar/rrule';
10
10
  import { tzParts } from '../calendar/ics';
11
11
  import { I18N, stripLocale } from '../i18n';
@@ -236,22 +236,32 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
236
236
 
237
237
  // ---- automatic notices ---------------------------------------------------------
238
238
 
239
- const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
240
- const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
241
- // a change rule: its calendars, and one of its tags when it lists some (any case)
242
- function inChangeRule(rule: ChangeRule, e: CalEvent) {
243
- const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
244
- return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
239
+ const calendarMap = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
240
+ // Whether a rule (new event, starting soon, or a change rule) covers a
241
+ // published event: one in its calendars ('*' = any) with, for a change rule
242
+ // that lists tags, one of them (any case). A rule that names the event (one of
243
+ // its calendars, or tags it has) covers it in any calendar that isn't private,
244
+ // so a "Class" rule reaches classes in an unlisted rooms calendar; a catch-all
245
+ // ('*' and no tags) stays with public calendars. Private calendars and Google
246
+ // booking-page appointments never notify.
247
+ function covers(rule: { calendars: string[]; tags?: string[] }, e: CalEvent, calendars: Map<string, Calendar>) {
248
+ const tags = new Set((rule.tags ?? []).map((t) => t.toLowerCase()));
249
+ const named = e.calendarIds.some((id) => rule.calendars.includes(id));
250
+ if (!named && !rule.calendars.includes('*')) return false;
251
+ if (tags.size && !e.tags.some((t) => tags.has(t.toLowerCase()))) return false;
252
+ if (isGoogleBooking(e)) return false;
253
+ return named || tags.size ? eventIsPublic(e, calendars) : eventIsListed(e, calendars);
245
254
  }
246
255
  // every rule that covers the event adds its topics, or the event's own when it
247
256
  // names none: in rule order, once each ([] = no rule covers it)
248
257
  function changeAudience(rules: ChangeRule[], e: CalEvent, calendars: Map<string, Calendar>) {
249
- return [...new Set(rules.filter((r) => inChangeRule(r, e)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
258
+ return [...new Set(rules.filter((r) => covers(r, e, calendars)).flatMap((r) => (r.topics.length ? r.topics : eventTopics(e, calendars))))];
250
259
  }
251
260
  /**
252
261
  * The topics this event's change notices go to (Admin → Mobile app → When an
253
262
  * event changes), or [] while no rule covers it. Apps show it to promise
254
- * "we'll let you know if it changes" only when it's true.
263
+ * "we'll let you know if it changes" only when it's true. `calendars` holds
264
+ * every calendar (their visibility decides what notifies), not only public ones.
255
265
  */
256
266
  export function changeTopics(e: CalEvent, settings: Settings, calendars: Map<string, Calendar>): string[] {
257
267
  if (!PUSH.enabled || !CALENDAR.enabled) return [];
@@ -409,7 +419,8 @@ export async function queueEventChange(repo: ContentRepository, before: CalEvent
409
419
  const was = eventSnapshot(before);
410
420
  if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
411
421
  const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
412
- if (!settings.push.auto.eventChanges.some((r) => inChangeRule(r, after)) || !eventIsListed(after, listedCalendars(all))) return false;
422
+ const calendars = calendarMap(all);
423
+ if (!settings.push.auto.eventChanges.some((r) => covers(r, after, calendars))) return false;
413
424
  const key = `change:${after.id}`;
414
425
  // a change to a date that starts soon waits only a minute (judged from what people last knew)
415
426
  const row = await repo.push.queuedChange(key);
@@ -506,7 +517,7 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
506
517
  if (!rows.length) return 0;
507
518
  const settings = await repo.getSettings();
508
519
  const auto = settings.push.auto;
509
- const calendars = CALENDAR.enabled ? listedCalendars(await repo.listCalendars()) : new Map<string, Calendar>();
520
+ const calendars = CALENDAR.enabled ? calendarMap(await repo.listCalendars()) : new Map<string, Calendar>();
510
521
  const run = { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) };
511
522
  let sent = 0;
512
523
  for (const row of rows) {
@@ -517,14 +528,14 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
517
528
  if (post && post.status === 'published' && CONTENT.collections[post.collection] && auto.newPost.collections.includes(post.collection)) notice = postNotice(post, settings);
518
529
  } else if (row.kind === 'event' && CALENDAR.enabled) {
519
530
  const e = await repo.getEvent(row.docId);
520
- if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
531
+ if (e && !e.cancelled && covers(auto.newEvent, e, calendars)) notice = eventNotice(e, settings, calendars, now);
521
532
  } else if (row.kind === 'change' && CALENDAR.enabled) {
522
533
  // compared at send time: edits that cancel out, or come too late, send nothing
523
534
  const e = await repo.getEvent(row.docId);
524
535
  const was = readSnapshot(row.payload);
525
536
  // one notice, to everyone the rules that cover it send to
526
537
  const topics = e ? changeAudience(auto.eventChanges, e, calendars) : [];
527
- const change = e && was && topics.length && eventIsListed(e, calendars) ? eventChange(was, e, settings, now) : null;
538
+ const change = e && was && topics.length ? eventChange(was, e, settings, now) : null;
528
539
  if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, topics);
529
540
  }
530
541
  if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
@@ -547,8 +558,8 @@ export async function sendStartingSoon(repo: ContentRepository, opts: PushRunOpt
547
558
  const rule = settings.push.auto.startingSoon;
548
559
  if (!rule.calendars.length) return 0;
549
560
  const [all, events] = await Promise.all([repo.listCalendars(), repo.listPublishedEvents()]);
550
- const calendars = listedCalendars(all);
551
- const chosen = events.filter((e) => !e.cancelled && eventIsListed(e, calendars) && inRule(rule.calendars, e));
561
+ const calendars = calendarMap(all);
562
+ const chosen = events.filter((e) => !e.cancelled && covers(rule, e, calendars));
552
563
  if (!chosen.length) return 0;
553
564
  const lead = rule.minutes * MINUTE;
554
565
  const occurrences = expandEvents(chosen, settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + lead + 1 })
package/src/lib/schema.ts CHANGED
@@ -288,7 +288,8 @@ export type Redirect = z.infer<typeof Redirect>;
288
288
  // admin; `feed` calendars mirror an external ICS feed (Google, Apple,
289
289
  // Outlook, any .ics) live, read-only. Visibility decides where a calendar's
290
290
  // events surface: `public` = listed on the calendar page + the all-calendars
291
- // feed; `unlisted` = event pages and its own feed work, but it's not listed;
291
+ // feed; `unlisted` = event pages, its own feed and search work, but it's not
292
+ // listed (push rules cover its events only when they name them);
292
293
  // `private` = admin-only, plus a secret-token feed for staff subscriptions.
293
294
  export const CalendarKind = z.enum(['native', 'feed']);
294
295
  export type CalendarKind = z.infer<typeof CalendarKind>;
@@ -6,7 +6,7 @@ import { collectionUrl, tagUrl } from '../../lib/content';
6
6
  import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
7
7
  import { formatDate, siteLocale } from '../../lib/dates';
8
8
  import { CALENDAR, eventUrl } from '../../lib/calendar/config';
9
- import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../../lib/calendar/events';
9
+ import { eventIsSearchable, featuredOccurrence, formatOccurrence } from '../../lib/calendar/events';
10
10
 
11
11
  // Public search API behind the site's search modal. Published-only by
12
12
  // construction — the same contract as rss/sitemap — so it needs no auth and
@@ -42,8 +42,8 @@ export const GET: APIRoute = async ({ locals, url }) => {
42
42
  snippet,
43
43
  locale: p.locale,
44
44
  })),
45
- // private-calendar events never surface publicly
46
- events: (CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)) : []).slice(0, LIMITS.events).map(({ doc: e, snippet }) => {
45
+ // the app's rule too: never private calendars or booking-page appointments
46
+ events: (CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsSearchable(doc, calMap)) : []).slice(0, LIMITS.events).map(({ doc: e, snippet }) => {
47
47
  const o = featuredOccurrence(e, settings, now);
48
48
  return {
49
49
  title: e.title,
@@ -7,7 +7,7 @@ import { authorSlug } from '../lib/schema';
7
7
  import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
8
8
  import { searchStrings } from '../lib/search-i18n';
9
9
  import { CALENDAR, eventUrl } from '../lib/calendar/config';
10
- import { eventIsPublic, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
10
+ import { eventIsSearchable, featuredOccurrence, formatOccurrence } from '../lib/calendar/events';
11
11
  import { siteLocale } from '../lib/dates';
12
12
 
13
13
  // The no-JS search surface (the modal's form falls back here). Serves both
@@ -25,7 +25,7 @@ const results = q
25
25
  ? await repo.searchPublished(q, { locale })
26
26
  : { posts: [], pages: [], events: [], tags: [], authors: [] };
27
27
  const calMap = new Map((await repo.listCalendars()).map((c) => [c.id, c]));
28
- const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsPublic(doc, calMap)).slice(0, 10) : [];
28
+ const events = CALENDAR.enabled ? results.events.filter(({ doc }) => eventIsSearchable(doc, calMap)).slice(0, 10) : [];
29
29
  const total = results.posts.length + results.pages.length + events.length + results.tags.length + results.authors.length;
30
30
  ---
31
31