@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 +12 -0
- package/docs/mobile.md +7 -1
- package/package.json +1 -1
- package/src/components/MobileApp.tsx +11 -6
- package/src/lib/calendar/events.ts +12 -2
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/mobile/calendar.ts +11 -8
- package/src/lib/mobile/search.ts +7 -6
- package/src/lib/push/api.ts +4 -2
- package/src/lib/push/service.ts +26 -15
- package/src/lib/schema.ts +2 -1
- package/src/pages/api/search.ts +3 -3
- package/src/pages/search.astro +2 -2
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
|
@@ -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
|
-
|
|
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}
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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);
|
package/src/lib/mobile/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
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 }) =>
|
|
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 },
|
package/src/lib/push/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/lib/push/service.ts
CHANGED
|
@@ -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
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ?
|
|
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 &&
|
|
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
|
|
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 =
|
|
551
|
-
const chosen = events.filter((e) => !e.cancelled &&
|
|
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
|
|
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>;
|
package/src/pages/api/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
46
|
-
events: (CALENDAR.enabled ? results.events.filter(({ doc }) =>
|
|
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,
|
package/src/pages/search.astro
CHANGED
|
@@ -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 {
|
|
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 }) =>
|
|
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
|
|