@acms-subir/core 0.3.46 → 0.3.48
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 +47 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +14 -11
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +34 -12
- package/src/lib/calendar/event-words.ts +133 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +26 -18
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +32 -14
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +24 -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/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +16 -0
- package/src/theme-sdk/calendar.ts +3 -2
package/src/lib/calendar/data.ts
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// live through the cached ICS loader. Visibility rules live in events.ts.
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
|
|
7
|
-
import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
|
|
7
|
+
import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } 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;
|
|
@@ -19,6 +23,10 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
19
23
|
// native events: the rooms this date holds; public views name only rooms
|
|
20
24
|
// set to show on public events (Resource.showOnEvents)
|
|
21
25
|
rooms?: ViewRoom[];
|
|
26
|
+
// native events: the date's occurrence key (a moved date keeps its original one)
|
|
27
|
+
key?: string;
|
|
28
|
+
// this date was moved here: where it was (UTC ms) and the public note
|
|
29
|
+
moved?: OccurrenceMove;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
// color: '#rrggbb', or '' when the room has none
|
|
@@ -34,6 +42,12 @@ export interface ViewScope {
|
|
|
34
42
|
includeFeeds?: boolean;
|
|
35
43
|
// "Reserved" blocks from resources set to show on the calendar
|
|
36
44
|
includeBookings?: boolean;
|
|
45
|
+
// the address's cal/tag/notag/room (filter.ts); its calendars narrow
|
|
46
|
+
// calendarIds when both are given
|
|
47
|
+
filter?: ResolvedEventFilter;
|
|
48
|
+
// the request's language (requestLocale): translated titles, event links
|
|
49
|
+
// under its /<locale> prefix, and "Reserved" in its words
|
|
50
|
+
locale?: string;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
@@ -46,7 +60,9 @@ export async function collectViewEvents(
|
|
|
46
60
|
): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
|
|
47
61
|
const all = await repo.listCalendars();
|
|
48
62
|
const byId = new Map(all.map((c) => [c.id, c]));
|
|
49
|
-
const
|
|
63
|
+
const f = scope.filter;
|
|
64
|
+
const ids = scope.calendarIds && f?.calendarIds ? scope.calendarIds.filter((id) => f.calendarIds!.includes(id)) : scope.calendarIds ?? f?.calendarIds;
|
|
65
|
+
const requested = ids ? new Set(ids) : null;
|
|
50
66
|
const visible = all.filter((c) => {
|
|
51
67
|
if (scope.audience === 'admin') return true;
|
|
52
68
|
// a directly requested calendar may be unlisted; private never is
|
|
@@ -60,6 +76,7 @@ export async function collectViewEvents(
|
|
|
60
76
|
const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
|
|
61
77
|
const natives = source.filter((e) => {
|
|
62
78
|
if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
|
|
79
|
+
if (f && !tagsMatch(f, e.tags, { internal: f.internal })) return false;
|
|
63
80
|
if (!e.calendarIds.length) {
|
|
64
81
|
if (requested) return false;
|
|
65
82
|
return scope.includeUncategorized ?? true;
|
|
@@ -73,39 +90,44 @@ export async function collectViewEvents(
|
|
|
73
90
|
const shown = new Map<string, CalEvent>();
|
|
74
91
|
const view = (e: CalEvent) => {
|
|
75
92
|
if (scope.audience === 'admin') return e;
|
|
76
|
-
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
|
|
93
|
+
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
|
|
77
94
|
return shown.get(e.id)!;
|
|
78
95
|
};
|
|
79
|
-
const resources = natives.some((e) => e.
|
|
96
|
+
const resources = natives.some((e) => eventRoomIds(e).length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
|
|
80
97
|
const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
|
|
81
98
|
.filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
|
|
82
99
|
.map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
|
|
100
|
+
const words = eventStrings(scope.locale).calendar;
|
|
83
101
|
for (const o of expandEvents(natives, settings, win)) {
|
|
84
|
-
|
|
102
|
+
// rooms are per date (a moved date can change them)
|
|
103
|
+
if (f?.roomIds && !occurrenceMatches(f, o)) continue;
|
|
104
|
+
const e = view(o.event);
|
|
85
105
|
const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
|
|
86
106
|
const p = tzParts(o.start, tz);
|
|
87
107
|
const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
|
|
88
108
|
events.push({
|
|
89
109
|
start: o.start, end: o.end, allDay: o.allDay, days: o.days,
|
|
90
|
-
title: e.title ||
|
|
91
|
-
location:
|
|
110
|
+
title: e.title || words.untitled,
|
|
111
|
+
location: locationLine(o.moved?.location ?? e.location),
|
|
92
112
|
description: e.excerpt,
|
|
93
113
|
cal: cal ? idx.get(cal.id) ?? 0 : 0,
|
|
94
114
|
ref: e.id,
|
|
95
|
-
url: eventUrl(e.slug) + on,
|
|
115
|
+
url: localePath(eventUrl(e.slug), scope.locale ?? '') + on,
|
|
96
116
|
color: cal ? safeColor(cal.color) : undefined,
|
|
97
117
|
cancelled: o.cancelled,
|
|
98
118
|
image: e.cover || undefined,
|
|
99
119
|
imageAlt: e.coverAlt || undefined,
|
|
100
120
|
calendarName: cal?.name,
|
|
101
|
-
event:
|
|
121
|
+
event: e,
|
|
102
122
|
rooms: rooms(o.roomIds),
|
|
123
|
+
key: o.key,
|
|
124
|
+
...(o.moved ? { moved: o.moved } : {}),
|
|
103
125
|
});
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
const errors: string[] = [];
|
|
107
129
|
if (scope.includeFeeds ?? true) {
|
|
108
|
-
const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
|
|
130
|
+
const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl && (!f || untaggedMatches(f, { calendarId: c.id })));
|
|
109
131
|
const results = await Promise.all(
|
|
110
132
|
feeds.map(async (c) => {
|
|
111
133
|
try {
|
|
@@ -125,7 +147,7 @@ export async function collectViewEvents(
|
|
|
125
147
|
// resources marked "show on calendar": confirmed bookings appear as
|
|
126
148
|
// Reserved blocks (no guest names on public views) — only on unscoped views
|
|
127
149
|
if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
|
|
128
|
-
const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
|
|
150
|
+
const resources = (await repo.listResources()).filter((r) => r.showOnCalendar && (!f || untaggedMatches(f, { roomId: r.id })));
|
|
129
151
|
if (resources.length) {
|
|
130
152
|
const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
|
|
131
153
|
const byId = new Map(resources.map((r) => [r.id, r]));
|
|
@@ -135,7 +157,7 @@ export async function collectViewEvents(
|
|
|
135
157
|
const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
|
|
136
158
|
events.push({
|
|
137
159
|
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}` :
|
|
160
|
+
title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `${words.reserved} · ${r.name}`,
|
|
139
161
|
location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
|
|
140
162
|
url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
|
|
141
163
|
});
|
|
@@ -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.
|
|
@@ -16,6 +18,10 @@ export interface EventStrings {
|
|
|
16
18
|
/** one date of a repeating event */
|
|
17
19
|
dateCancelled: string;
|
|
18
20
|
dateCancelledNote: string;
|
|
21
|
+
/** one date moved to another time: the flag, then "Moved from {from}." and the note (staff's own note replaces it) */
|
|
22
|
+
moved: string;
|
|
23
|
+
dateMoved: string;
|
|
24
|
+
dateMovedNote: string;
|
|
19
25
|
/** the side column's landmark label */
|
|
20
26
|
details: string;
|
|
21
27
|
joinOnline: string;
|
|
@@ -35,6 +41,58 @@ export interface EventStrings {
|
|
|
35
41
|
fullCalendar: string;
|
|
36
42
|
allDay: string;
|
|
37
43
|
repeat: RepeatWords;
|
|
44
|
+
calendar: CalendarWords;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The calendar page, its filters, embeds and the calendar card. */
|
|
48
|
+
export interface CalendarWords {
|
|
49
|
+
title: string;
|
|
50
|
+
/** under the title; {site} is the site's name */
|
|
51
|
+
lede: string;
|
|
52
|
+
/** landmark labels */
|
|
53
|
+
calendars: string;
|
|
54
|
+
filters: string;
|
|
55
|
+
views: string;
|
|
56
|
+
nextTwoWeeks: string;
|
|
57
|
+
agenda: string;
|
|
58
|
+
/** the chip for every calendar */
|
|
59
|
+
all: string;
|
|
60
|
+
month: string;
|
|
61
|
+
week: string;
|
|
62
|
+
list: string;
|
|
63
|
+
/** a filter chip: "Without Class", "Room: Chapel" (a tag chip is the tag alone) */
|
|
64
|
+
without: string;
|
|
65
|
+
room: string;
|
|
66
|
+
/** the × on a chip, for screen readers */
|
|
67
|
+
remove: string;
|
|
68
|
+
clear: string;
|
|
69
|
+
subscribe: string;
|
|
70
|
+
subscribeAll: string;
|
|
71
|
+
subscribeNamed: string;
|
|
72
|
+
subscribeFiltered: string;
|
|
73
|
+
copyLink: string;
|
|
74
|
+
comingUp: string;
|
|
75
|
+
/** the footer: {zone} is "Eastern Daylight Time" in the page's language */
|
|
76
|
+
timesIn: string;
|
|
77
|
+
feedErrors: string;
|
|
78
|
+
empty: string;
|
|
79
|
+
emptySoon: string;
|
|
80
|
+
noMatch: string;
|
|
81
|
+
/** a confirmed booking on the public calendar: "Reserved · {room}" */
|
|
82
|
+
reserved: string;
|
|
83
|
+
untitled: string;
|
|
84
|
+
/** the grid's toolbar and pop-up */
|
|
85
|
+
today: string;
|
|
86
|
+
previous: string;
|
|
87
|
+
next: string;
|
|
88
|
+
upcoming: string;
|
|
89
|
+
noUpcoming: string;
|
|
90
|
+
details: string;
|
|
91
|
+
close: string;
|
|
92
|
+
/** the calendar card's own messages */
|
|
93
|
+
noSource: string;
|
|
94
|
+
unavailable: string;
|
|
95
|
+
openGoogle: string;
|
|
38
96
|
}
|
|
39
97
|
|
|
40
98
|
/** Weekdays are 0 = Sunday; `n` is the interval (every n days/weeks/months/years). */
|
|
@@ -76,6 +134,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
76
134
|
en: {
|
|
77
135
|
cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
|
|
78
136
|
dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
|
|
137
|
+
moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
|
|
79
138
|
details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
|
|
80
139
|
directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
|
|
81
140
|
addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
|
|
@@ -90,10 +149,25 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
90
149
|
until: (d) => `, until ${d}`,
|
|
91
150
|
other: 'Repeats',
|
|
92
151
|
},
|
|
152
|
+
calendar: {
|
|
153
|
+
title: 'Calendar', lede: 'What’s happening at {site}. Subscribe to keep it in your own calendar.',
|
|
154
|
+
calendars: 'Calendars', filters: 'Filters', views: 'View', nextTwoWeeks: 'Next two weeks', agenda: 'Agenda',
|
|
155
|
+
all: 'All', month: 'Month', week: 'Week', list: 'List',
|
|
156
|
+
without: 'Without {tag}', room: 'Room: {room}', remove: 'Remove {filter}', clear: 'Clear filters',
|
|
157
|
+
subscribe: 'Subscribe', subscribeAll: 'Add this calendar to your calendar app. New events show up on their own.',
|
|
158
|
+
subscribeNamed: 'Add “{name}” to your calendar app. New events show up on their own.',
|
|
159
|
+
subscribeFiltered: 'Add these events to your calendar app. New ones show up on their own.',
|
|
160
|
+
copyLink: 'Copy link', comingUp: 'Coming up', timesIn: 'Times in {zone}', feedErrors: 'Some linked calendars couldn’t be loaded right now.',
|
|
161
|
+
empty: 'Nothing on the calendar yet.', emptySoon: 'Nothing in the next two months.', noMatch: 'Nothing matches these filters.',
|
|
162
|
+
reserved: 'Reserved', untitled: 'Untitled event',
|
|
163
|
+
today: 'Today', previous: 'Previous', next: 'Next', upcoming: 'Upcoming', noUpcoming: 'No upcoming events.', details: 'Details', close: 'Close',
|
|
164
|
+
noSource: 'No calendar connected.', unavailable: 'Calendar unavailable right now.', openGoogle: 'Open in Google Calendar ↗',
|
|
165
|
+
},
|
|
93
166
|
},
|
|
94
167
|
es: {
|
|
95
168
|
cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
|
|
96
169
|
dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
|
|
170
|
+
moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
|
|
97
171
|
details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
|
|
98
172
|
directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
|
|
99
173
|
addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
|
|
@@ -108,10 +182,25 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
108
182
|
until: (d) => `, hasta el ${d}`,
|
|
109
183
|
other: 'Se repite',
|
|
110
184
|
},
|
|
185
|
+
calendar: {
|
|
186
|
+
title: 'Calendario', lede: 'Lo que pasa en {site}. Suscríbete para tenerlo en tu propio calendario.',
|
|
187
|
+
calendars: 'Calendarios', filters: 'Filtros', views: 'Vista', nextTwoWeeks: 'Próximas dos semanas', agenda: 'Agenda',
|
|
188
|
+
all: 'Todos', month: 'Mes', week: 'Semana', list: 'Lista',
|
|
189
|
+
without: 'Sin {tag}', room: 'Sala: {room}', remove: 'Quitar {filter}', clear: 'Quitar filtros',
|
|
190
|
+
subscribe: 'Suscribirse', subscribeAll: 'Agrega este calendario a tu app de calendario. Los eventos nuevos aparecen solos.',
|
|
191
|
+
subscribeNamed: 'Agrega “{name}” a tu app de calendario. Los eventos nuevos aparecen solos.',
|
|
192
|
+
subscribeFiltered: 'Agrega estos eventos a tu app de calendario. Los nuevos aparecen solos.',
|
|
193
|
+
copyLink: 'Copiar enlace', comingUp: 'Próximamente', timesIn: 'Horario de {zone}', feedErrors: 'Algunos calendarios vinculados no se pudieron cargar en este momento.',
|
|
194
|
+
empty: 'Todavía no hay nada en el calendario.', emptySoon: 'Nada en los próximos dos meses.', noMatch: 'Nada coincide con estos filtros.',
|
|
195
|
+
reserved: 'Reservado', untitled: 'Evento sin título',
|
|
196
|
+
today: 'Hoy', previous: 'Anterior', next: 'Siguiente', upcoming: 'Próximos', noUpcoming: 'No hay eventos próximos.', details: 'Detalles', close: 'Cerrar',
|
|
197
|
+
noSource: 'No hay ningún calendario conectado.', unavailable: 'El calendario no está disponible en este momento.', openGoogle: 'Abrir en Google Calendar ↗',
|
|
198
|
+
},
|
|
111
199
|
},
|
|
112
200
|
fr: {
|
|
113
201
|
cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
|
|
114
202
|
dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
|
|
203
|
+
moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
|
|
115
204
|
details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
|
|
116
205
|
directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
|
|
117
206
|
addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
|
|
@@ -126,10 +215,25 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
126
215
|
until: (d) => `, jusqu’au ${d}`,
|
|
127
216
|
other: 'Récurrent',
|
|
128
217
|
},
|
|
218
|
+
calendar: {
|
|
219
|
+
title: 'Calendrier', lede: 'Ce qui se passe à {site}. Abonnez-vous pour le retrouver dans votre propre calendrier.',
|
|
220
|
+
calendars: 'Calendriers', filters: 'Filtres', views: 'Affichage', nextTwoWeeks: 'Les deux prochaines semaines', agenda: 'Agenda',
|
|
221
|
+
all: 'Tous', month: 'Mois', week: 'Semaine', list: 'Liste',
|
|
222
|
+
without: 'Sans {tag}', room: 'Salle : {room}', remove: 'Retirer {filter}', clear: 'Effacer les filtres',
|
|
223
|
+
subscribe: 'S’abonner', subscribeAll: 'Ajoutez ce calendrier à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
|
|
224
|
+
subscribeNamed: 'Ajoutez « {name} » à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
|
|
225
|
+
subscribeFiltered: 'Ajoutez ces événements à votre application de calendrier. Les nouveaux s’y ajoutent d’eux-mêmes.',
|
|
226
|
+
copyLink: 'Copier le lien', comingUp: 'À venir', timesIn: 'Heures : {zone}', feedErrors: 'Certains calendriers liés n’ont pas pu être chargés pour le moment.',
|
|
227
|
+
empty: 'Rien au calendrier pour l’instant.', emptySoon: 'Rien dans les deux prochains mois.', noMatch: 'Rien ne correspond à ces filtres.',
|
|
228
|
+
reserved: 'Réservé', untitled: 'Événement sans titre',
|
|
229
|
+
today: 'Aujourd’hui', previous: 'Précédent', next: 'Suivant', upcoming: 'À venir', noUpcoming: 'Aucun événement à venir.', details: 'Détails', close: 'Fermer',
|
|
230
|
+
noSource: 'Aucun calendrier connecté.', unavailable: 'Calendrier indisponible pour le moment.', openGoogle: 'Ouvrir dans Google Agenda ↗',
|
|
231
|
+
},
|
|
129
232
|
},
|
|
130
233
|
de: {
|
|
131
234
|
cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
|
|
132
235
|
dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
|
|
236
|
+
moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
|
|
133
237
|
details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
|
|
134
238
|
directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
|
|
135
239
|
addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
|
|
@@ -144,10 +248,25 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
144
248
|
until: (d) => `, bis zum ${d}`,
|
|
145
249
|
other: 'Wiederkehrend',
|
|
146
250
|
},
|
|
251
|
+
calendar: {
|
|
252
|
+
title: 'Kalender', lede: 'Was bei {site} los ist. Abonnieren Sie den Kalender, um alles in Ihrem eigenen Kalender zu haben.',
|
|
253
|
+
calendars: 'Kalender', filters: 'Filter', views: 'Ansicht', nextTwoWeeks: 'Die nächsten zwei Wochen', agenda: 'Terminliste',
|
|
254
|
+
all: 'Alle', month: 'Monat', week: 'Woche', list: 'Liste',
|
|
255
|
+
without: 'Ohne {tag}', room: 'Raum: {room}', remove: '{filter} entfernen', clear: 'Filter löschen',
|
|
256
|
+
subscribe: 'Abonnieren', subscribeAll: 'Fügen Sie diesen Kalender Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
|
|
257
|
+
subscribeNamed: 'Fügen Sie „{name}“ Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
|
|
258
|
+
subscribeFiltered: 'Fügen Sie diese Veranstaltungen Ihrer Kalender-App hinzu. Neue erscheinen automatisch.',
|
|
259
|
+
copyLink: 'Link kopieren', comingUp: 'Demnächst', timesIn: 'Zeiten in {zone}', feedErrors: 'Einige verknüpfte Kalender konnten gerade nicht geladen werden.',
|
|
260
|
+
empty: 'Noch nichts im Kalender.', emptySoon: 'In den nächsten zwei Monaten steht nichts an.', noMatch: 'Nichts passt zu diesen Filtern.',
|
|
261
|
+
reserved: 'Reserviert', untitled: 'Unbenannte Veranstaltung',
|
|
262
|
+
today: 'Heute', previous: 'Zurück', next: 'Weiter', upcoming: 'Demnächst', noUpcoming: 'Keine anstehenden Veranstaltungen.', details: 'Details', close: 'Schließen',
|
|
263
|
+
noSource: 'Kein Kalender verbunden.', unavailable: 'Kalender gerade nicht verfügbar.', openGoogle: 'In Google Kalender öffnen ↗',
|
|
264
|
+
},
|
|
147
265
|
},
|
|
148
266
|
pt: {
|
|
149
267
|
cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
|
|
150
268
|
dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
|
|
269
|
+
moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
|
|
151
270
|
details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
|
|
152
271
|
directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
|
|
153
272
|
addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
|
|
@@ -164,6 +283,20 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
164
283
|
until: (d) => `, até ${d}`,
|
|
165
284
|
other: 'Repete',
|
|
166
285
|
},
|
|
286
|
+
calendar: {
|
|
287
|
+
title: 'Calendário', lede: 'O que acontece em {site}. Assine para ter tudo no seu próprio calendário.',
|
|
288
|
+
calendars: 'Calendários', filters: 'Filtros', views: 'Visualização', nextTwoWeeks: 'Próximas duas semanas', agenda: 'Agenda',
|
|
289
|
+
all: 'Todos', month: 'Mês', week: 'Semana', list: 'Lista',
|
|
290
|
+
without: 'Sem {tag}', room: 'Sala: {room}', remove: 'Remover {filter}', clear: 'Limpar filtros',
|
|
291
|
+
subscribe: 'Assinar', subscribeAll: 'Adicione este calendário ao seu app de calendário. Os novos eventos aparecem sozinhos.',
|
|
292
|
+
subscribeNamed: 'Adicione “{name}” ao seu app de calendário. Os novos eventos aparecem sozinhos.',
|
|
293
|
+
subscribeFiltered: 'Adicione estes eventos ao seu app de calendário. Os novos aparecem sozinhos.',
|
|
294
|
+
copyLink: 'Copiar link', comingUp: 'Em breve', timesIn: 'Horário de {zone}', feedErrors: 'Alguns calendários vinculados não puderam ser carregados agora.',
|
|
295
|
+
empty: 'Ainda não há nada no calendário.', emptySoon: 'Nada nos próximos dois meses.', noMatch: 'Nada corresponde a estes filtros.',
|
|
296
|
+
reserved: 'Reservado', untitled: 'Evento sem título',
|
|
297
|
+
today: 'Hoje', previous: 'Anterior', next: 'Próximo', upcoming: 'Próximos', noUpcoming: 'Nenhum evento programado.', details: 'Detalhes', close: 'Fechar',
|
|
298
|
+
noSource: 'Nenhum calendário conectado.', unavailable: 'Calendário indisponível no momento.', openGoogle: 'Abrir no Google Agenda ↗',
|
|
299
|
+
},
|
|
167
300
|
},
|
|
168
301
|
};
|
|
169
302
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Native events: wall-clock time math, recurrence expansion (reusing the ICS
|
|
2
2
|
// expander, so feed events and native events follow identical rules),
|
|
3
3
|
// visibility, and "add to calendar" links. Pure — no repo, no fetch.
|
|
4
|
-
import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
|
|
4
|
+
import type { CalEvent, Calendar, EventLocation, MovedDate, Settings } from '../schema';
|
|
5
5
|
import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
|
|
6
6
|
import { cleanRrule } from './rrule';
|
|
7
7
|
import { eventStrings } from './event-words';
|
|
@@ -35,6 +35,9 @@ function fromMinutes(min: number, dateOnly: boolean): Wall {
|
|
|
35
35
|
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: dateOnly ? 0 : dt.getUTCHours(), mm: dateOnly ? 0 : dt.getUTCMinutes(), dateOnly };
|
|
36
36
|
}
|
|
37
37
|
export const addWallDays = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n * 1440, w.dateOnly);
|
|
38
|
+
export const addWallMinutes = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n, w.dateOnly);
|
|
39
|
+
/** whole minutes from a to b (wall-clock, no zone) */
|
|
40
|
+
export const wallMinutesBetween = (a: Wall, b: Wall) => wallMinutes(b) - wallMinutes(a);
|
|
38
41
|
|
|
39
42
|
export function isValidTimeZone(tz: string): boolean {
|
|
40
43
|
if (!tz) return false;
|
|
@@ -122,8 +125,27 @@ export interface Occurrence {
|
|
|
122
125
|
key: string;
|
|
123
126
|
// the whole event is cancelled, or just this date
|
|
124
127
|
cancelled: boolean;
|
|
125
|
-
// the rooms this date holds (Resource ids
|
|
128
|
+
// the rooms this date holds (Resource ids: the event's roomHold.resourceIds,
|
|
129
|
+
// or a moved date's own)
|
|
126
130
|
roomIds: string[];
|
|
131
|
+
// this date was moved here (CalEvent.movedDates): where it was, and why
|
|
132
|
+
moved?: OccurrenceMove;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface OccurrenceMove {
|
|
136
|
+
/** the date's original start and end (UTC ms) */
|
|
137
|
+
from: { start: number; end: number };
|
|
138
|
+
/** staff's public note ('' for none) */
|
|
139
|
+
note: string;
|
|
140
|
+
/** the date's own place, when it moved somewhere else */
|
|
141
|
+
location?: EventLocation;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every room an event may hold: its own and any a moved date holds instead. */
|
|
145
|
+
export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'>): string[] {
|
|
146
|
+
const ids = new Set(e.roomHold?.resourceIds ?? []);
|
|
147
|
+
for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
|
|
148
|
+
return [...ids];
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
/**
|
|
@@ -148,14 +170,73 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
148
170
|
if (!v) continue;
|
|
149
171
|
// ?? []: editor previews pass unsaved events that may lack the field
|
|
150
172
|
const cancelledDates = new Set(e.cancelledDates ?? []);
|
|
173
|
+
const moved = movedEntries(e);
|
|
151
174
|
for (const o of expandVEvents([v], tz, win, 0)) {
|
|
152
175
|
const key = occurrenceKey(e, o.start, settings, win.displayTz);
|
|
176
|
+
// a moved date comes out at its new time below
|
|
177
|
+
if (moved.has(key)) continue;
|
|
153
178
|
out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
|
|
154
179
|
}
|
|
180
|
+
for (const [key, m] of moved) {
|
|
181
|
+
const at = movedInstants(e, m, settings, win.displayTz);
|
|
182
|
+
if (!at || at.end <= win.windowStartMs || at.start >= win.windowEndMs) continue;
|
|
183
|
+
const o = movedOccurrence(e, key, m, settings, win.displayTz);
|
|
184
|
+
if (o) out.push(o);
|
|
185
|
+
}
|
|
155
186
|
}
|
|
156
187
|
return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
|
|
157
188
|
}
|
|
158
189
|
|
|
190
|
+
// ---- moved dates ---------------------------------------------------------
|
|
191
|
+
// A moved date (CalEvent.movedDates) keeps its original key; it comes out at
|
|
192
|
+
// its new time, in its own rooms (and place) when it has them, with `moved`
|
|
193
|
+
// saying where it was. Keys that no longer match a date of the series (it
|
|
194
|
+
// was retimed or its rule changed) are ignored, like stale cancelled keys,
|
|
195
|
+
// and a key that is also cancelled stays cancelled at its original time.
|
|
196
|
+
|
|
197
|
+
// the moves that count: not on a cancelled date
|
|
198
|
+
function movedEntries(e: Pick<CalEvent, 'movedDates' | 'cancelledDates'>): Map<string, MovedDate> {
|
|
199
|
+
const out = new Map<string, MovedDate>();
|
|
200
|
+
const cancelled = new Set(e.cancelledDates ?? []);
|
|
201
|
+
for (const [key, m] of Object.entries(e.movedDates ?? {})) if (!cancelled.has(key)) out.set(key, m);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A moved date's new instants (all-day: midnight in the display zone, as the expander places them), or null when unusable. */
|
|
206
|
+
export function movedInstants(e: Pick<CalEvent, 'allDay' | 'timezone'>, m: Pick<MovedDate, 'start' | 'end'>, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): { start: number; end: number; days: number } | null {
|
|
207
|
+
const s = parseWall(m.start);
|
|
208
|
+
if (!s || s.dateOnly !== e.allDay) return null;
|
|
209
|
+
const en = parseWall(m.end) ?? s;
|
|
210
|
+
if (e.allDay) {
|
|
211
|
+
const days = Math.max(1, Math.round((wallMinutes({ ...en, hh: 0, mm: 0 }) - wallMinutes(s)) / 1440) + 1);
|
|
212
|
+
const start = zonedToUtc(s.y, s.m, s.d, 0, 0, 0, displayTz || 'UTC');
|
|
213
|
+
return { start, end: start + days * DAY, days };
|
|
214
|
+
}
|
|
215
|
+
const tz = eventTimeZone(e, settings);
|
|
216
|
+
const start = wallToMs(s, tz), end = wallToMs(en, tz);
|
|
217
|
+
return end > start ? { start, end, days: 1 } : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// the date as the series alone makes it (no move), or null when it isn't one
|
|
221
|
+
function seriesOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
|
|
222
|
+
const w = parseWall(key);
|
|
223
|
+
if (!w || w.dateOnly !== e.allDay) return null;
|
|
224
|
+
const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
|
|
225
|
+
return expandEvents([{ ...e, movedDates: {} }], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
|
|
226
|
+
.find((o) => o.key === key && o.start === at) ?? null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
|
|
230
|
+
const at = movedInstants(e, m, settings, displayTz);
|
|
231
|
+
const was = at && seriesOccurrence(e, key, settings, displayTz);
|
|
232
|
+
if (!at || !was) return null;
|
|
233
|
+
return {
|
|
234
|
+
event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: e.cancelled,
|
|
235
|
+
roomIds: m.roomIds ?? e.roomHold?.resourceIds ?? [],
|
|
236
|
+
moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
159
240
|
// When RSVPs to one date close (UTC ms), or null when they stay open until it
|
|
160
241
|
// ends: the event's closesAt (wall time in its zone, one instant for every
|
|
161
242
|
// date), else closeBefore minutes before this date's start. A deadline at or
|
|
@@ -168,18 +249,17 @@ export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, set
|
|
|
168
249
|
}
|
|
169
250
|
|
|
170
251
|
// The occurrence of a series with this key (null when the series has no
|
|
171
|
-
// such date: never generated, skipped by exdates, or the series moved)
|
|
252
|
+
// such date: never generated, skipped by exdates, or the series moved). A
|
|
253
|
+
// moved date comes back at its new time.
|
|
172
254
|
export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
|
|
176
|
-
return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
|
|
177
|
-
.find((o) => o.key === key && o.start === at) ?? null;
|
|
255
|
+
const m = movedEntries(e).get(key);
|
|
256
|
+
return m ? movedOccurrence(e, key, m, settings, displayTz) : seriesOccurrence(e, key, settings, displayTz);
|
|
178
257
|
}
|
|
179
258
|
|
|
180
259
|
// The occurrence to feature for an event: the next one that hasn't ended,
|
|
181
260
|
// else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
|
|
182
|
-
// (links from the calendar grid carry it)
|
|
261
|
+
// (links from the calendar grid carry it); a moved date answers both its new
|
|
262
|
+
// day and the day it was moved from.
|
|
183
263
|
export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, on?: string): Occurrence | null {
|
|
184
264
|
const tz = eventTimeZone(e, settings);
|
|
185
265
|
const displayTz = settings.timezone || 'UTC';
|
|
@@ -190,6 +270,11 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
|
|
|
190
270
|
const hit = expandEvents([e], settings, { displayTz, now, windowStartMs: from, windowEndMs: from + DAY })
|
|
191
271
|
.find((o) => o.start >= from - DAY);
|
|
192
272
|
if (hit) return hit;
|
|
273
|
+
for (const [key, m] of movedEntries(e)) {
|
|
274
|
+
if (key.slice(0, 10) !== on) continue;
|
|
275
|
+
const o = movedOccurrence(e, key, m, settings, displayTz);
|
|
276
|
+
if (o) return o;
|
|
277
|
+
}
|
|
193
278
|
}
|
|
194
279
|
}
|
|
195
280
|
if (!e.rrule) {
|
|
@@ -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, {
|