@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
|
@@ -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
|
+
}
|
|
@@ -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[] {
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Moved dates (CalEvent.movedDates): one date of a repeating event at another
|
|
2
|
+
// time, and maybe in other rooms or at another place, keeping what makes it
|
|
3
|
+
// that date: its key, its RSVPs (they move with it, and people get the "new
|
|
4
|
+
// time" email in their language with their cancel link), its hosts and its
|
|
5
|
+
// page. Moving and moving back apply at once, even on a published event, and
|
|
6
|
+
// leave staged edits alone, like cancelling a date. A date is moved or
|
|
7
|
+
// cancelled, never both: cancelling a moved date drops the move.
|
|
8
|
+
//
|
|
9
|
+
// These check who is acting, so the admin, the built-in member pages and a
|
|
10
|
+
// site's own code (@acms-subir/core/server) share the rules:
|
|
11
|
+
// - staff ("Create and edit content") and GOOGLE_SYNC (a calendar sync acting
|
|
12
|
+
// with staff rights) may move any date. A room taken at the new time is
|
|
13
|
+
// returned as a warning, since the change may already have happened
|
|
14
|
+
// elsewhere;
|
|
15
|
+
// - a host of the event may move its dates while the rooms are free; a taken
|
|
16
|
+
// room refuses the move (409), and staff are emailed about each host move.
|
|
17
|
+
// `quiet` skips the app notice and the staff email (a sync catching up); the
|
|
18
|
+
// RSVP emails always go, because those people's own booking changed.
|
|
19
|
+
import type { ContentRepository } from '../repo/types';
|
|
20
|
+
import { EventLocation, type CalEvent, type MovedDate } from '../schema';
|
|
21
|
+
import { ApiError } from '../api';
|
|
22
|
+
import { eventSideEffects, type SaveHooks } from '../content-service';
|
|
23
|
+
import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
|
|
24
|
+
import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
|
|
25
|
+
import { kickEmail } from '../email/service';
|
|
26
|
+
import { waitUntilFrom } from '../webhooks';
|
|
27
|
+
import { MEMBERS } from './members-config';
|
|
28
|
+
import { siteLocale } from '../dates';
|
|
29
|
+
|
|
30
|
+
/** The actor for a calendar sync (e.g. Google): staff rights, room clashes only warn. */
|
|
31
|
+
export const GOOGLE_SYNC = 'google-sync';
|
|
32
|
+
/** Who is acting: a person (currentUser() from @acms-subir/core/server fits) or the sync. */
|
|
33
|
+
export type MoveActor = { id: string; can(permission: string): boolean } | typeof GOOGLE_SYNC;
|
|
34
|
+
|
|
35
|
+
export interface MoveInput {
|
|
36
|
+
/** the new start, wall-clock in the event's timezone: YYYY-MM-DDTHH:mm, or YYYY-MM-DD for an all-day event */
|
|
37
|
+
start: string;
|
|
38
|
+
/** the new end in the same form (all-day: the last day, inclusive); default: the date keeps its length */
|
|
39
|
+
end?: string;
|
|
40
|
+
/** the rooms that date holds instead (Resource ids); omit to keep its current rooms */
|
|
41
|
+
roomIds?: string[];
|
|
42
|
+
/** that date's place instead; null = the event's place again; omit to keep its current one */
|
|
43
|
+
location?: Partial<EventLocation> | null;
|
|
44
|
+
/** a public note shown with the date ("Moved for the youth retreat"); omit to keep the current one */
|
|
45
|
+
note?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface MoveOptions {
|
|
48
|
+
/** no app notice and no staff email (a sync catching up); RSVP emails still go */
|
|
49
|
+
quiet?: boolean;
|
|
50
|
+
now?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface MoveResult {
|
|
53
|
+
event: CalEvent;
|
|
54
|
+
/** what else takes the date's rooms at its new time (warnings for staff; hosts are refused instead) */
|
|
55
|
+
clashes: BusySlot[];
|
|
56
|
+
/** false: the date already was exactly so, and nothing was written, sent or emailed */
|
|
57
|
+
changed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Why a move was refused (error.code, with error.status): one-off (not a
|
|
62
|
+
* repeating event), cancelled-event, not-a-date (the key isn't one of its
|
|
63
|
+
* dates), cancelled-date, started (the date has started or passed),
|
|
64
|
+
* original-passed (moving back to a time that has passed), past (the new time
|
|
65
|
+
* has passed), bad-time, room-taken (a host, 409).
|
|
66
|
+
*/
|
|
67
|
+
export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
|
|
68
|
+
const refuse = (status: number, code: MoveRefusal, message: string) => Object.assign(new ApiError(status, message), { code });
|
|
69
|
+
|
|
70
|
+
const DAY = 86_400_000;
|
|
71
|
+
const NOTE_MAX = 500;
|
|
72
|
+
|
|
73
|
+
type Role = 'staff' | 'host';
|
|
74
|
+
function roleOf(actor: MoveActor | null, e: CalEvent): Role {
|
|
75
|
+
if (actor === GOOGLE_SYNC) return 'staff';
|
|
76
|
+
if (!actor) throw new ApiError(401, 'Sign in to continue.');
|
|
77
|
+
if (actor.can('content.edit')) return 'staff';
|
|
78
|
+
if (e.hostIds.includes(actor.id)) return 'host';
|
|
79
|
+
throw new ApiError(404, 'Event not found.');
|
|
80
|
+
}
|
|
81
|
+
const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
|
|
82
|
+
|
|
83
|
+
async function load(repo: ContentRepository, id: string) {
|
|
84
|
+
const e = await repo.getEvent(id);
|
|
85
|
+
if (!e || e.status === 'archived') throw new ApiError(404, 'Event not found.');
|
|
86
|
+
return e;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// the date as it is now (moved or not), checked for moving (`started` waits: an unchanged move is a no-op even then)
|
|
90
|
+
function dateToChange(e: CalEvent, key: string, settings: { timezone: string }): Occurrence {
|
|
91
|
+
if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event has single dates to move. Change the event’s time instead.');
|
|
92
|
+
if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
|
|
93
|
+
if (typeof key !== 'string' || key.length > 20) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
|
|
94
|
+
if (e.cancelledDates.includes(key)) throw refuse(409, 'cancelled-date', 'That date is cancelled. Restore it before moving it.');
|
|
95
|
+
const o = findOccurrence(e, key, settings);
|
|
96
|
+
if (!o) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
|
|
97
|
+
return o;
|
|
98
|
+
}
|
|
99
|
+
const started = () => refuse(400, 'started', 'That date has already started or passed.');
|
|
100
|
+
|
|
101
|
+
// The new wall times: `start` in the event's form, `end` defaulting to the
|
|
102
|
+
// date's own length (all-day: the same number of days)
|
|
103
|
+
function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
|
|
104
|
+
const s = parseWall(input.start);
|
|
105
|
+
if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
|
|
106
|
+
if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
|
|
107
|
+
let end = input.end;
|
|
108
|
+
if (!end) {
|
|
109
|
+
const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
|
|
110
|
+
const length = es && ee ? wallMinutesBetween(es, ee) : 60;
|
|
111
|
+
end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return normalizeEventTimes({ start: input.start, end, allDay: e.allDay });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// what else takes the rooms this date would hold at its new time (setup and
|
|
121
|
+
// cleanup, or the whole day, as the event holds them)
|
|
122
|
+
async function clashesFor(repo: ContentRepository, next: CalEvent, key: string, at: { start: number; end: number }, settings: { timezone: string }): Promise<BusySlot[]> {
|
|
123
|
+
const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
|
|
124
|
+
const holds = holdsFromEvents([next], resources, settings, at.start - 2 * DAY, at.end + 2 * DAY, { force: true }).filter((h) => h.key === key);
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
const out: BusySlot[] = [];
|
|
127
|
+
for (const h of holds) {
|
|
128
|
+
for (const b of await roomBusy(repo, settings, { resourceIds: [h.resourceId], from: h.start, to: h.end, ignore: { eventId: next.id, key } })) {
|
|
129
|
+
const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
|
|
130
|
+
if (!seen.has(id)) { seen.add(id); out.push(b); }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out.sort((a, b) => a.start - b.start);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
|
|
137
|
+
const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
|
|
138
|
+
const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
|
|
139
|
+
const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
|
|
140
|
+
throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
|
|
144
|
+
// the same move, whoever made it and when
|
|
145
|
+
const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
|
|
146
|
+
&& JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Move one date of a repeating event to a new time (and maybe other rooms or
|
|
150
|
+
* another place). Moving an already moved date replaces its move; moving it
|
|
151
|
+
* back to exactly where it was is moveOccurrenceBack. Moving it to where it
|
|
152
|
+
* already is changes nothing (`changed: false`): retries never email twice.
|
|
153
|
+
*/
|
|
154
|
+
export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
|
|
155
|
+
const now = opts.now ?? Date.now();
|
|
156
|
+
const e = await load(repo, id);
|
|
157
|
+
const role = roleOf(actor, e);
|
|
158
|
+
const settings = await repo.getSettings();
|
|
159
|
+
const current = dateToChange(e, key, settings);
|
|
160
|
+
const prior = e.movedDates[key];
|
|
161
|
+
const times = newTimes(e, input);
|
|
162
|
+
|
|
163
|
+
let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
|
|
164
|
+
if (roomIds !== undefined) {
|
|
165
|
+
const known = new Set((await repo.listResources()).map((r) => r.id));
|
|
166
|
+
roomIds = roomIds.filter((r) => known.has(r));
|
|
167
|
+
// the event's own rooms aren't an override
|
|
168
|
+
if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
|
|
169
|
+
}
|
|
170
|
+
let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
|
|
171
|
+
if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
|
|
172
|
+
if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
|
|
173
|
+
const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
|
174
|
+
|
|
175
|
+
const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
|
|
176
|
+
if (prior && sameMove(prior, entry)) return { event: e, clashes: [], changed: false };
|
|
177
|
+
const at = movedInstants(e, entry, settings);
|
|
178
|
+
if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
|
|
179
|
+
|
|
180
|
+
// back where the series puts it, with nothing else different: that's moving it back
|
|
181
|
+
const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
|
|
182
|
+
if (at.start === series.start && at.end === series.end && !roomIds && !location) return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
|
|
183
|
+
if (current.start <= now) throw started();
|
|
184
|
+
if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
|
|
185
|
+
|
|
186
|
+
const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
|
|
187
|
+
const clashes = await clashesFor(repo, next, key, at, settings);
|
|
188
|
+
if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
|
|
189
|
+
|
|
190
|
+
await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
|
|
191
|
+
const after = (await repo.getEvent(id))!;
|
|
192
|
+
const moved = findOccurrence(after, key, settings)!;
|
|
193
|
+
await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
|
|
194
|
+
return { event: after, clashes, changed: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
|
|
198
|
+
export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
|
|
199
|
+
const now = opts.now ?? Date.now();
|
|
200
|
+
const e = await load(repo, id);
|
|
201
|
+
const role = roleOf(actor, e);
|
|
202
|
+
if (!Object.hasOwn(e.movedDates, key)) return { event: e, clashes: [], changed: false };
|
|
203
|
+
const settings = await repo.getSettings();
|
|
204
|
+
const { [key]: _gone, ...rest } = e.movedDates;
|
|
205
|
+
const next: CalEvent = { ...e, movedDates: rest };
|
|
206
|
+
const current = findOccurrence(e, key, settings);
|
|
207
|
+
const back = findOccurrence(next, key, settings);
|
|
208
|
+
// a key the series no longer has (it was retimed): nothing to put back, just forget it
|
|
209
|
+
if (current && back) {
|
|
210
|
+
if (current.start <= now) throw started();
|
|
211
|
+
if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
|
|
212
|
+
}
|
|
213
|
+
const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
|
|
214
|
+
if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
|
|
215
|
+
await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
|
|
216
|
+
const after = (await repo.getEvent(id))!;
|
|
217
|
+
if (current && back) await afterChange(repo, after, current, back, { role, actor: actor!, note: '', hooks, opts, settings });
|
|
218
|
+
else await eventSideEffects(repo, hooks, after);
|
|
219
|
+
return { event: after, clashes, changed: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// RSVPs follow the date (emailed when its time changed), then the usual side
|
|
223
|
+
// effects (webhook; the app notice unless the write was quiet), then staff hear about a host's move
|
|
224
|
+
async function afterChange(repo: ContentRepository, after: CalEvent, from: Occurrence, to: Occurrence,
|
|
225
|
+
c: { role: Role; actor: MoveActor; note: string; hooks: SaveHooks; opts: MoveOptions; settings: Awaited<ReturnType<ContentRepository['getSettings']>> }) {
|
|
226
|
+
if ((from.start !== to.start || from.end !== to.end) && await repo.moveOccurrenceRsvps(after.id, from.start, { start: to.start, end: to.end }, c.note)) {
|
|
227
|
+
await kickEmail(repo, c.hooks.locals, waitUntilFrom(c.hooks.locals));
|
|
228
|
+
}
|
|
229
|
+
await eventSideEffects(repo, c.hooks, after);
|
|
230
|
+
if (c.role === 'host' && !c.opts.quiet && c.actor !== GOOGLE_SYNC) await notifyStaffOfMove(repo, after, from, to, c.actor.id, c.note, c.settings, c.hooks);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// "Date moved" to members.notify (else the owners), like members' requests
|
|
234
|
+
async function notifyStaffOfMove(repo: ContentRepository, e: CalEvent, from: Occurrence, to: Occurrence, by: string, note: string,
|
|
235
|
+
settings: Awaited<ReturnType<ContentRepository['getSettings']>>, hooks: SaveHooks) {
|
|
236
|
+
const recipients = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
|
|
237
|
+
if (!recipients.length) return;
|
|
238
|
+
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
239
|
+
const fmt = (o: Occurrence) => {
|
|
240
|
+
try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
|
|
241
|
+
catch { return new Date(o.start).toISOString(); }
|
|
242
|
+
};
|
|
243
|
+
const version = (await repo.email.config().catch(() => null))?.version ?? 0;
|
|
244
|
+
const at = Date.now();
|
|
245
|
+
for (const recipient of recipients) {
|
|
246
|
+
await repo.email.enqueue({ dedupe: `event-move:${e.id}:${from.key}:${at}:${recipient}`, kind: 'event-request', recipient,
|
|
247
|
+
payload: JSON.stringify({ eventId: e.id, kind: 'staff-moved', at, reply: '', by, note, when: `${fmt(from)} → ${fmt(to)}` }), configVersion: version, expiresAt: at + 7 * DAY });
|
|
248
|
+
}
|
|
249
|
+
await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One upcoming date for the editor's Dates panel: its key, wall times (for the move form), words, and state. */
|
|
253
|
+
export interface DateRow {
|
|
254
|
+
key: string;
|
|
255
|
+
/** where it is now, wall-clock in the event's zone (a moved date: its new time) */
|
|
256
|
+
start: string;
|
|
257
|
+
end: string;
|
|
258
|
+
/** "Thu, Jan 10 · 6:00 – 7:00 PM" */
|
|
259
|
+
when: string;
|
|
260
|
+
cancelled: boolean;
|
|
261
|
+
/** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
|
|
262
|
+
moved: { from: string; note: string } | null;
|
|
263
|
+
roomIds: string[];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The next `limit` dates of a series from `now`, moved and cancelled ones included. */
|
|
267
|
+
export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentRepository['getSettings']>>, limit: number, now = Date.now()): DateRow[] {
|
|
268
|
+
if (!e.rrule) return [];
|
|
269
|
+
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
270
|
+
const locale = siteLocale(settings);
|
|
271
|
+
const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
272
|
+
const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
273
|
+
const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
|
|
274
|
+
const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
|
|
275
|
+
const length = es && ee ? wallMinutesBetween(es, ee) : 60;
|
|
276
|
+
return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
|
|
277
|
+
.slice(0, limit)
|
|
278
|
+
.map((o) => {
|
|
279
|
+
const m = o.moved ? e.movedDates[o.key] : undefined;
|
|
280
|
+
const k = parseWall(o.key)!;
|
|
281
|
+
return {
|
|
282
|
+
key: o.key,
|
|
283
|
+
start: m?.start ?? o.key,
|
|
284
|
+
end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
|
|
285
|
+
when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
|
|
286
|
+
cancelled: o.cancelled,
|
|
287
|
+
moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
|
|
288
|
+
roomIds: o.roomIds,
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
}
|
|
@@ -11,7 +11,8 @@ import { siteLocale } from '../dates';
|
|
|
11
11
|
import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
|
|
12
12
|
import { calendarPageUrl } from './src';
|
|
13
13
|
import { collectViewEvents, type ViewEvent } from './data';
|
|
14
|
-
import { eventStrings } from './event-words';
|
|
14
|
+
import { eventStrings, type EventStrings } from './event-words';
|
|
15
|
+
import { fill } from '../live/words';
|
|
15
16
|
|
|
16
17
|
// Card sources are ICS feed URLs or native site calendars:
|
|
17
18
|
// 'acms:all' every public calendar (+ uncategorized events)
|
|
@@ -58,6 +59,7 @@ interface DisplayEvent {
|
|
|
58
59
|
u?: string; // native events: the event page
|
|
59
60
|
k?: string; // calendar color (#rrggbb), overrides the indexed dot color
|
|
60
61
|
x?: 1; // cancelled
|
|
62
|
+
mv?: string; // a moved date: "Moved from Thu, Jan 10 · 6:00 PM."
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function compactTime(s: string): string {
|
|
@@ -65,6 +67,8 @@ function compactTime(s: string): string {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
|
|
70
|
+
const ew = eventStrings(locale);
|
|
71
|
+
const allDay = ew.allDay;
|
|
68
72
|
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
69
73
|
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
70
74
|
return events.map((e) => {
|
|
@@ -73,7 +77,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
|
|
|
73
77
|
let tm = '';
|
|
74
78
|
let rg: string;
|
|
75
79
|
if (e.allDay) {
|
|
76
|
-
rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} ·
|
|
80
|
+
rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · ${allDay}`;
|
|
77
81
|
} else {
|
|
78
82
|
tm = compactTime(timeFmt.format(e.start));
|
|
79
83
|
rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
|
|
@@ -84,6 +88,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
|
|
|
84
88
|
...(e.url ? { u: e.url } : {}),
|
|
85
89
|
...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
|
|
86
90
|
...(e.cancelled ? { x: 1 as const } : {}),
|
|
91
|
+
...(e.moved && !e.cancelled ? { mv: fill(ew.dateMoved, { from: e.allDay ? dayFmt.format(e.moved.from.start) : `${dayFmt.format(e.moved.from.start)} · ${timeFmt.format(e.moved.from.start)}` }) } : {}),
|
|
87
92
|
};
|
|
88
93
|
});
|
|
89
94
|
}
|
|
@@ -104,7 +109,7 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
|
|
|
104
109
|
|
|
105
110
|
const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
|
|
106
111
|
const chipHtml = (e: DisplayEvent, i: number) =>
|
|
107
|
-
`<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
|
|
112
|
+
`<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
|
|
108
113
|
|
|
109
114
|
function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
|
|
110
115
|
const first = dayNum(y, m, 1);
|
|
@@ -132,11 +137,11 @@ function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]
|
|
|
132
137
|
return `<div class="pc-cal-week">${h}</div>`;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
|
|
136
|
-
if (!order.length) return `<p class="pc-cal-empty"
|
|
140
|
+
function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w: EventStrings): string {
|
|
141
|
+
if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
|
|
137
142
|
const items = order.slice(0, limit).map((i) => {
|
|
138
143
|
const e = events[i];
|
|
139
|
-
return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) ||
|
|
144
|
+
return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
|
|
140
145
|
});
|
|
141
146
|
return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
|
|
142
147
|
}
|
|
@@ -153,7 +158,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
|
|
|
153
158
|
function dow(n){return((n+4-(P.ws||0))%7+7)%7}
|
|
154
159
|
var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
|
|
155
160
|
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
156
|
-
function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
|
|
161
|
+
function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+(e.mv?' is-moved':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
|
|
157
162
|
function chips(n){return(byDay[n]||[]).map(chip).join('')}
|
|
158
163
|
var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
|
|
159
164
|
function renderMonth(){
|
|
@@ -189,7 +194,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
|
|
|
189
194
|
var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
|
|
190
195
|
closePop();
|
|
191
196
|
pop=document.createElement('div');pop.className='pc-cal-pop';
|
|
192
|
-
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="
|
|
197
|
+
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">×</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+(e.mv?'<span class="pc-cal-pop-flag is-moved">'+esc(P.moved)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.mv?'<div class="pc-cal-pop-moved">'+esc(e.mv)+'</div>':'')+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' →</a>':'');
|
|
193
198
|
frame.appendChild(pop);
|
|
194
199
|
var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
|
|
195
200
|
pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
|
|
@@ -234,7 +239,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
234
239
|
(opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
|
|
235
240
|
(opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
|
|
236
241
|
const tz = settings.timezone || 'UTC';
|
|
242
|
+
// a page in another language passes its settings with that locale
|
|
237
243
|
const locale = siteLocale(settings);
|
|
244
|
+
const w = eventStrings(locale);
|
|
238
245
|
const today = tzParts(now, tz);
|
|
239
246
|
const [minY, minM] = addMonths(today.y, today.m, -6);
|
|
240
247
|
const [maxY, maxM] = addMonths(today.y, today.m, 6);
|
|
@@ -263,10 +270,10 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
263
270
|
titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
|
|
264
271
|
bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
|
|
265
272
|
} else if (opts.view === 'upcoming') {
|
|
266
|
-
titleText =
|
|
273
|
+
titleText = w.calendar.upcoming;
|
|
267
274
|
const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
|
|
268
275
|
const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
|
|
269
|
-
bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
|
|
276
|
+
bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)), w);
|
|
270
277
|
if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
|
|
271
278
|
} else {
|
|
272
279
|
titleText = `${months[fm - 1]} ${fy}`;
|
|
@@ -276,9 +283,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
276
283
|
const navButtons =
|
|
277
284
|
opts.view === 'upcoming'
|
|
278
285
|
? ''
|
|
279
|
-
: `<button type="button" class="pc-cal-btn" data-nav="today"
|
|
280
|
-
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="
|
|
281
|
-
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="
|
|
286
|
+
: `<button type="button" class="pc-cal-btn" data-nav="today">${esc(w.calendar.today)}</button>` +
|
|
287
|
+
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="${esc(w.calendar.previous)}">‹</button>` +
|
|
288
|
+
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="${esc(w.calendar.next)}">›</button>`;
|
|
282
289
|
|
|
283
290
|
const footer = opts.footer
|
|
284
291
|
? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(opts.footer.href)}" target="_blank" rel="noopener">${esc(opts.footer.label)}</a></div>`
|
|
@@ -291,8 +298,8 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
291
298
|
min: [minY, minM],
|
|
292
299
|
max: [maxY, maxM],
|
|
293
300
|
months, monthsShort, dows, events, ws,
|
|
294
|
-
// the pop-up's
|
|
295
|
-
details:
|
|
301
|
+
// the pop-up's words in the dates' language, like event cards
|
|
302
|
+
details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close, moved: w.moved,
|
|
296
303
|
};
|
|
297
304
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
298
305
|
|
|
@@ -319,11 +326,12 @@ export async function renderCalendarBlock(
|
|
|
319
326
|
const heading =
|
|
320
327
|
(a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
|
|
321
328
|
(a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
|
|
329
|
+
const w = eventStrings(siteLocale(settings)).calendar;
|
|
322
330
|
|
|
323
331
|
const feeds = a.sources.filter((s) => s.startsWith('http'));
|
|
324
332
|
const natives = repo ? a.sources.filter(isNativeSource) : [];
|
|
325
333
|
if (!feeds.length && !natives.length) {
|
|
326
|
-
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty"
|
|
334
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.noSource)}</p></div></section>`;
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
const win = calendarWindow(settings, now);
|
|
@@ -346,7 +354,7 @@ export async function renderCalendarBlock(
|
|
|
346
354
|
const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
|
|
347
355
|
|
|
348
356
|
if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
|
|
349
|
-
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty"
|
|
357
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.unavailable)}</p></div></section>`;
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
|
|
@@ -355,7 +363,7 @@ export async function renderCalendarBlock(
|
|
|
355
363
|
title: a.title,
|
|
356
364
|
subtitle: a.subtitle,
|
|
357
365
|
limit: a.limit,
|
|
358
|
-
footer: pageUrl ? { href: pageUrl, label:
|
|
366
|
+
footer: pageUrl ? { href: pageUrl, label: w.openGoogle } : undefined,
|
|
359
367
|
// dots only mean something with 2+ calendars
|
|
360
368
|
multi: feeds.length + nativeCalendars > 1,
|
|
361
369
|
});
|