@acms-subir/core 0.3.47 → 0.3.49
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 +56 -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/integration.mjs +37 -0
- 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 +5 -5
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +9 -3
- package/src/lib/calendar/event-words.ts +9 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +10 -6
- 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 +5 -2
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/origin-check.ts +54 -0
- 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/middleware.ts +5 -0
- package/src/pages/api/calendar/events/index.ts +6 -0
- 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 +6 -0
- package/src/theme-sdk/calendar.ts +3 -2
- package/src/virtual.d.ts +2 -0
|
@@ -13,27 +13,45 @@
|
|
|
13
13
|
// but never member pushes (its first publishes are recorded as skipped, like
|
|
14
14
|
// restores). Posts keep their v37 triggers.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// Each migration's column lists are frozen: a later events column joins by a
|
|
17
|
+
// new migration that recreates these triggers through eventChangeTriggers()
|
|
18
|
+
// (the test that sorts every column fails until then). v66 added moved_dates.
|
|
18
19
|
const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// v65's lists
|
|
22
|
+
const V65_SNAPSHOT = ['id', 'slug', 'title', 'excerpt', 'cover', 'cover_alt', 'status', 'featured', 'tags',
|
|
22
23
|
'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates', 'location', 'cta', 'calendar_ids',
|
|
23
24
|
'fields', 'rsvp', 'room_hold', 'start_at', 'end_at', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
|
|
24
|
-
|
|
25
|
-
export const EVENT_CHANGE_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
|
|
25
|
+
const V65_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc',
|
|
26
26
|
'status', 'featured', 'tags', 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'cancelled_dates',
|
|
27
27
|
'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
|
|
28
|
+
|
|
29
|
+
/** the columns copied into each snapshot (what the webhook payload and push rules read) */
|
|
30
|
+
export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates'];
|
|
31
|
+
/** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
|
|
32
|
+
export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates'];
|
|
28
33
|
/** columns no change is recorded for */
|
|
29
34
|
export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
|
|
30
35
|
|
|
31
|
-
const
|
|
36
|
+
const snapshotOf = (cols: string[], row: 'OLD' | 'NEW') => `json_object(${cols.map((c) => `'${c}',${row}.${c}`).join(',')})`;
|
|
32
37
|
const live = '(SELECT suppressed FROM webhook_control WHERE id=1)=0';
|
|
33
38
|
const quiet = 'COALESCE((SELECT quiet FROM push_control WHERE id=1),0)';
|
|
34
39
|
const record = (id: string, before: string, after: string) => `INSERT INTO event_changes (event_id,old_row,new_row,quiet,created_at)
|
|
35
40
|
VALUES (${id},${before},${after},${quiet},${now});`;
|
|
36
|
-
|
|
41
|
+
|
|
42
|
+
/** The three event_changes triggers, for one migration's frozen lists (drop the old ones first when recreating). */
|
|
43
|
+
export function eventChangeTriggers(snapshotCols: string[], changedCols: string[]): string[] {
|
|
44
|
+
const changed = changedCols.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
|
|
45
|
+
return [
|
|
46
|
+
`CREATE TRIGGER event_changes_insert AFTER INSERT ON events WHEN NEW.status='published' AND ${live}
|
|
47
|
+
BEGIN ${record('NEW.id', 'NULL', snapshotOf(snapshotCols, 'NEW'))} END`,
|
|
48
|
+
`CREATE TRIGGER event_changes_update AFTER UPDATE ON events
|
|
49
|
+
WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
|
|
50
|
+
BEGIN ${record('NEW.id', snapshotOf(snapshotCols, 'OLD'), snapshotOf(snapshotCols, 'NEW'))} END`,
|
|
51
|
+
`CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
|
|
52
|
+
BEGIN ${record('OLD.id', snapshotOf(snapshotCols, 'OLD'), 'NULL')} END`,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
37
55
|
|
|
38
56
|
// v37's first-publish ledger rows, now also skipped while push_control.quiet is set
|
|
39
57
|
const skipped = `CASE WHEN (SELECT suppressed FROM webhook_control WHERE id=1)=1 OR ${quiet}=1 THEN 'skipped' ELSE 'pending' END`;
|
|
@@ -50,16 +68,23 @@ export const EVENT_CHANGES_MIGRATION = { version: 65, statements: [
|
|
|
50
68
|
`CREATE INDEX event_changes_event ON event_changes (event_id, claimed_until)`,
|
|
51
69
|
`CREATE TABLE push_control (id INTEGER PRIMARY KEY CHECK(id=1), quiet INTEGER NOT NULL DEFAULT 0)`,
|
|
52
70
|
`INSERT INTO push_control (id,quiet) VALUES (1,0)`,
|
|
53
|
-
|
|
54
|
-
BEGIN ${record('NEW.id', 'NULL', snapshot('NEW'))} END`,
|
|
55
|
-
`CREATE TRIGGER event_changes_update AFTER UPDATE ON events
|
|
56
|
-
WHEN (OLD.status='published' OR NEW.status='published') AND (${changed}) AND ${live}
|
|
57
|
-
BEGIN ${record('NEW.id', snapshot('OLD'), snapshot('NEW'))} END`,
|
|
58
|
-
`CREATE TRIGGER event_changes_delete AFTER DELETE ON events WHEN OLD.status='published' AND ${live}
|
|
59
|
-
BEGIN ${record('OLD.id', snapshot('OLD'), 'NULL')} END`,
|
|
71
|
+
...eventChangeTriggers(V65_SNAPSHOT, V65_COLUMNS),
|
|
60
72
|
`DROP TRIGGER push_event_insert`,
|
|
61
73
|
`DROP TRIGGER push_event_publish`,
|
|
62
74
|
`CREATE TRIGGER push_event_insert AFTER INSERT ON events WHEN NEW.status='published' BEGIN ${ledger} END`,
|
|
63
75
|
`CREATE TRIGGER push_event_publish AFTER UPDATE OF status ON events
|
|
64
76
|
WHEN NEW.status='published' AND OLD.status IS NOT 'published' BEGIN ${ledger} END`,
|
|
65
77
|
] };
|
|
78
|
+
|
|
79
|
+
// v66: single dates of a series moved to another time, room or place
|
|
80
|
+
// (CalEvent.movedDates, keyed by the original occurrence key), a column on the
|
|
81
|
+
// row: public, so it bumps the content version and its changes are recorded
|
|
82
|
+
export const MOVED_DATES_MIGRATION = (contentVersionUpdate: string) => ({ version: 66, statements: [
|
|
83
|
+
`ALTER TABLE events ADD COLUMN moved_dates TEXT NOT NULL DEFAULT '{}'`,
|
|
84
|
+
`DROP TRIGGER content_version_events_update`,
|
|
85
|
+
contentVersionUpdate,
|
|
86
|
+
`DROP TRIGGER event_changes_insert`,
|
|
87
|
+
`DROP TRIGGER event_changes_update`,
|
|
88
|
+
`DROP TRIGGER event_changes_delete`,
|
|
89
|
+
...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates'], [...V65_COLUMNS, 'moved_dates']),
|
|
90
|
+
] });
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
10
|
import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
|
|
@@ -23,6 +23,10 @@ export interface ViewEvent extends CalendarEvent {
|
|
|
23
23
|
// native events: the rooms this date holds; public views name only rooms
|
|
24
24
|
// set to show on public events (Resource.showOnEvents)
|
|
25
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;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
// color: '#rrggbb', or '' when the room has none
|
|
@@ -89,7 +93,7 @@ export async function collectViewEvents(
|
|
|
89
93
|
if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
|
|
90
94
|
return shown.get(e.id)!;
|
|
91
95
|
};
|
|
92
|
-
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>();
|
|
93
97
|
const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
|
|
94
98
|
.filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
|
|
95
99
|
.map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
|
|
@@ -104,7 +108,7 @@ export async function collectViewEvents(
|
|
|
104
108
|
events.push({
|
|
105
109
|
start: o.start, end: o.end, allDay: o.allDay, days: o.days,
|
|
106
110
|
title: e.title || words.untitled,
|
|
107
|
-
location:
|
|
111
|
+
location: locationLine(o.moved?.location ?? e.location),
|
|
108
112
|
description: e.excerpt,
|
|
109
113
|
cal: cal ? idx.get(cal.id) ?? 0 : 0,
|
|
110
114
|
ref: e.id,
|
|
@@ -116,6 +120,8 @@ export async function collectViewEvents(
|
|
|
116
120
|
calendarName: cal?.name,
|
|
117
121
|
event: e,
|
|
118
122
|
rooms: rooms(o.roomIds),
|
|
123
|
+
key: o.key,
|
|
124
|
+
...(o.moved ? { moved: o.moved } : {}),
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
|
|
@@ -18,6 +18,10 @@ export interface EventStrings {
|
|
|
18
18
|
/** one date of a repeating event */
|
|
19
19
|
dateCancelled: string;
|
|
20
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;
|
|
21
25
|
/** the side column's landmark label */
|
|
22
26
|
details: string;
|
|
23
27
|
joinOnline: string;
|
|
@@ -130,6 +134,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
130
134
|
en: {
|
|
131
135
|
cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
|
|
132
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.',
|
|
133
138
|
details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
|
|
134
139
|
directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
|
|
135
140
|
addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
|
|
@@ -162,6 +167,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
162
167
|
es: {
|
|
163
168
|
cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
|
|
164
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.',
|
|
165
171
|
details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
|
|
166
172
|
directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
|
|
167
173
|
addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
|
|
@@ -194,6 +200,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
194
200
|
fr: {
|
|
195
201
|
cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
|
|
196
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.',
|
|
197
204
|
details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
|
|
198
205
|
directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
|
|
199
206
|
addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
|
|
@@ -226,6 +233,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
226
233
|
de: {
|
|
227
234
|
cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
|
|
228
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.',
|
|
229
237
|
details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
|
|
230
238
|
directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
|
|
231
239
|
addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
|
|
@@ -258,6 +266,7 @@ const STRINGS: Record<string, EventStrings> = {
|
|
|
258
266
|
pt: {
|
|
259
267
|
cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
|
|
260
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.',
|
|
261
270
|
details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
|
|
262
271
|
directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
|
|
263
272
|
addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
|
|
@@ -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,7 +5,8 @@
|
|
|
5
5
|
// across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
|
|
6
6
|
// events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
|
|
7
7
|
// date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
|
|
8
|
-
// STATUS:CANCELLED, so calendar apps strike that one date
|
|
8
|
+
// STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
|
|
9
|
+
// override with its new DTSTART/DTEND (and its own place), so they show it there.
|
|
9
10
|
import type { CalEvent, Calendar } from '../schema';
|
|
10
11
|
import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
|
|
11
12
|
import { zonedToUtc } from './ics';
|
|
@@ -103,10 +104,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
103
104
|
if (e.cancelled) lines.push('STATUS:CANCELLED');
|
|
104
105
|
const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
|
|
105
106
|
lines.push(...stamps, 'END:VEVENT');
|
|
106
|
-
if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
|
|
107
|
+
if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
|
|
107
108
|
return lines;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
// one override per moved date that is still part of the series (and not
|
|
112
|
+
// cancelled): the same date (RECURRENCE-ID) at its new time
|
|
113
|
+
function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
114
|
+
const out: string[] = [];
|
|
115
|
+
const cancelled = new Set(e.cancelledDates ?? []);
|
|
116
|
+
for (const key of Object.keys(e.movedDates ?? {}).sort()) {
|
|
117
|
+
const m = e.movedDates[key];
|
|
118
|
+
const w = parseWall(key), ms = parseWall(m.start), me = parseWall(m.end) ?? ms;
|
|
119
|
+
if (cancelled.has(key) || !w || !ms || !me || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
|
|
120
|
+
const when = e.allDay
|
|
121
|
+
? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(ms)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...me, dateOnly: true }, 1))}`]
|
|
122
|
+
: [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(ms)}`, `DTEND;TZID=${tz}:${localValue(me)}`];
|
|
123
|
+
const place = m.location ?? e.location;
|
|
124
|
+
const loc = locationLine(place);
|
|
125
|
+
const desc = [m.note, e.excerpt, place.url && place.url !== loc ? place.url : '', url].filter(Boolean).join('\n\n');
|
|
126
|
+
out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
127
|
+
`SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
|
|
128
|
+
`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
// one override per cancelled date that is still part of the series (keys
|
|
111
134
|
// left behind by a moved series are ignored)
|
|
112
135
|
function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
|