@acms-subir/core 0.3.22 → 0.3.23
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 +6 -0
- package/docs/sections.md +2 -2
- package/docs/server.md +9 -7
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/components/sections/SectionFields.tsx +31 -10
- package/src/lib/calendar/cards.ts +6 -4
- package/src/lib/mobile/calendar.ts +6 -2
- package/src/lib/schema.ts +12 -0
- package/src/lib/section-targets.ts +10 -0
- package/src/lib/sections.ts +7 -5
- package/src/pages/api/calendar/events/index.ts +8 -1
- package/src/server/index.ts +15 -2
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/theme-sdk/fields.mjs +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.23
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1a0ceb2: Narrow event lists by tag. Section sources take `tag` (only items with that tag) and `excludeTag` (leave them out) for events as well as posts, so one shared calendar can feed a Classes page (`tag: "Class"`) and a home page that leaves classes out (`excludeTag: "Class"`). The section editor shows both pickers, listing the tags used on events. The same options work in `upcomingEvents()` from `@acms-subir/core/theme`, `listEvents()` and `listOccurrences()` from `@acms-subir/core/server`, the app API (`/api/v1/events?tag=&excludeTag=`, and `events({ tag, excludeTag })` in the client), and `/api/calendar/events`. A filter matches a tag's name in any case or its slug ("bible-study" matches "Bible Study"); internal `#` tags match by their full name and are never matched by the public APIs. Section tags with spaces or a leading `#` no longer fail the pre-publish check.
|
|
8
|
+
|
|
3
9
|
## 0.3.22
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/docs/sections.md
CHANGED
|
@@ -97,11 +97,11 @@ Field keys are camelCase. Every field takes `label`, optional `help` (one line s
|
|
|
97
97
|
| `date` | `YYYY-MM-DD` | string. Format with `formatDay()` | one-off dates |
|
|
98
98
|
| `time` | `HH:MM` | string. Format with `formatTime()` | times of day |
|
|
99
99
|
| `list` | array of rows | array of rows (sub-fields resolved the same way) | repeating things: services, people, plans, photos |
|
|
100
|
-
| `source` | `{ calendar?, collection?, tag?, resource?, limit }` | fresh `EventItem[]` / `PostItem[]` / `BookingItem[]` | content that must stay current on its own |
|
|
100
|
+
| `source` | `{ calendar?, collection?, tag?, excludeTag?, resource?, limit }` | fresh `EventItem[]` / `PostItem[]` / `BookingItem[]` | content that must stay current on its own |
|
|
101
101
|
|
|
102
102
|
`list` options: `fields` (scalar sub-fields only; a list can't contain a list or source), `itemLabel` (the sub-field shown on collapsed rows), `min`, `max` (default 50).
|
|
103
103
|
|
|
104
|
-
`source` options: `source: "events" | "posts" | "bookings"`. The owner picks which calendar / collection
|
|
104
|
+
`source` options: `source: "events" | "posts" | "bookings"`. The owner picks which calendar / collection / resource, and how many (1–24). Events and posts can also be narrowed by tag: `tag` shows only items with that tag, and `excludeTag` leaves out items with it. Both take a tag's name (any case) or slug, so a Classes page can show events tagged "Class" while the home page leaves them out. A theme can preset them in the field's `default`, e.g. `{ "tag": "Class", "limit": 6 }`.
|
|
105
105
|
|
|
106
106
|
**When to use `source` instead of `list`:** if the thing already exists elsewhere in ACMS (calendar events, blog posts, booking links), link to it with `source`. Then the homepage updates itself when the calendar changes. Use a `list` only for content that lives nowhere else.
|
|
107
107
|
|
package/docs/server.md
CHANGED
|
@@ -27,9 +27,9 @@ use, so a site can replace any of them, or turn them all off, and keep the same
|
|
|
27
27
|
```ts
|
|
28
28
|
getRepo(locals: unknown): Promise<Repo>
|
|
29
29
|
currentUser(ctx: { request: Request; cookies: AstroCookies; locals: unknown }): Promise<ServerUser | null>
|
|
30
|
-
listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string; hostedBy?: string }): Promise<CalEvent[]>
|
|
30
|
+
listEvents(repo: Repo, opts?: { publishedOnly?: boolean; calendar?: string; hostedBy?: string; tag?: string; excludeTag?: string }): Promise<CalEvent[]>
|
|
31
31
|
getEvent(repo: Repo, id: string): Promise<CalEvent | null>
|
|
32
|
-
listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number }): Promise<Occurrence[]>
|
|
32
|
+
listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
|
|
33
33
|
cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
34
34
|
restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
|
|
35
35
|
|
|
@@ -74,12 +74,14 @@ The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOpt
|
|
|
74
74
|
- **`listEvents(repo, opts)`**: events by first start date, earliest first. By default,
|
|
75
75
|
every status except archived. `publishedOnly: true` returns only published events.
|
|
76
76
|
`calendar` (an id or slug) limits the list to one calendar. `hostedBy` (a user id)
|
|
77
|
-
limits it to events that person hosts (`event.hostIds`).
|
|
78
|
-
|
|
77
|
+
limits it to events that person hosts (`event.hostIds`). `tag` keeps only events with
|
|
78
|
+
that tag and `excludeTag` leaves them out; both take a tag's name (any case) or slug,
|
|
79
|
+
and an internal `#` tag by its full name. Values are the live ones; unpublished edits
|
|
80
|
+
staged in the admin are not merged in.
|
|
79
81
|
- **`getEvent(repo, id)`**: one event, or `null`.
|
|
80
|
-
- **`listOccurrences(repo, { eventId?, from, to })`**: occurrences in
|
|
81
|
-
soonest first, for one event or all non-archived events
|
|
82
|
-
days. Each occurrence has its `key` (below) and `cancelled`, which is true when the
|
|
82
|
+
- **`listOccurrences(repo, { eventId?, from, to, tag?, excludeTag? })`**: occurrences in
|
|
83
|
+
`[from, to)`, soonest first, for one event or all non-archived events, narrowed by tag
|
|
84
|
+
like `listEvents`. The window can be up to 400 days. Each occurrence has its `key` (below) and `cancelled`, which is true when the
|
|
83
85
|
whole event or just that date is cancelled. Drafts are included, so check
|
|
84
86
|
`o.event.status` before showing anything publicly.
|
|
85
87
|
- **`cancelOccurrence(repo, eventId, key, { url, locals })`**: cancels one date of a
|
package/docs/theme-authoring.md
CHANGED
|
@@ -55,7 +55,7 @@ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astr
|
|
|
55
55
|
</section>}
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
`calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
|
|
58
|
+
`calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. `tag` keeps only events with that tag and `excludeTag` leaves them out (a tag's name in any case, or its slug), e.g. `upcomingEvents(Astro, { tag: 'Class' })` for a Classes page. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
|
|
59
59
|
|
|
60
60
|
Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
|
|
61
61
|
|
package/package.json
CHANGED
|
@@ -25,6 +25,8 @@ export interface SectionTargets {
|
|
|
25
25
|
calendars: { slug: string; name: string }[];
|
|
26
26
|
collections: { key: string; label: string }[];
|
|
27
27
|
tags: string[];
|
|
28
|
+
/** tags on published events (the events source picks from these) */
|
|
29
|
+
eventTags?: string[];
|
|
28
30
|
resources: { slug: string; name: string }[];
|
|
29
31
|
features: { events: boolean; bookings: boolean };
|
|
30
32
|
ai?: { enabled: boolean; languages: string[] };
|
|
@@ -321,27 +323,46 @@ function SourceInput({ id, field, value, onChange, targets }: InputProps<SourceF
|
|
|
321
323
|
const off = (field.source === 'events' && !targets.features.events) || (field.source === 'bookings' && !targets.features.bookings);
|
|
322
324
|
let from = '';
|
|
323
325
|
let picker: ReactNode = null;
|
|
326
|
+
// only items with one tag, and/or leaving out another ("events not tagged Class")
|
|
327
|
+
const tag = String(v.tag ?? ''), excludeTag = String(v.excludeTag ?? '');
|
|
328
|
+
const tagged = `${tag ? ` tagged “${tag}”` : ''}${tag && excludeTag ? ',' : ''}${excludeTag ? ` not tagged “${excludeTag}”` : ''}`;
|
|
329
|
+
const tagPickers = (names: string[]) => {
|
|
330
|
+
// a saved tag no item uses any more stays listed, so the picker shows what's stored
|
|
331
|
+
const list = [...new Set([...names, tag, excludeTag].filter(Boolean))];
|
|
332
|
+
return (
|
|
333
|
+
<>
|
|
334
|
+
<select aria-label="Only items with this tag" value={tag} onChange={(e) => set({ tag: e.target.value })}>
|
|
335
|
+
<option value="">Any tag</option>
|
|
336
|
+
{list.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
337
|
+
</select>
|
|
338
|
+
<select aria-label="Leave out items with this tag" value={excludeTag} onChange={(e) => set({ excludeTag: e.target.value })}>
|
|
339
|
+
<option value="">Leave nothing out</option>
|
|
340
|
+
{list.map((t) => <option key={t} value={t}>Leave out “{t}”</option>)}
|
|
341
|
+
</select>
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
};
|
|
324
345
|
if (field.source === 'events') {
|
|
325
346
|
const cal = targets.calendars.find((c) => c.slug === v.calendar);
|
|
326
|
-
from =
|
|
347
|
+
from = `${cal ? `Calendar → ${cal.name}` : 'All public calendars'}${tagged ? ` · events${tagged}` : ''}`;
|
|
327
348
|
picker = (
|
|
328
|
-
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
349
|
+
<>
|
|
350
|
+
<select id={id} aria-label="Calendar" value={String(v.calendar ?? '')} onChange={(e) => set({ calendar: e.target.value })}>
|
|
351
|
+
<option value="">All public calendars</option>
|
|
352
|
+
{targets.calendars.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
353
|
+
</select>
|
|
354
|
+
{tagPickers(targets.eventTags ?? targets.tags)}
|
|
355
|
+
</>
|
|
332
356
|
);
|
|
333
357
|
} else if (field.source === 'posts') {
|
|
334
358
|
const col = targets.collections.find((c) => c.key === (v.collection ?? 'posts'));
|
|
335
|
-
from = `${col?.label ?? 'Posts'}${
|
|
359
|
+
from = `${col?.label ?? 'Posts'}${tagged}`;
|
|
336
360
|
picker = (
|
|
337
361
|
<>
|
|
338
362
|
<select id={id} aria-label="Collection" value={String(v.collection ?? 'posts')} onChange={(e) => set({ collection: e.target.value === 'posts' ? '' : e.target.value })}>
|
|
339
363
|
{targets.collections.map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
|
340
364
|
</select>
|
|
341
|
-
|
|
342
|
-
<option value="">Any tag</option>
|
|
343
|
-
{targets.tags.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
344
|
-
</select>
|
|
365
|
+
{tagPickers(targets.tags)}
|
|
345
366
|
</>
|
|
346
367
|
);
|
|
347
368
|
} else {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// block (data-dyn="events"), the /calendar page and embeds. Stable
|
|
3
3
|
// .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
|
-
import type
|
|
5
|
+
import { tagFilter, type CalEvent, type Settings } from '../schema';
|
|
6
6
|
import { siteLocale } from '../dates';
|
|
7
7
|
import { eventImage } from './images';
|
|
8
8
|
import { eventUrl } from './config';
|
|
@@ -62,21 +62,23 @@ export function eventCardHtml(
|
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// next occurrence of each distinct event, soonest first
|
|
65
|
+
// next occurrence of each distinct event, soonest first; tag/excludeTag
|
|
66
|
+
// narrow by event tag (name or slug, any case)
|
|
66
67
|
export async function upcomingEvents(
|
|
67
68
|
repo: ContentRepository,
|
|
68
69
|
settings: Settings,
|
|
69
70
|
now: number,
|
|
70
|
-
opts: { calendarIds?: string[]; limit: number; horizonDays?: number }
|
|
71
|
+
opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
|
|
71
72
|
) {
|
|
72
73
|
const tz = settings.timezone || 'UTC';
|
|
74
|
+
const tagged = tagFilter(opts.tag, opts.excludeTag);
|
|
73
75
|
const { events } = await collectViewEvents(repo, settings, {
|
|
74
76
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
75
77
|
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
|
|
76
78
|
const seen = new Set<string>();
|
|
77
79
|
const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
|
|
78
80
|
for (const v of events) {
|
|
79
|
-
if (!v.event || v.end <= now || seen.has(v.event.id)) continue;
|
|
81
|
+
if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
|
|
80
82
|
seen.add(v.event.id);
|
|
81
83
|
out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled) });
|
|
82
84
|
if (out.length >= opts.limit) break;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
-
import type
|
|
3
|
+
import { publicTags, tagFilter, 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 } from '../calendar/api';
|
|
@@ -23,7 +23,9 @@ export async function listCalendars(repo: ContentRepository, url: URL) {
|
|
|
23
23
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
24
24
|
return { calendars: (await repo.listCalendars()).filter(c => c.visibility === 'public').map(c => calendarToJson(c, origin)) };
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// tag/excludeTag: a public tag's name or slug (internal "#" tags are never matched, as they are never shown)
|
|
27
|
+
export const eventsQuery = z.object({ ...pageQuery, locale: localeQuery, from: isoDayOrInstant.optional(), to: isoDayOrInstant.optional(), calendar: z.string().min(1).max(200).optional(),
|
|
28
|
+
tag: z.string().min(1).max(100).optional(), excludeTag: z.string().min(1).max(100).optional(), q: z.string().max(100).optional() }).strict();
|
|
27
29
|
export async function listEvents(repo: ContentRepository, url: URL) {
|
|
28
30
|
requireCalendar();
|
|
29
31
|
const q = eventsQuery.parse(queryParams(url));
|
|
@@ -44,7 +46,9 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
44
46
|
const cal = q.calendar ? calendars.find(c => c.slug === q.calendar && c.visibility !== 'private') : null;
|
|
45
47
|
if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
|
|
46
48
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
|
|
49
|
+
const tagged = tagFilter(q.tag, q.excludeTag);
|
|
47
50
|
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
51
|
+
&& tagged(publicTags(e.tags))
|
|
48
52
|
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
49
53
|
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
50
54
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|
package/src/lib/schema.ts
CHANGED
|
@@ -155,6 +155,18 @@ export type Post = z.infer<typeof Post>;
|
|
|
155
155
|
// and for post-list blocks.
|
|
156
156
|
export const isInternalTag = (name: string) => name.startsWith('#');
|
|
157
157
|
export const publicTags = (names: string[]) => names.filter((n) => !isInternalTag(n));
|
|
158
|
+
// Filters name a tag by its name (any case) or its slug: "Bible Study",
|
|
159
|
+
// "bible study" and "bible-study" all match a "Bible Study" tag. Internal
|
|
160
|
+
// tags match by name only, so "class" never picks up "#class".
|
|
161
|
+
export function hasTag(names: string[], wanted: string): boolean {
|
|
162
|
+
const w = wanted.trim().toLowerCase();
|
|
163
|
+
if (!w) return false;
|
|
164
|
+
const slug = /^[a-z0-9-]+$/.test(w);
|
|
165
|
+
return names.some((n) => n.trim().toLowerCase() === w || (slug && !isInternalTag(n.trim()) && slugify(n) === w));
|
|
166
|
+
}
|
|
167
|
+
/** only `tag` (when set), never `excludeTag` (when set) */
|
|
168
|
+
export const tagFilter = (tag?: string, excludeTag?: string) => (names: string[]) =>
|
|
169
|
+
(!tag?.trim() || hasTag(names, tag)) && (!excludeTag?.trim() || !hasTag(names, excludeTag));
|
|
158
170
|
|
|
159
171
|
export const MediaVariant = z.object({ w: z.number(), key: z.string() });
|
|
160
172
|
export type MediaVariant = z.infer<typeof MediaVariant>;
|
|
@@ -18,6 +18,8 @@ export interface SectionTargets {
|
|
|
18
18
|
calendars: { slug: string; name: string }[];
|
|
19
19
|
collections: { key: string; label: string }[];
|
|
20
20
|
tags: string[];
|
|
21
|
+
/** tags on published events (the events source picks from these) */
|
|
22
|
+
eventTags: string[];
|
|
21
23
|
resources: { slug: string; name: string }[];
|
|
22
24
|
features: { events: boolean; bookings: boolean };
|
|
23
25
|
/** AI help for text fields: provider configured + languages to translate into */
|
|
@@ -26,6 +28,13 @@ export interface SectionTargets {
|
|
|
26
28
|
i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// one entry per tag, ignoring case (the first spelling wins), A–Z
|
|
32
|
+
function uniqueTags(names: string[]) {
|
|
33
|
+
const seen = new Map<string, string>();
|
|
34
|
+
for (const n of names.map((x) => x.trim()).filter(Boolean)) if (!seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
|
|
35
|
+
return [...seen.values()].sort((a, b) => a.localeCompare(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
|
|
30
39
|
const bookings = bookingsEnabled();
|
|
31
40
|
const settings = await repo.getSettings();
|
|
@@ -54,6 +63,7 @@ export async function sectionTargets(repo: ContentRepository, locals?: unknown):
|
|
|
54
63
|
calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
|
|
55
64
|
collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
|
|
56
65
|
tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
|
|
66
|
+
eventTags: uniqueTags(events.flatMap((e) => e.tags)),
|
|
57
67
|
resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
|
|
58
68
|
features: { events: CALENDAR.enabled, bookings },
|
|
59
69
|
ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
|
package/src/lib/sections.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import sanitizeHtml from 'sanitize-html';
|
|
2
2
|
import type { z } from 'zod';
|
|
3
3
|
import type { ContentRepository } from './repo/types';
|
|
4
|
-
import { authorSlug, type Settings, type User } from './schema';
|
|
4
|
+
import { authorSlug, tagFilter, type Settings, type User } from './schema';
|
|
5
5
|
import { htmlToPlaintext } from './plaintext';
|
|
6
6
|
import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
|
|
7
7
|
import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
|
|
@@ -21,7 +21,8 @@ import { localizeDoc, isExtraLocale } from './translations';
|
|
|
21
21
|
// • migrations/<type>.mjs upgrades older instances on the fly
|
|
22
22
|
// • link fields resolve their ref (page/post/event/booking) to today's URL
|
|
23
23
|
// • richtext is sanitized
|
|
24
|
-
// • source fields ("next 3 Youth events"
|
|
24
|
+
// • source fields ("next 3 Youth events", "events not tagged Class") become
|
|
25
|
+
// fresh item arrays
|
|
25
26
|
|
|
26
27
|
type Instance = z.infer<typeof SectionInstanceSchema>;
|
|
27
28
|
type Definition = z.infer<typeof SectionDefinition>;
|
|
@@ -121,7 +122,7 @@ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = s
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
|
|
124
|
-
const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
|
|
125
|
+
const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; excludeTag?: string; resource?: string; limit?: number };
|
|
125
126
|
const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
|
|
126
127
|
const { repo, settings, now } = ctx;
|
|
127
128
|
// another language (settings.locale is set to it by the renderer)
|
|
@@ -137,7 +138,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
137
138
|
}
|
|
138
139
|
const tz = settings.timezone || 'UTC';
|
|
139
140
|
const locale = siteLocale(settings);
|
|
140
|
-
return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
|
|
141
|
+
return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
|
|
141
142
|
const e = lang ? localizeDoc(o.event, lang) : o.event;
|
|
142
143
|
const f = formatOccurrence(o, tz, locale);
|
|
143
144
|
const ctaHref = safeUrl(e.cta.href);
|
|
@@ -152,7 +153,8 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
|
|
|
152
153
|
if (field.source === 'posts') {
|
|
153
154
|
const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
|
|
154
155
|
let posts = await repo.listPublishedPosts(collection);
|
|
155
|
-
|
|
156
|
+
const tagged = tagFilter(cfg.tag, cfg.excludeTag);
|
|
157
|
+
posts = posts.filter((p) => tagged(p.tags));
|
|
156
158
|
const shown = posts.slice(0, limit);
|
|
157
159
|
// public byline only: name, profile URL, avatar — never emails or roles
|
|
158
160
|
const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
|
|
@@ -14,14 +14,16 @@ 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
18
|
|
|
18
19
|
// Public read API for other sites, apps and lobby screens.
|
|
19
20
|
// GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
|
|
21
|
+
// GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
|
|
20
22
|
// → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
|
|
21
23
|
// GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
|
|
22
24
|
// Anonymous callers see public calendars (and an unlisted one when asked by
|
|
23
25
|
// slug). A key with events:read also sees private calendars and private
|
|
24
|
-
// custom fields.
|
|
26
|
+
// custom fields, and its tag filters also match internal "#" tags.
|
|
25
27
|
const CORS = {
|
|
26
28
|
'Access-Control-Allow-Origin': '*',
|
|
27
29
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -55,6 +57,11 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
55
57
|
return full ? true : eventIsListed(e, map);
|
|
56
58
|
});
|
|
57
59
|
if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
|
|
60
|
+
const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
|
|
61
|
+
if (tag || excludeTag) {
|
|
62
|
+
const tagged = tagFilter(tag, excludeTag);
|
|
63
|
+
events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
|
|
64
|
+
}
|
|
58
65
|
|
|
59
66
|
if (url.searchParams.get('expand') === '0') {
|
|
60
67
|
return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
|
package/src/server/index.ts
CHANGED
|
@@ -44,6 +44,10 @@ export interface ListEventsOptions {
|
|
|
44
44
|
calendar?: string;
|
|
45
45
|
/** only events this Team member hosts (CalEvent.hostIds) */
|
|
46
46
|
hostedBy?: string;
|
|
47
|
+
/** only events with this tag (name or slug, any case) */
|
|
48
|
+
tag?: string;
|
|
49
|
+
/** leave out events with this tag (name or slug, any case) */
|
|
50
|
+
excludeTag?: string;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export interface OccurrenceWindow {
|
|
@@ -52,6 +56,10 @@ export interface OccurrenceWindow {
|
|
|
52
56
|
/** window start and end, as a Date or Unix milliseconds; at most 400 days */
|
|
53
57
|
from: Date | number;
|
|
54
58
|
to: Date | number;
|
|
59
|
+
/** only events with this tag (name or slug, any case) */
|
|
60
|
+
tag?: string;
|
|
61
|
+
/** leave out events with this tag (name or slug, any case) */
|
|
62
|
+
excludeTag?: string;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Where a write came from: used for webhook URLs and background work. */
|
|
@@ -108,6 +116,10 @@ export async function listEvents(repo: Repo, opts: ListEventsOptions = {}): Prom
|
|
|
108
116
|
events = events.filter((e) => e.calendarIds.includes(cal.id));
|
|
109
117
|
}
|
|
110
118
|
if (opts.hostedBy) events = events.filter((e) => e.hostIds.includes(opts.hostedBy!));
|
|
119
|
+
if (opts.tag || opts.excludeTag) {
|
|
120
|
+
const tagged = (await import('../lib/schema')).tagFilter(opts.tag, opts.excludeTag);
|
|
121
|
+
events = events.filter((e) => tagged(e.tags));
|
|
122
|
+
}
|
|
111
123
|
return events;
|
|
112
124
|
}
|
|
113
125
|
|
|
@@ -126,7 +138,7 @@ export async function listOccurrences(repo: Repo, window: OccurrenceWindow): Pro
|
|
|
126
138
|
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError('listOccurrences needs a "to" after "from".');
|
|
127
139
|
if (to - from > MAX_WINDOW) throw new RangeError('listOccurrences windows are at most 400 days.');
|
|
128
140
|
const r = internal(repo);
|
|
129
|
-
const [{ expandEvents }, settings] = await Promise.all([import('../lib/calendar/events'), r.getSettings()]);
|
|
141
|
+
const [{ expandEvents }, { tagFilter }, settings] = await Promise.all([import('../lib/calendar/events'), import('../lib/schema'), r.getSettings()]);
|
|
130
142
|
let events: CalEvent[];
|
|
131
143
|
if (window.eventId) {
|
|
132
144
|
const e = await r.getEvent(window.eventId);
|
|
@@ -134,7 +146,8 @@ export async function listOccurrences(repo: Repo, window: OccurrenceWindow): Pro
|
|
|
134
146
|
} else {
|
|
135
147
|
events = (await r.listEvents()).filter((e) => e.status !== 'archived');
|
|
136
148
|
}
|
|
137
|
-
|
|
149
|
+
const tagged = tagFilter(window.tag, window.excludeTag);
|
|
150
|
+
return expandEvents(events.filter((e) => tagged(e.tags)), settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
/**
|
|
@@ -20,6 +20,10 @@ export interface UpcomingEventsOptions {
|
|
|
20
20
|
limit?: number;
|
|
21
21
|
/** Look-ahead window in days; defaults to 400. */
|
|
22
22
|
horizonDays?: number;
|
|
23
|
+
/** Only events with this tag (name or slug, any case). */
|
|
24
|
+
tag?: string;
|
|
25
|
+
/** Leave out events with this tag (name or slug, any case). */
|
|
26
|
+
excludeTag?: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/**
|
|
@@ -32,7 +36,7 @@ export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay
|
|
|
32
36
|
/** Server-side helper for Astro templates and endpoints. No repository access needed. */
|
|
33
37
|
export async function upcomingEvents(
|
|
34
38
|
context: { locals: unknown },
|
|
35
|
-
{ limit = 6, horizonDays = 400 }: UpcomingEventsOptions = {},
|
|
39
|
+
{ limit = 6, horizonDays = 400, tag, excludeTag }: UpcomingEventsOptions = {},
|
|
36
40
|
): Promise<UpcomingEvent[]> {
|
|
37
41
|
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
38
42
|
throw new RangeError('upcomingEvents limit must be a non-negative safe integer');
|
|
@@ -48,5 +52,5 @@ export async function upcomingEvents(
|
|
|
48
52
|
]);
|
|
49
53
|
const repo = await getRepo(context.locals);
|
|
50
54
|
const settings = await repo.getSettings();
|
|
51
|
-
return loadUpcomingEvents(repo, settings, Date.now(), { limit, horizonDays });
|
|
55
|
+
return loadUpcomingEvents(repo, settings, Date.now(), { limit, horizonDays, tag, excludeTag });
|
|
52
56
|
}
|
package/src/theme-sdk/fields.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// number → number toggle → boolean
|
|
8
8
|
// link → { label, href, ref? } (ref = 'page:<id>' | 'post:<id>' | 'event:<id>' | 'booking:<id>')
|
|
9
9
|
// list → Array<Record<subKey, scalar | link>>
|
|
10
|
-
// source → { calendar?, collection?, tag?, resource?, limit? } (resolved to items at render time
|
|
10
|
+
// source → { calendar?, collection?, tag?, excludeTag?, resource?, limit? } (resolved to items at render time;
|
|
11
|
+
// tags are names or slugs, for events and posts)
|
|
11
12
|
|
|
12
13
|
export const SCALAR_TYPES = /** @type {const} */ (['text', 'textarea', 'richtext', 'image', 'url', 'link', 'select', 'number', 'toggle', 'date', 'time']);
|
|
13
14
|
export const SOURCE_KINDS = /** @type {const} */ (['events', 'posts', 'bookings']);
|
|
@@ -218,9 +219,13 @@ export function valueIssues(field, value, path = field.label) {
|
|
|
218
219
|
if (!isObject(value)) return bad();
|
|
219
220
|
const limit = value.limit ?? 3;
|
|
220
221
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > SOURCE_LIMIT_MAX) return bad(`${field.label}: show between 1 and ${SOURCE_LIMIT_MAX}`);
|
|
221
|
-
for (const k of ['calendar', 'collection', '
|
|
222
|
+
for (const k of ['calendar', 'collection', 'resource']) {
|
|
222
223
|
if (value[k] !== undefined && (typeof value[k] !== 'string' || !/^[a-z0-9-]{0,100}$/i.test(value[k]))) return bad();
|
|
223
224
|
}
|
|
225
|
+
// tag names, like "Bible Study" or "#class"
|
|
226
|
+
for (const k of ['tag', 'excludeTag']) {
|
|
227
|
+
if (value[k] !== undefined && (typeof value[k] !== 'string' || value[k].length > 100 || /[\u0000-\u001f\u007f]/.test(value[k]))) return bad();
|
|
228
|
+
}
|
|
224
229
|
return [];
|
|
225
230
|
}
|
|
226
231
|
default: return typeof value === 'string' ? [] : bad();
|