@acms-subir/core 0.3.46 → 0.3.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/lib/calendar/cards.ts +9 -6
- package/src/lib/calendar/data.ts +25 -9
- package/src/lib/calendar/event-words.ts +124 -0
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/render.ts +20 -16
- package/src/lib/mobile/calendar.ts +27 -12
- package/src/lib/mobile/http.ts +5 -3
- package/src/pages/api/calendar/events/index.ts +18 -12
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- package/src/styles/site.css +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.47
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 26f2060: The public calendar can be filtered from its address, and speaks the page's language.
|
|
8
|
+
|
|
9
|
+
**Filters.** `/calendar?tag=Class` shows only classes, `/calendar?notag=Class&cal=events` shows the Events calendar without them, and `/calendar?room=chapel` shows what happens in a room. `tag` keeps events with any of its tags, `notag` (alias `excludeTag`) leaves them out, `cal` (alias `calendar`) now takes several calendars, and `room` takes room slugs. Each can repeat (`tag=a&tag=b`) or be comma-separated (`tag=a,b`); different filters must all match. Tags keep their rules (a name in any case, or its slug; internal `#` tags never match publicly). Only rooms shown on public events match, and a private calendar or a hidden room answers exactly like one that doesn't exist, so a link can't probe for them. Events from linked feed calendars and "Reserved" booking blocks have no tags: `tag` leaves them out, `notag` keeps them, and a room filter keeps a room's own Reserved blocks.
|
|
10
|
+
|
|
11
|
+
On `/calendar` the calendar chips now switch calendars on and off, so several can be picked; tags, left-out tags and rooms show as removable chips with **Clear filters**; and view links, chips and **Subscribe** keep the filters. A filtered page subscribes to `/calendar.ics` with the same filters (one calendar alone keeps its own feed). The same parameters work on `/embed/calendar/<slug|all>` (`cal` adds calendars to the one in the address), `/calendar.ics`, `/calendar/<slug>.ics` (`tag`, `notag`, `room`), `/api/v1/events` (now documented in the OpenAPI document; repeating them is allowed, other parameters still may not repeat) and `/api/calendar/events`, where a key with `events:read` also matches private calendars, every room and `#` tags. An unknown, private or hidden calendar or room is a 404 on the APIs and feeds, and an empty calendar with its chip on the page.
|
|
12
|
+
|
|
13
|
+
**Languages.** `/es/calendar` is in Spanish like event pages: the headings, chips, views, Subscribe menu, agenda, empty states, the grid's Today/Upcoming/Details and the dates and time zone. Event titles use their translations, and every link on the page (views, chips, events, cards) stays under `/es`. French, German and Portuguese work the same way. Embeds take `?lang=es` (one of the site's languages). The calendar card ("No calendar connected", "Calendar unavailable right now", "Open in Google Calendar") and event cards ("Untitled event", "No upcoming events") also speak their dates' language.
|
|
14
|
+
|
|
3
15
|
## 0.3.46
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import { eventImage } from './images';
|
|
|
9
9
|
import { eventUrl } from './config';
|
|
10
10
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
11
11
|
import { collectViewEvents } from './data';
|
|
12
|
+
import type { ResolvedEventFilter } from './filter';
|
|
12
13
|
|
|
13
14
|
export interface EventCardAttrs {
|
|
14
15
|
mode: 'single' | 'upcoming';
|
|
@@ -56,7 +57,7 @@ export function eventCardHtml(
|
|
|
56
57
|
return (
|
|
57
58
|
`<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
|
|
58
59
|
`<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
|
|
59
|
-
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title ||
|
|
60
|
+
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || eventStrings(siteLang(settings)).calendar.untitled)}</a></${h}>` +
|
|
60
61
|
(e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
|
|
61
62
|
(loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
|
|
62
63
|
cta +
|
|
@@ -65,18 +66,19 @@ export function eventCardHtml(
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// next occurrence of each distinct event, soonest first; tag/excludeTag
|
|
68
|
-
// narrow by event tag (name or slug, any case)
|
|
69
|
+
// narrow by event tag (name or slug, any case), and filter by an address's
|
|
70
|
+
// cal/tag/notag/room (filter.ts); locale: titles and links in that language
|
|
69
71
|
export async function upcomingEvents(
|
|
70
72
|
repo: ContentRepository,
|
|
71
73
|
settings: Settings,
|
|
72
74
|
now: number,
|
|
73
|
-
opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string }
|
|
75
|
+
opts: { calendarIds?: string[]; limit: number; horizonDays?: number; tag?: string; excludeTag?: string; filter?: ResolvedEventFilter; locale?: string }
|
|
74
76
|
) {
|
|
75
77
|
const tz = settings.timezone || 'UTC';
|
|
76
78
|
const tagged = tagFilter(opts.tag, opts.excludeTag);
|
|
77
79
|
const { events } = await collectViewEvents(repo, settings, {
|
|
78
80
|
displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
|
|
79
|
-
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false });
|
|
81
|
+
}, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
|
|
80
82
|
const seen = new Set<string>();
|
|
81
83
|
const out: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>[] = [];
|
|
82
84
|
for (const v of events) {
|
|
@@ -98,13 +100,14 @@ export async function renderEventCards(a: EventCardAttrs, repo: ContentRepositor
|
|
|
98
100
|
if (!o) return wrap('<p class="pc-ev-empty">This event isn’t available.</p>', ' is-empty');
|
|
99
101
|
return wrap(eventCardHtml(o, settings, { showImage: a.showImage, showCta: true }), ' is-single');
|
|
100
102
|
}
|
|
103
|
+
const noUpcoming = eventStrings(siteLang(settings)).calendar.noUpcoming;
|
|
101
104
|
let calendarIds: string[] | undefined;
|
|
102
105
|
if (a.calendar) {
|
|
103
106
|
const c = await repo.getCalendarBySlug(a.calendar);
|
|
104
|
-
if (!c || c.visibility === 'private') return wrap(
|
|
107
|
+
if (!c || c.visibility === 'private') return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
|
|
105
108
|
calendarIds = [c.id];
|
|
106
109
|
}
|
|
107
110
|
const items = await upcomingEvents(repo, settings, now, { calendarIds, limit: a.limit });
|
|
108
|
-
if (!items.length) return wrap(
|
|
111
|
+
if (!items.length) return wrap(`<p class="pc-ev-empty">${esc(noUpcoming)}</p>`, ' is-empty');
|
|
109
112
|
return wrap(items.map((o) => eventCardHtml(o, settings, { showImage: a.showImage })).join(''));
|
|
110
113
|
}
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -7,6 +7,10 @@ import { roomColor, type CalEvent, type Calendar, type Resource, type Settings }
|
|
|
7
7
|
import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
|
|
8
8
|
import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
|
|
9
9
|
import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
|
|
10
|
+
import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
|
|
11
|
+
import { eventStrings } from './event-words';
|
|
12
|
+
import { localePath } from '../i18n';
|
|
13
|
+
import { localizeDoc } from '../translations';
|
|
10
14
|
|
|
11
15
|
export interface ViewEvent extends CalendarEvent {
|
|
12
16
|
url?: string;
|
|
@@ -34,6 +38,12 @@ export interface ViewScope {
|
|
|
34
38
|
includeFeeds?: boolean;
|
|
35
39
|
// "Reserved" blocks from resources set to show on the calendar
|
|
36
40
|
includeBookings?: boolean;
|
|
41
|
+
// the address's cal/tag/notag/room (filter.ts); its calendars narrow
|
|
42
|
+
// calendarIds when both are given
|
|
43
|
+
filter?: ResolvedEventFilter;
|
|
44
|
+
// the request's language (requestLocale): translated titles, event links
|
|
45
|
+
// under its /<locale> prefix, and "Reserved" in its words
|
|
46
|
+
locale?: string;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
@@ -46,7 +56,9 @@ export async function collectViewEvents(
|
|
|
46
56
|
): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
|
|
47
57
|
const all = await repo.listCalendars();
|
|
48
58
|
const byId = new Map(all.map((c) => [c.id, c]));
|
|
49
|
-
const
|
|
59
|
+
const f = scope.filter;
|
|
60
|
+
const ids = scope.calendarIds && f?.calendarIds ? scope.calendarIds.filter((id) => f.calendarIds!.includes(id)) : scope.calendarIds ?? f?.calendarIds;
|
|
61
|
+
const requested = ids ? new Set(ids) : null;
|
|
50
62
|
const visible = all.filter((c) => {
|
|
51
63
|
if (scope.audience === 'admin') return true;
|
|
52
64
|
// a directly requested calendar may be unlisted; private never is
|
|
@@ -60,6 +72,7 @@ export async function collectViewEvents(
|
|
|
60
72
|
const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
|
|
61
73
|
const natives = source.filter((e) => {
|
|
62
74
|
if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
|
|
75
|
+
if (f && !tagsMatch(f, e.tags, { internal: f.internal })) return false;
|
|
63
76
|
if (!e.calendarIds.length) {
|
|
64
77
|
if (requested) return false;
|
|
65
78
|
return scope.includeUncategorized ?? true;
|
|
@@ -73,39 +86,42 @@ export async function collectViewEvents(
|
|
|
73
86
|
const shown = new Map<string, CalEvent>();
|
|
74
87
|
const view = (e: CalEvent) => {
|
|
75
88
|
if (scope.audience === 'admin') return e;
|
|
76
|
-
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
|
|
89
|
+
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
|
|
77
90
|
return shown.get(e.id)!;
|
|
78
91
|
};
|
|
79
92
|
const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
|
|
80
93
|
const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
|
|
81
94
|
.filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
|
|
82
95
|
.map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
|
|
96
|
+
const words = eventStrings(scope.locale).calendar;
|
|
83
97
|
for (const o of expandEvents(natives, settings, win)) {
|
|
84
|
-
|
|
98
|
+
// rooms are per date (a moved date can change them)
|
|
99
|
+
if (f?.roomIds && !occurrenceMatches(f, o)) continue;
|
|
100
|
+
const e = view(o.event);
|
|
85
101
|
const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
|
|
86
102
|
const p = tzParts(o.start, tz);
|
|
87
103
|
const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
|
|
88
104
|
events.push({
|
|
89
105
|
start: o.start, end: o.end, allDay: o.allDay, days: o.days,
|
|
90
|
-
title: e.title ||
|
|
106
|
+
title: e.title || words.untitled,
|
|
91
107
|
location: [e.location.name, e.location.address].filter(Boolean).join(', '),
|
|
92
108
|
description: e.excerpt,
|
|
93
109
|
cal: cal ? idx.get(cal.id) ?? 0 : 0,
|
|
94
110
|
ref: e.id,
|
|
95
|
-
url: eventUrl(e.slug) + on,
|
|
111
|
+
url: localePath(eventUrl(e.slug), scope.locale ?? '') + on,
|
|
96
112
|
color: cal ? safeColor(cal.color) : undefined,
|
|
97
113
|
cancelled: o.cancelled,
|
|
98
114
|
image: e.cover || undefined,
|
|
99
115
|
imageAlt: e.coverAlt || undefined,
|
|
100
116
|
calendarName: cal?.name,
|
|
101
|
-
event:
|
|
117
|
+
event: e,
|
|
102
118
|
rooms: rooms(o.roomIds),
|
|
103
119
|
});
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
const errors: string[] = [];
|
|
107
123
|
if (scope.includeFeeds ?? true) {
|
|
108
|
-
const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
|
|
124
|
+
const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl && (!f || untaggedMatches(f, { calendarId: c.id })));
|
|
109
125
|
const results = await Promise.all(
|
|
110
126
|
feeds.map(async (c) => {
|
|
111
127
|
try {
|
|
@@ -125,7 +141,7 @@ export async function collectViewEvents(
|
|
|
125
141
|
// resources marked "show on calendar": confirmed bookings appear as
|
|
126
142
|
// Reserved blocks (no guest names on public views) — only on unscoped views
|
|
127
143
|
if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
|
|
128
|
-
const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
|
|
144
|
+
const resources = (await repo.listResources()).filter((r) => r.showOnCalendar && (!f || untaggedMatches(f, { roomId: r.id })));
|
|
129
145
|
if (resources.length) {
|
|
130
146
|
const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
|
|
131
147
|
const byId = new Map(resources.map((r) => [r.id, r]));
|
|
@@ -135,7 +151,7 @@ export async function collectViewEvents(
|
|
|
135
151
|
const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
|
|
136
152
|
events.push({
|
|
137
153
|
start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
|
|
138
|
-
title: scope.audience === 'admin' ? `${r.name} · ${b.name}` :
|
|
154
|
+
title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `${words.reserved} · ${r.name}`,
|
|
139
155
|
location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
|
|
140
156
|
url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
|
|
141
157
|
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// describeRepeat). Plain strings with {date} and {place} slots (live/words.ts
|
|
5
5
|
// `fill`); the repeat line is built by functions, since word order differs
|
|
6
6
|
// per language. No server imports: the event editor shares the repeat line.
|
|
7
|
+
// `calendar` holds the words of the /calendar page, embeds and the calendar
|
|
8
|
+
// card (render.ts).
|
|
7
9
|
// The event's own words (title, button, fields) stay as written. Spanish
|
|
8
10
|
// matches the push notices ("Cancelado el …"). Other languages fall back to
|
|
9
11
|
// English.
|
|
@@ -35,6 +37,58 @@ export interface EventStrings {
|
|
|
35
37
|
fullCalendar: string;
|
|
36
38
|
allDay: string;
|
|
37
39
|
repeat: RepeatWords;
|
|
40
|
+
calendar: CalendarWords;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The calendar page, its filters, embeds and the calendar card. */
|
|
44
|
+
export interface CalendarWords {
|
|
45
|
+
title: string;
|
|
46
|
+
/** under the title; {site} is the site's name */
|
|
47
|
+
lede: string;
|
|
48
|
+
/** landmark labels */
|
|
49
|
+
calendars: string;
|
|
50
|
+
filters: string;
|
|
51
|
+
views: string;
|
|
52
|
+
nextTwoWeeks: string;
|
|
53
|
+
agenda: string;
|
|
54
|
+
/** the chip for every calendar */
|
|
55
|
+
all: string;
|
|
56
|
+
month: string;
|
|
57
|
+
week: string;
|
|
58
|
+
list: string;
|
|
59
|
+
/** a filter chip: "Without Class", "Room: Chapel" (a tag chip is the tag alone) */
|
|
60
|
+
without: string;
|
|
61
|
+
room: string;
|
|
62
|
+
/** the × on a chip, for screen readers */
|
|
63
|
+
remove: string;
|
|
64
|
+
clear: string;
|
|
65
|
+
subscribe: string;
|
|
66
|
+
subscribeAll: string;
|
|
67
|
+
subscribeNamed: string;
|
|
68
|
+
subscribeFiltered: string;
|
|
69
|
+
copyLink: string;
|
|
70
|
+
comingUp: string;
|
|
71
|
+
/** the footer: {zone} is "Eastern Daylight Time" in the page's language */
|
|
72
|
+
timesIn: string;
|
|
73
|
+
feedErrors: string;
|
|
74
|
+
empty: string;
|
|
75
|
+
emptySoon: string;
|
|
76
|
+
noMatch: string;
|
|
77
|
+
/** a confirmed booking on the public calendar: "Reserved · {room}" */
|
|
78
|
+
reserved: string;
|
|
79
|
+
untitled: string;
|
|
80
|
+
/** the grid's toolbar and pop-up */
|
|
81
|
+
today: string;
|
|
82
|
+
previous: string;
|
|
83
|
+
next: string;
|
|
84
|
+
upcoming: string;
|
|
85
|
+
noUpcoming: string;
|
|
86
|
+
details: string;
|
|
87
|
+
close: string;
|
|
88
|
+
/** the calendar card's own messages */
|
|
89
|
+
noSource: string;
|
|
90
|
+
unavailable: string;
|
|
91
|
+
openGoogle: string;
|
|
38
92
|
}
|
|
39
93
|
|
|
40
94
|
/** Weekdays are 0 = Sunday; `n` is the interval (every n days/weeks/months/years). */
|
|
@@ -90,6 +144,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
90
144
|
until: (d) => `, until ${d}`,
|
|
91
145
|
other: 'Repeats',
|
|
92
146
|
},
|
|
147
|
+
calendar: {
|
|
148
|
+
title: 'Calendar', lede: 'What’s happening at {site}. Subscribe to keep it in your own calendar.',
|
|
149
|
+
calendars: 'Calendars', filters: 'Filters', views: 'View', nextTwoWeeks: 'Next two weeks', agenda: 'Agenda',
|
|
150
|
+
all: 'All', month: 'Month', week: 'Week', list: 'List',
|
|
151
|
+
without: 'Without {tag}', room: 'Room: {room}', remove: 'Remove {filter}', clear: 'Clear filters',
|
|
152
|
+
subscribe: 'Subscribe', subscribeAll: 'Add this calendar to your calendar app. New events show up on their own.',
|
|
153
|
+
subscribeNamed: 'Add “{name}” to your calendar app. New events show up on their own.',
|
|
154
|
+
subscribeFiltered: 'Add these events to your calendar app. New ones show up on their own.',
|
|
155
|
+
copyLink: 'Copy link', comingUp: 'Coming up', timesIn: 'Times in {zone}', feedErrors: 'Some linked calendars couldn’t be loaded right now.',
|
|
156
|
+
empty: 'Nothing on the calendar yet.', emptySoon: 'Nothing in the next two months.', noMatch: 'Nothing matches these filters.',
|
|
157
|
+
reserved: 'Reserved', untitled: 'Untitled event',
|
|
158
|
+
today: 'Today', previous: 'Previous', next: 'Next', upcoming: 'Upcoming', noUpcoming: 'No upcoming events.', details: 'Details', close: 'Close',
|
|
159
|
+
noSource: 'No calendar connected.', unavailable: 'Calendar unavailable right now.', openGoogle: 'Open in Google Calendar ↗',
|
|
160
|
+
},
|
|
93
161
|
},
|
|
94
162
|
es: {
|
|
95
163
|
cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
|
|
@@ -108,6 +176,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
108
176
|
until: (d) => `, hasta el ${d}`,
|
|
109
177
|
other: 'Se repite',
|
|
110
178
|
},
|
|
179
|
+
calendar: {
|
|
180
|
+
title: 'Calendario', lede: 'Lo que pasa en {site}. Suscríbete para tenerlo en tu propio calendario.',
|
|
181
|
+
calendars: 'Calendarios', filters: 'Filtros', views: 'Vista', nextTwoWeeks: 'Próximas dos semanas', agenda: 'Agenda',
|
|
182
|
+
all: 'Todos', month: 'Mes', week: 'Semana', list: 'Lista',
|
|
183
|
+
without: 'Sin {tag}', room: 'Sala: {room}', remove: 'Quitar {filter}', clear: 'Quitar filtros',
|
|
184
|
+
subscribe: 'Suscribirse', subscribeAll: 'Agrega este calendario a tu app de calendario. Los eventos nuevos aparecen solos.',
|
|
185
|
+
subscribeNamed: 'Agrega “{name}” a tu app de calendario. Los eventos nuevos aparecen solos.',
|
|
186
|
+
subscribeFiltered: 'Agrega estos eventos a tu app de calendario. Los nuevos aparecen solos.',
|
|
187
|
+
copyLink: 'Copiar enlace', comingUp: 'Próximamente', timesIn: 'Horario de {zone}', feedErrors: 'Algunos calendarios vinculados no se pudieron cargar en este momento.',
|
|
188
|
+
empty: 'Todavía no hay nada en el calendario.', emptySoon: 'Nada en los próximos dos meses.', noMatch: 'Nada coincide con estos filtros.',
|
|
189
|
+
reserved: 'Reservado', untitled: 'Evento sin título',
|
|
190
|
+
today: 'Hoy', previous: 'Anterior', next: 'Siguiente', upcoming: 'Próximos', noUpcoming: 'No hay eventos próximos.', details: 'Detalles', close: 'Cerrar',
|
|
191
|
+
noSource: 'No hay ningún calendario conectado.', unavailable: 'El calendario no está disponible en este momento.', openGoogle: 'Abrir en Google Calendar ↗',
|
|
192
|
+
},
|
|
111
193
|
},
|
|
112
194
|
fr: {
|
|
113
195
|
cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
|
|
@@ -126,6 +208,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
126
208
|
until: (d) => `, jusqu’au ${d}`,
|
|
127
209
|
other: 'Récurrent',
|
|
128
210
|
},
|
|
211
|
+
calendar: {
|
|
212
|
+
title: 'Calendrier', lede: 'Ce qui se passe à {site}. Abonnez-vous pour le retrouver dans votre propre calendrier.',
|
|
213
|
+
calendars: 'Calendriers', filters: 'Filtres', views: 'Affichage', nextTwoWeeks: 'Les deux prochaines semaines', agenda: 'Agenda',
|
|
214
|
+
all: 'Tous', month: 'Mois', week: 'Semaine', list: 'Liste',
|
|
215
|
+
without: 'Sans {tag}', room: 'Salle : {room}', remove: 'Retirer {filter}', clear: 'Effacer les filtres',
|
|
216
|
+
subscribe: 'S’abonner', subscribeAll: 'Ajoutez ce calendrier à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
|
|
217
|
+
subscribeNamed: 'Ajoutez « {name} » à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
|
|
218
|
+
subscribeFiltered: 'Ajoutez ces événements à votre application de calendrier. Les nouveaux s’y ajoutent d’eux-mêmes.',
|
|
219
|
+
copyLink: 'Copier le lien', comingUp: 'À venir', timesIn: 'Heures : {zone}', feedErrors: 'Certains calendriers liés n’ont pas pu être chargés pour le moment.',
|
|
220
|
+
empty: 'Rien au calendrier pour l’instant.', emptySoon: 'Rien dans les deux prochains mois.', noMatch: 'Rien ne correspond à ces filtres.',
|
|
221
|
+
reserved: 'Réservé', untitled: 'Événement sans titre',
|
|
222
|
+
today: 'Aujourd’hui', previous: 'Précédent', next: 'Suivant', upcoming: 'À venir', noUpcoming: 'Aucun événement à venir.', details: 'Détails', close: 'Fermer',
|
|
223
|
+
noSource: 'Aucun calendrier connecté.', unavailable: 'Calendrier indisponible pour le moment.', openGoogle: 'Ouvrir dans Google Agenda ↗',
|
|
224
|
+
},
|
|
129
225
|
},
|
|
130
226
|
de: {
|
|
131
227
|
cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
|
|
@@ -144,6 +240,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
144
240
|
until: (d) => `, bis zum ${d}`,
|
|
145
241
|
other: 'Wiederkehrend',
|
|
146
242
|
},
|
|
243
|
+
calendar: {
|
|
244
|
+
title: 'Kalender', lede: 'Was bei {site} los ist. Abonnieren Sie den Kalender, um alles in Ihrem eigenen Kalender zu haben.',
|
|
245
|
+
calendars: 'Kalender', filters: 'Filter', views: 'Ansicht', nextTwoWeeks: 'Die nächsten zwei Wochen', agenda: 'Terminliste',
|
|
246
|
+
all: 'Alle', month: 'Monat', week: 'Woche', list: 'Liste',
|
|
247
|
+
without: 'Ohne {tag}', room: 'Raum: {room}', remove: '{filter} entfernen', clear: 'Filter löschen',
|
|
248
|
+
subscribe: 'Abonnieren', subscribeAll: 'Fügen Sie diesen Kalender Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
|
|
249
|
+
subscribeNamed: 'Fügen Sie „{name}“ Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
|
|
250
|
+
subscribeFiltered: 'Fügen Sie diese Veranstaltungen Ihrer Kalender-App hinzu. Neue erscheinen automatisch.',
|
|
251
|
+
copyLink: 'Link kopieren', comingUp: 'Demnächst', timesIn: 'Zeiten in {zone}', feedErrors: 'Einige verknüpfte Kalender konnten gerade nicht geladen werden.',
|
|
252
|
+
empty: 'Noch nichts im Kalender.', emptySoon: 'In den nächsten zwei Monaten steht nichts an.', noMatch: 'Nichts passt zu diesen Filtern.',
|
|
253
|
+
reserved: 'Reserviert', untitled: 'Unbenannte Veranstaltung',
|
|
254
|
+
today: 'Heute', previous: 'Zurück', next: 'Weiter', upcoming: 'Demnächst', noUpcoming: 'Keine anstehenden Veranstaltungen.', details: 'Details', close: 'Schließen',
|
|
255
|
+
noSource: 'Kein Kalender verbunden.', unavailable: 'Kalender gerade nicht verfügbar.', openGoogle: 'In Google Kalender öffnen ↗',
|
|
256
|
+
},
|
|
147
257
|
},
|
|
148
258
|
pt: {
|
|
149
259
|
cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
|
|
@@ -164,6 +274,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
164
274
|
until: (d) => `, até ${d}`,
|
|
165
275
|
other: 'Repete',
|
|
166
276
|
},
|
|
277
|
+
calendar: {
|
|
278
|
+
title: 'Calendário', lede: 'O que acontece em {site}. Assine para ter tudo no seu próprio calendário.',
|
|
279
|
+
calendars: 'Calendários', filters: 'Filtros', views: 'Visualização', nextTwoWeeks: 'Próximas duas semanas', agenda: 'Agenda',
|
|
280
|
+
all: 'Todos', month: 'Mês', week: 'Semana', list: 'Lista',
|
|
281
|
+
without: 'Sem {tag}', room: 'Sala: {room}', remove: 'Remover {filter}', clear: 'Limpar filtros',
|
|
282
|
+
subscribe: 'Assinar', subscribeAll: 'Adicione este calendário ao seu app de calendário. Os novos eventos aparecem sozinhos.',
|
|
283
|
+
subscribeNamed: 'Adicione “{name}” ao seu app de calendário. Os novos eventos aparecem sozinhos.',
|
|
284
|
+
subscribeFiltered: 'Adicione estes eventos ao seu app de calendário. Os novos aparecem sozinhos.',
|
|
285
|
+
copyLink: 'Copiar link', comingUp: 'Em breve', timesIn: 'Horário de {zone}', feedErrors: 'Alguns calendários vinculados não puderam ser carregados agora.',
|
|
286
|
+
empty: 'Ainda não há nada no calendário.', emptySoon: 'Nada nos próximos dois meses.', noMatch: 'Nada corresponde a estes filtros.',
|
|
287
|
+
reserved: 'Reservado', untitled: 'Evento sem título',
|
|
288
|
+
today: 'Hoje', previous: 'Anterior', next: 'Próximo', upcoming: 'Próximos', noUpcoming: 'Nenhum evento programado.', details: 'Detalhes', close: 'Fechar',
|
|
289
|
+
noSource: 'Nenhum calendário conectado.', unavailable: 'Calendário indisponível no momento.', openGoogle: 'Abrir no Google Agenda ↗',
|
|
290
|
+
},
|
|
167
291
|
},
|
|
168
292
|
};
|
|
169
293
|
|
|
@@ -5,6 +5,7 @@ import type { CalEvent, Calendar, Settings } from '../schema';
|
|
|
5
5
|
import { buildFeed } from './ics-write';
|
|
6
6
|
import { eventIsListed, eventIsPublic } from './events';
|
|
7
7
|
import { eventUrl } from './config';
|
|
8
|
+
import { eventMatches, type ResolvedEventFilter } from './filter';
|
|
8
9
|
|
|
9
10
|
export function icsResponse(body: string, filename: string, cacheSeconds = 300): Response {
|
|
10
11
|
return new Response(body, {
|
|
@@ -21,15 +22,22 @@ export async function feedFor(
|
|
|
21
22
|
repo: ContentRepository,
|
|
22
23
|
settings: Settings,
|
|
23
24
|
origin: string,
|
|
24
|
-
|
|
25
|
+
// filter: the address's cal/tag/notag/room (filter.ts); its calendars
|
|
26
|
+
// stand in for `calendar` on the site-wide feed
|
|
27
|
+
scope: { calendar?: Calendar; allowPrivate?: boolean; filter?: ResolvedEventFilter }
|
|
25
28
|
): Promise<{ events: CalEvent[]; body: string }> {
|
|
26
29
|
const [published, calendars] = await Promise.all([repo.listPublishedEvents(), repo.listCalendars()]);
|
|
27
30
|
const map = new Map(calendars.map((c) => [c.id, c]));
|
|
28
31
|
const c = scope.calendar;
|
|
32
|
+
const f = scope.filter;
|
|
29
33
|
const events = published.filter((e) =>
|
|
30
|
-
c
|
|
34
|
+
(c
|
|
31
35
|
? e.calendarIds.includes(c.id) && (scope.allowPrivate || eventIsPublic(e, map) || c.visibility !== 'private')
|
|
32
|
-
:
|
|
36
|
+
: f?.calendarIds
|
|
37
|
+
? eventIsPublic(e, map)
|
|
38
|
+
: eventIsListed(e, map))
|
|
39
|
+
// a feed lists series, so rooms are the series' own
|
|
40
|
+
&& (!f || eventMatches(f, e))
|
|
33
41
|
);
|
|
34
42
|
const host = new URL(origin).host;
|
|
35
43
|
const body = buildFeed(events, settings.timezone, {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Which events a calendar view shows, read from its address. The /calendar
|
|
2
|
+
// page, embeds, /api/v1/events, /api/calendar/events, the subscribe feeds
|
|
3
|
+
// and the TV display all take the same parameters and apply the same rules:
|
|
4
|
+
// cal=youth,events only these calendars (slugs); `calendar` is an alias
|
|
5
|
+
// tag=Class only events with one of these tags
|
|
6
|
+
// notag=Class never events with any of these; `excludeTag` is an alias
|
|
7
|
+
// room=chapel only events held in one of these rooms (resource slugs)
|
|
8
|
+
// Each can repeat (tag=a&tag=b) or be comma-separated (tag=a,b). A list
|
|
9
|
+
// matches any of its values; different parameters must all match. Tags keep
|
|
10
|
+
// the hasTag rules (a name in any case, or its slug); internal "#" tags only
|
|
11
|
+
// match with allowPrivate. Publicly, private calendars and rooms not shown on
|
|
12
|
+
// public events resolve exactly like slugs that don't exist, so a link can't
|
|
13
|
+
// probe for them.
|
|
14
|
+
import { hasTag, publicTags, type CalEvent, type Calendar, type Resource } from '../schema';
|
|
15
|
+
|
|
16
|
+
export interface EventFilter {
|
|
17
|
+
cal: string[];
|
|
18
|
+
tag: string[];
|
|
19
|
+
notag: string[];
|
|
20
|
+
room: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const NO_FILTER: EventFilter = Object.freeze({ cal: [], tag: [], notag: [], room: [] }) as EventFilter;
|
|
24
|
+
|
|
25
|
+
const NAMES: Record<keyof EventFilter, string[]> = {
|
|
26
|
+
cal: ['cal', 'calendar'],
|
|
27
|
+
tag: ['tag'],
|
|
28
|
+
notag: ['notag', 'excludeTag'],
|
|
29
|
+
room: ['room'],
|
|
30
|
+
};
|
|
31
|
+
const MAX_VALUES = 20;
|
|
32
|
+
const MAX_LENGTH = 100;
|
|
33
|
+
|
|
34
|
+
/** the filter in an address's query (`Astro.url.searchParams`, `url.searchParams`) */
|
|
35
|
+
export function readEventFilter(params: URLSearchParams): EventFilter {
|
|
36
|
+
const read = (names: string[]) => {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const name of names) for (const raw of params.getAll(name)) for (const part of raw.split(',')) {
|
|
39
|
+
const v = part.trim().slice(0, MAX_LENGTH);
|
|
40
|
+
if (v && out.length < MAX_VALUES && !out.some((o) => o.toLowerCase() === v.toLowerCase())) out.push(v);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
|
44
|
+
return { cal: read(NAMES.cal), tag: read(NAMES.tag), notag: read(NAMES.notag), room: read(NAMES.room) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const hasEventFilter = (f: EventFilter) => f.cal.length + f.tag.length + f.notag.length + f.room.length > 0;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The filter as a query string without the "?" ('' when empty), in the
|
|
51
|
+
* canonical names with comma-separated values: "cal=events¬ag=Class".
|
|
52
|
+
* For links, subscribe feeds and API calls that should show the same events.
|
|
53
|
+
*/
|
|
54
|
+
export function eventFilterQuery(f: EventFilter): string {
|
|
55
|
+
return (Object.keys(NAMES) as (keyof EventFilter)[])
|
|
56
|
+
.filter((k) => f[k].length)
|
|
57
|
+
.map((k) => `${k}=${f[k].map(encodeURIComponent).join(',')}`)
|
|
58
|
+
.join('&');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ResolvedEventFilter {
|
|
62
|
+
/** null: no calendar filter; [] when `cal` was given but none resolved (the view is then empty) */
|
|
63
|
+
calendarIds: string[] | null;
|
|
64
|
+
/** null: no room filter; [] when `room` was given but none resolved */
|
|
65
|
+
roomIds: string[] | null;
|
|
66
|
+
/** the calendars and rooms that resolved, in the order the address gave them */
|
|
67
|
+
calendars: Calendar[];
|
|
68
|
+
rooms: Resource[];
|
|
69
|
+
tag: string[];
|
|
70
|
+
notag: string[];
|
|
71
|
+
/** internal "#" tags match (allowPrivate) */
|
|
72
|
+
internal: boolean;
|
|
73
|
+
/** some `cal` or `room` value didn't resolve (unknown, private or hidden) */
|
|
74
|
+
unknown: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Turns slugs into ids. Publicly, unlisted calendars resolve (as a direct
|
|
80
|
+
* `?cal=` link always has) but private ones don't, and only rooms shown on
|
|
81
|
+
* public events resolve. allowPrivate (API keys with events:read, saved
|
|
82
|
+
* screens) resolves every calendar and room and lets "#" tags match.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveEventFilter(
|
|
85
|
+
f: EventFilter,
|
|
86
|
+
ctx: { calendars: Calendar[]; resources?: Resource[]; allowPrivate?: boolean }
|
|
87
|
+
): ResolvedEventFilter {
|
|
88
|
+
const allowPrivate = ctx.allowPrivate ?? false;
|
|
89
|
+
const pick = <T extends { id: string; slug: string }>(slugs: string[], items: T[], visible: (item: T) => boolean) => {
|
|
90
|
+
const found: T[] = [];
|
|
91
|
+
for (const s of slugs) {
|
|
92
|
+
const item = items.find((i) => i.slug === s.toLowerCase() && visible(i));
|
|
93
|
+
if (item && !found.includes(item)) found.push(item);
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
};
|
|
97
|
+
const calendars = pick(f.cal, ctx.calendars, (c) => allowPrivate || c.visibility !== 'private');
|
|
98
|
+
const rooms = pick(f.room, ctx.resources ?? [], (r) => allowPrivate || r.showOnEvents);
|
|
99
|
+
return {
|
|
100
|
+
calendarIds: f.cal.length ? calendars.map((c) => c.id) : null,
|
|
101
|
+
roomIds: f.room.length ? rooms.map((r) => r.id) : null,
|
|
102
|
+
calendars, rooms,
|
|
103
|
+
tag: f.tag, notag: f.notag,
|
|
104
|
+
internal: allowPrivate,
|
|
105
|
+
unknown: calendars.length < f.cal.length || rooms.length < f.room.length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `tag` (any of them, when given) and no `notag`; "#" tags only count when internal */
|
|
110
|
+
export function tagsMatch(f: Pick<EventFilter, 'tag' | 'notag'>, names: string[], opts: { internal?: boolean } = {}): boolean {
|
|
111
|
+
const shown = opts.internal ? names : publicTags(names);
|
|
112
|
+
return (!f.tag.length || f.tag.some((t) => hasTag(shown, t))) && !f.notag.some((t) => hasTag(shown, t));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const overlaps = (wanted: string[] | null, have: string[]) => wanted === null || have.some((id) => wanted.includes(id));
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Does one event pass? `roomIds` are the rooms the date holds: the event's
|
|
119
|
+
* room hold unless a moved date changed them. Visibility (drafts, private
|
|
120
|
+
* calendars) is the caller's job; this only applies the filter.
|
|
121
|
+
*/
|
|
122
|
+
export function eventMatches(r: ResolvedEventFilter, e: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>, roomIds: string[] = e.roomHold.resourceIds): boolean {
|
|
123
|
+
return overlaps(r.calendarIds, e.calendarIds) && overlaps(r.roomIds, roomIds) && tagsMatch(r, e.tags, { internal: r.internal });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** eventMatches for one occurrence, using the rooms that date holds */
|
|
127
|
+
export function occurrenceMatches(r: ResolvedEventFilter, o: { event: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>; roomIds?: string[] }): boolean {
|
|
128
|
+
return eventMatches(r, o.event, o.roomIds ?? o.event.roomHold.resourceIds);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Entries without an event (feed calendars, Reserved blocks) have no tags:
|
|
133
|
+
* `tag` leaves them out and `notag` keeps them. A Reserved block passes a
|
|
134
|
+
* room filter for its own room; feed entries never do.
|
|
135
|
+
*/
|
|
136
|
+
export function untaggedMatches(r: ResolvedEventFilter, opts: { calendarId?: string; roomId?: string } = {}): boolean {
|
|
137
|
+
if (r.tag.length) return false;
|
|
138
|
+
if (r.roomIds !== null && !(opts.roomId && r.roomIds.includes(opts.roomId))) return false;
|
|
139
|
+
return r.calendarIds === null || Boolean(opts.calendarId && r.calendarIds.includes(opts.calendarId));
|
|
140
|
+
}
|