@acms-subir/core 0.3.3 → 0.3.4
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 +36 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Data for the admin Schedule: every occurrence in a date range — events
|
|
2
|
+
// (drafts included), subscribed calendars, bookings and blocked time —
|
|
3
|
+
// flattened into site-local days so the browser only does layout
|
|
4
|
+
// (lib/calendar/grid.ts). Times are shown in the site timezone.
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
import type { CalEvent, Settings } from '../schema';
|
|
7
|
+
import { collectViewEvents } from './data';
|
|
8
|
+
import { eventTimeZone, safeColor } from './events';
|
|
9
|
+
import { tzParts, zonedToUtc } from './ics';
|
|
10
|
+
import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
|
|
11
|
+
import { bookingsEnabled } from './config';
|
|
12
|
+
import { siteLocale } from '../dates';
|
|
13
|
+
import { describeRepeat } from './rrule';
|
|
14
|
+
import { holdsRooms } from '../booking/holds';
|
|
15
|
+
|
|
16
|
+
export type BoardKind = 'event' | 'feed' | 'booking' | 'block';
|
|
17
|
+
export type BoardStatus = 'published' | 'draft' | 'scheduled' | 'archived' | 'feed' | 'pending' | 'confirmed';
|
|
18
|
+
|
|
19
|
+
export interface BoardItem extends GridItem {
|
|
20
|
+
kind: BoardKind;
|
|
21
|
+
id: string; // event id / booking id / '' for feeds
|
|
22
|
+
time: string; // short start label, e.g. "7p", "10:30a"
|
|
23
|
+
range: string; // "7:00 – 8:30 PM" or "All day"
|
|
24
|
+
color: string;
|
|
25
|
+
calendarId: string;
|
|
26
|
+
calendarName: string;
|
|
27
|
+
status: BoardStatus;
|
|
28
|
+
recurring: boolean;
|
|
29
|
+
repeat: string; // "Weekly on Wednesday" ('' when it doesn't repeat)
|
|
30
|
+
rooms: string[]; // bookable rooms this event holds (names)
|
|
31
|
+
roomsHeld: boolean; // false for drafts that don't hold yet
|
|
32
|
+
// this occurrence's start as wall time in the event's own zone (for exdates)
|
|
33
|
+
occurrence: string;
|
|
34
|
+
cancelled: boolean;
|
|
35
|
+
location: string;
|
|
36
|
+
edited: boolean; // live event with unpublished changes
|
|
37
|
+
url: string; // public URL ('' when none)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BoardCalendar { id: string; name: string; color: string; kind: 'native' | 'feed'; visibility: string }
|
|
41
|
+
|
|
42
|
+
export interface BoardData {
|
|
43
|
+
from: Iso;
|
|
44
|
+
to: Iso;
|
|
45
|
+
today: Iso;
|
|
46
|
+
weekStart: 0 | 1;
|
|
47
|
+
items: BoardItem[];
|
|
48
|
+
calendars: BoardCalendar[];
|
|
49
|
+
errors: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
53
|
+
const isoOf = (p: { y: number; m: number; d: number }) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
54
|
+
export const NO_CALENDAR = '';
|
|
55
|
+
export const BOOKING_COLOR = '#8e919a';
|
|
56
|
+
|
|
57
|
+
function shortTime(hh: number, mm: number): string {
|
|
58
|
+
const h = hh % 12 || 12;
|
|
59
|
+
return `${h}${mm ? `:${pad(mm)}` : ''}${hh < 12 ? 'a' : 'p'}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function todayIn(tz: string, now = Date.now()): Iso {
|
|
63
|
+
return isoOf(tzParts(now, tz || 'UTC'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// local day + minutes for an instant; `endExclusive` maps midnight to the
|
|
67
|
+
// previous day's 24:00 so an event ending at midnight stays on its day
|
|
68
|
+
function local(ms: number, tz: string, endExclusive = false) {
|
|
69
|
+
const p = tzParts(endExclusive ? ms - 1 : ms, tz);
|
|
70
|
+
const day = isoOf(p);
|
|
71
|
+
const min = endExclusive ? p.hh * 60 + p.mm + 1 : p.hh * 60 + p.mm;
|
|
72
|
+
return { day, min: Math.min(min, 1440), hh: p.hh, mm: p.mm };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function loadBoard(repo: ContentRepository, settings: Settings, from: Iso, to: Iso, opts: { now?: number } = {}): Promise<BoardData> {
|
|
76
|
+
if (!isIsoDay(from) || !isIsoDay(to) || to < from) throw new Error('Pick a valid date range.');
|
|
77
|
+
// at most ~14 weeks per request — a month grid needs 6, prefetching adds the neighbours
|
|
78
|
+
if (addDays(from, 100) < to) throw new Error('That date range is too long.');
|
|
79
|
+
const tz = settings.timezone || 'UTC';
|
|
80
|
+
const now = opts.now ?? Date.now();
|
|
81
|
+
const startMs = zonedToUtc(+from.slice(0, 4), +from.slice(5, 7), +from.slice(8, 10), 0, 0, 0, tz);
|
|
82
|
+
const endIso = addDays(to, 1);
|
|
83
|
+
const endMs = zonedToUtc(+endIso.slice(0, 4), +endIso.slice(5, 7), +endIso.slice(8, 10), 0, 0, 0, tz);
|
|
84
|
+
const win = { displayTz: tz, now, windowStartMs: startMs, windowEndMs: endMs };
|
|
85
|
+
|
|
86
|
+
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'admin', includeFeeds: true, includeBookings: false });
|
|
87
|
+
const locale = siteLocale(settings);
|
|
88
|
+
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
89
|
+
const rangeOf = (s: number, e: number, allDay: boolean) => {
|
|
90
|
+
if (allDay) return 'All day';
|
|
91
|
+
try { return timeFmt.formatRange(s, e); } catch { return `${timeFmt.format(s)} – ${timeFmt.format(e)}`; }
|
|
92
|
+
};
|
|
93
|
+
const calById = new Map(calendars.map((c) => [c.id, c]));
|
|
94
|
+
const roomName = new Map((bookingsEnabled() ? await repo.listResources() : []).map((r) => [r.id, r.name]));
|
|
95
|
+
const items: BoardItem[] = [];
|
|
96
|
+
|
|
97
|
+
for (const v of events) {
|
|
98
|
+
const e: CalEvent | undefined = v.event;
|
|
99
|
+
const allDay = v.allDay;
|
|
100
|
+
const s = local(v.start, tz);
|
|
101
|
+
const en = local(v.end, tz, true);
|
|
102
|
+
const startDay = s.day;
|
|
103
|
+
const endDay = allDay ? addDays(startDay, Math.max(1, v.days) - 1) : en.day < s.day ? s.day : en.day;
|
|
104
|
+
const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((c) => c.name === v.calendarName && c.kind === 'feed');
|
|
105
|
+
let occurrence = '';
|
|
106
|
+
if (e) {
|
|
107
|
+
const p = tzParts(v.start, eventTimeZone(e, settings));
|
|
108
|
+
occurrence = allDay ? isoOf(p) : `${isoOf(p)}T${pad(p.hh)}:${pad(p.mm)}`;
|
|
109
|
+
}
|
|
110
|
+
items.push({
|
|
111
|
+
key: e ? `e:${e.id}:${v.start}` : `f:${cal?.id ?? ''}:${v.start}:${v.title}`,
|
|
112
|
+
kind: e ? 'event' : 'feed',
|
|
113
|
+
id: e?.id ?? '',
|
|
114
|
+
title: v.title,
|
|
115
|
+
startDay, endDay, allDay,
|
|
116
|
+
startMin: allDay ? 0 : s.min,
|
|
117
|
+
endMin: allDay ? 1440 : en.min,
|
|
118
|
+
time: allDay ? '' : shortTime(s.hh, s.mm),
|
|
119
|
+
range: rangeOf(v.start, v.end, allDay),
|
|
120
|
+
color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
|
|
121
|
+
calendarId: cal?.id ?? NO_CALENDAR,
|
|
122
|
+
calendarName: cal?.name ?? '',
|
|
123
|
+
status: e ? (e.status as BoardStatus) : 'feed',
|
|
124
|
+
recurring: Boolean(e?.rrule),
|
|
125
|
+
repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
|
|
126
|
+
rooms: e ? e.roomHold.resourceIds.map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
|
|
127
|
+
roomsHeld: e ? holdsRooms(e) : false,
|
|
128
|
+
occurrence,
|
|
129
|
+
cancelled: Boolean(v.cancelled),
|
|
130
|
+
location: v.location,
|
|
131
|
+
edited: Boolean(e?.draft),
|
|
132
|
+
url: e?.status === 'published' ? v.url ?? '' : '',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (bookingsEnabled() && settings.calendar.showBookings) {
|
|
137
|
+
const [bookings, resources] = await Promise.all([
|
|
138
|
+
repo.listBookings({ from: startMs, to: endMs, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] }),
|
|
139
|
+
repo.listResources(),
|
|
140
|
+
]);
|
|
141
|
+
const rById = new Map(resources.map((r) => [r.id, r]));
|
|
142
|
+
for (const b of bookings) {
|
|
143
|
+
const r = rById.get(b.resourceId);
|
|
144
|
+
const s = local(b.start, tz);
|
|
145
|
+
const en = local(b.end, tz, true);
|
|
146
|
+
const allDay = s.min === 0 && en.min === 1440;
|
|
147
|
+
items.push({
|
|
148
|
+
key: `b:${b.id}`,
|
|
149
|
+
kind: b.kind === 'block' ? 'block' : 'booking',
|
|
150
|
+
id: b.id,
|
|
151
|
+
title: b.kind === 'block' ? `${r?.name ?? 'Blocked'} · ${b.notes || 'Blocked'}` : `${r?.name ?? 'Booking'} · ${b.name}`,
|
|
152
|
+
startDay: s.day, endDay: en.day < s.day ? s.day : en.day, allDay,
|
|
153
|
+
startMin: allDay ? 0 : s.min, endMin: allDay ? 1440 : en.min,
|
|
154
|
+
time: allDay ? '' : shortTime(s.hh, s.mm),
|
|
155
|
+
range: rangeOf(b.start, b.end, allDay),
|
|
156
|
+
color: BOOKING_COLOR,
|
|
157
|
+
calendarId: `res:${b.resourceId}`,
|
|
158
|
+
calendarName: r?.name ?? '',
|
|
159
|
+
status: b.status as BoardStatus,
|
|
160
|
+
recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false,
|
|
161
|
+
location: r?.name ?? '', edited: false, url: '',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
items.sort((a, b) => a.startDay.localeCompare(b.startDay) || a.startMin - b.startMin);
|
|
167
|
+
return {
|
|
168
|
+
from, to, items, errors, today: todayIn(tz, now), weekStart: settings.calendar.weekStart,
|
|
169
|
+
calendars: calendars.map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color), kind: c.kind, visibility: c.visibility })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -21,6 +21,8 @@ export interface CalendarConfig {
|
|
|
21
21
|
eventsBase: string;
|
|
22
22
|
// bookings: rooms, people and equipment at bookBase (off = no /book routes)
|
|
23
23
|
bookings: boolean;
|
|
24
|
+
// starting approval default for booking links (acms({ calendar: { bookings: { approval } } }))
|
|
25
|
+
bookingApproval: 'auto' | 'rules' | 'manual';
|
|
24
26
|
bookBase: string;
|
|
25
27
|
resourceKinds: string[];
|
|
26
28
|
defaultView: 'month' | 'week' | 'list';
|
|
@@ -32,6 +34,7 @@ const FALLBACK: CalendarConfig = {
|
|
|
32
34
|
base: '/calendar',
|
|
33
35
|
eventsBase: '/events',
|
|
34
36
|
bookings: true,
|
|
37
|
+
bookingApproval: 'auto',
|
|
35
38
|
bookBase: '/book',
|
|
36
39
|
resourceKinds: ['Room', 'Person', 'Vehicle', 'Equipment'],
|
|
37
40
|
defaultView: 'month',
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Pure date + layout math for the admin Schedule (month and week grids).
|
|
2
|
+
// Runs on the server and in the browser, so no repo, no Intl time zones:
|
|
3
|
+
// every date here is a site-local calendar day as "YYYY-MM-DD", and every
|
|
4
|
+
// item already carries its local start/end day (lib/calendar/board.ts).
|
|
5
|
+
|
|
6
|
+
export type Iso = string;
|
|
7
|
+
|
|
8
|
+
export interface GridItem {
|
|
9
|
+
key: string;
|
|
10
|
+
startDay: Iso;
|
|
11
|
+
endDay: Iso; // inclusive
|
|
12
|
+
allDay: boolean;
|
|
13
|
+
startMin: number; // minutes after midnight on startDay (timed items)
|
|
14
|
+
endMin: number; // minutes after midnight on endDay
|
|
15
|
+
title: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
19
|
+
const toUtc = (d: Iso) => Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10));
|
|
20
|
+
const fromUtc = (ms: number): Iso => {
|
|
21
|
+
const t = new Date(ms);
|
|
22
|
+
return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const isIsoDay = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && fromUtc(toUtc(s)) === s;
|
|
26
|
+
export const addDays = (d: Iso, n: number): Iso => fromUtc(toUtc(d) + n * 86_400_000);
|
|
27
|
+
export const diffDays = (a: Iso, b: Iso) => Math.round((toUtc(b) - toUtc(a)) / 86_400_000);
|
|
28
|
+
// 0 = Sunday … 6 = Saturday
|
|
29
|
+
export const dayOfWeek = (d: Iso) => new Date(toUtc(d)).getUTCDay();
|
|
30
|
+
export const monthOf = (d: Iso) => d.slice(0, 7);
|
|
31
|
+
export const isIsoMonth = (s: string) => /^\d{4}-(0[1-9]|1[0-2])$/.test(s);
|
|
32
|
+
export function addMonths(month: string, n: number): string {
|
|
33
|
+
const y = +month.slice(0, 4), m = +month.slice(5, 7) - 1 + n;
|
|
34
|
+
const t = new Date(Date.UTC(y, m, 1));
|
|
35
|
+
return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function startOfWeek(d: Iso, weekStart: 0 | 1): Iso {
|
|
39
|
+
return addDays(d, -((dayOfWeek(d) - weekStart + 7) % 7));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Weeks covering a month: always whole weeks, 4–6 rows depending on the month
|
|
43
|
+
export function monthWeeks(month: string, weekStart: 0 | 1): Iso[][] {
|
|
44
|
+
const first = `${month}-01`;
|
|
45
|
+
const last = addDays(`${addMonths(month, 1)}-01`, -1);
|
|
46
|
+
const weeks: Iso[][] = [];
|
|
47
|
+
for (let w = startOfWeek(first, weekStart); w <= last; w = addDays(w, 7)) {
|
|
48
|
+
weeks.push(Array.from({ length: 7 }, (_, i) => addDays(w, i)));
|
|
49
|
+
}
|
|
50
|
+
return weeks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function weekDays(day: Iso, weekStart: 0 | 1): Iso[] {
|
|
54
|
+
const s = startOfWeek(day, weekStart);
|
|
55
|
+
return Array.from({ length: 7 }, (_, i) => addDays(s, i));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Items that need a bar across days: all-day items and anything that runs
|
|
59
|
+
// past midnight. Single-day timed items render as chips.
|
|
60
|
+
export const isSpanning = (it: GridItem) => it.allDay || it.endDay > it.startDay;
|
|
61
|
+
|
|
62
|
+
export interface Bar<T extends GridItem> {
|
|
63
|
+
item: T;
|
|
64
|
+
col: number; // 0-based column inside the week
|
|
65
|
+
span: number; // columns covered
|
|
66
|
+
lane: number;
|
|
67
|
+
continuesLeft: boolean;
|
|
68
|
+
continuesRight: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WeekLayout<T extends GridItem> {
|
|
72
|
+
bars: Bar<T>[]; // visible bars; `lane` is the line they sit on
|
|
73
|
+
chips: { item: T; line: number }[][]; // per column, visible chips and their line
|
|
74
|
+
hidden: number[]; // per column, items not shown ("+N more")
|
|
75
|
+
all: T[][]; // per column, everything on that day (for the day panel)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const byStart = <T extends GridItem>(a: T, b: T) =>
|
|
79
|
+
a.startDay.localeCompare(b.startDay) || Number(b.allDay) - Number(a.allDay) || a.startMin - b.startMin || a.title.localeCompare(b.title);
|
|
80
|
+
|
|
81
|
+
// Lay out one week row as `lines` stacked slots per day. Bars (all-day and
|
|
82
|
+
// multi-day items) take the same line across every day they cover; timed
|
|
83
|
+
// chips fill each day's free lines. A day with more than fits keeps its last
|
|
84
|
+
// line for "+N more".
|
|
85
|
+
export function layoutWeek<T extends GridItem>(days: Iso[], items: T[], lines: number): WeekLayout<T> {
|
|
86
|
+
const first = days[0], last = days[days.length - 1];
|
|
87
|
+
const inWeek = items.filter((it) => it.startDay <= last && it.endDay >= first).sort(byStart);
|
|
88
|
+
const all: T[][] = days.map((d) => inWeek.filter((it) => it.startDay <= d && it.endDay >= d));
|
|
89
|
+
|
|
90
|
+
// greedy lane packing, longest bar first among those starting the same day
|
|
91
|
+
const clip = (d: Iso) => (d < first ? first : d);
|
|
92
|
+
const spanning = inWeek.filter(isSpanning).sort((a, b) =>
|
|
93
|
+
clip(a.startDay).localeCompare(clip(b.startDay)) || diffDays(b.startDay, b.endDay) - diffDays(a.startDay, a.endDay) || byStart(a, b));
|
|
94
|
+
const laneEnds: number[] = [];
|
|
95
|
+
const placed: Bar<T>[] = [];
|
|
96
|
+
for (const item of spanning) {
|
|
97
|
+
const col = Math.max(0, diffDays(first, item.startDay));
|
|
98
|
+
const end = Math.min(days.length - 1, diffDays(first, item.endDay));
|
|
99
|
+
let lane = laneEnds.findIndex((e) => e < col);
|
|
100
|
+
if (lane === -1) { lane = laneEnds.length; laneEnds.push(end); } else laneEnds[lane] = end;
|
|
101
|
+
placed.push({ item, col, span: end - col + 1, lane, continuesLeft: item.startDay < first, continuesRight: item.endDay > last });
|
|
102
|
+
}
|
|
103
|
+
const covers = (b: Bar<T>, c: number) => b.col <= c && b.col + b.span > c;
|
|
104
|
+
const timed = days.map((d) => inWeek.filter((it) => !isSpanning(it) && it.startDay === d));
|
|
105
|
+
const overflow = days.map((_, c) => {
|
|
106
|
+
const here = placed.filter((b) => covers(b, c));
|
|
107
|
+
return here.length + timed[c].length > lines || here.some((b) => b.lane >= lines);
|
|
108
|
+
});
|
|
109
|
+
const visible = (b: Bar<T>) => {
|
|
110
|
+
if (b.lane >= lines) return false;
|
|
111
|
+
if (b.lane < lines - 1) return true;
|
|
112
|
+
for (let c = b.col; c < b.col + b.span; c++) if (overflow[c]) return false;
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
// a hidden bar needs a "+N more" line in every day it covers, which can
|
|
116
|
+
// hide more bars on that line — settle until nothing changes
|
|
117
|
+
for (let changed = true; changed; ) {
|
|
118
|
+
changed = false;
|
|
119
|
+
for (const b of placed) {
|
|
120
|
+
if (visible(b)) continue;
|
|
121
|
+
for (let c = b.col; c < b.col + b.span; c++) if (!overflow[c]) { overflow[c] = true; changed = true; }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const bars = placed.filter(visible);
|
|
125
|
+
const chips: { item: T; line: number }[][] = [];
|
|
126
|
+
const hidden: number[] = [];
|
|
127
|
+
for (let c = 0; c < days.length; c++) {
|
|
128
|
+
const capacity = overflow[c] ? lines - 1 : lines;
|
|
129
|
+
const taken = new Set(bars.filter((b) => covers(b, c)).map((b) => b.lane));
|
|
130
|
+
const free: number[] = [];
|
|
131
|
+
for (let l = 0; l < capacity; l++) if (!taken.has(l)) free.push(l);
|
|
132
|
+
const shown = timed[c].slice(0, free.length).map((item, i) => ({ item, line: free[i] }));
|
|
133
|
+
chips.push(shown);
|
|
134
|
+
const hiddenBars = placed.filter((b) => covers(b, c) && !visible(b)).length;
|
|
135
|
+
hidden.push(hiddenBars + timed[c].length - shown.length);
|
|
136
|
+
}
|
|
137
|
+
return { bars, chips, hidden, all };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Week view: side-by-side columns for overlapping timed items in one day
|
|
141
|
+
export interface Placed<T extends GridItem> { item: T; top: number; height: number; col: number; cols: number }
|
|
142
|
+
export function layoutDay<T extends GridItem>(day: Iso, items: T[]): Placed<T>[] {
|
|
143
|
+
const list = items
|
|
144
|
+
.filter((it) => !isSpanning(it) && it.startDay === day)
|
|
145
|
+
.sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
|
|
146
|
+
const out: Placed<T>[] = [];
|
|
147
|
+
let group: Placed<T>[] = [];
|
|
148
|
+
let groupEnd = -1;
|
|
149
|
+
const flush = () => {
|
|
150
|
+
const cols = group.reduce((n, p) => Math.max(n, p.col + 1), 1);
|
|
151
|
+
for (const p of group) p.cols = cols;
|
|
152
|
+
out.push(...group);
|
|
153
|
+
group = [];
|
|
154
|
+
};
|
|
155
|
+
for (const item of list) {
|
|
156
|
+
const end = Math.max(item.endMin, item.startMin + 20);
|
|
157
|
+
if (item.startMin >= groupEnd && group.length) flush();
|
|
158
|
+
const taken = new Set(group.filter((p) => p.top + p.height > item.startMin).map((p) => p.col));
|
|
159
|
+
let col = 0;
|
|
160
|
+
while (taken.has(col)) col++;
|
|
161
|
+
group.push({ item, top: item.startMin, height: end - item.startMin, col, cols: 1 });
|
|
162
|
+
groupEnd = Math.max(groupEnd, end);
|
|
163
|
+
}
|
|
164
|
+
if (group.length) flush();
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Move an item by whole days, keeping its length (drag to reschedule)
|
|
169
|
+
export const shiftDays = (from: Iso, to: Iso) => diffDays(from, to);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Schedule + approval settings. Site-wide calendar settings (week start,
|
|
2
|
+
// default calendar, booking approval default) live in one JSON column; each
|
|
3
|
+
// booking link gains an approval mode that can follow that default. Existing
|
|
4
|
+
// links that confirmed automatically now follow the site default (which
|
|
5
|
+
// starts as automatic, so nothing changes); manual ones stay manual.
|
|
6
|
+
export const CALENDAR_SETTINGS_MIGRATION = { version: 40, statements: [
|
|
7
|
+
`ALTER TABLE settings ADD COLUMN calendar TEXT NOT NULL DEFAULT '{}'`,
|
|
8
|
+
`ALTER TABLE booking_links ADD COLUMN approval TEXT NOT NULL DEFAULT 'default'`,
|
|
9
|
+
`ALTER TABLE booking_links ADD COLUMN approval_rules TEXT NOT NULL DEFAULT '{}'`,
|
|
10
|
+
`UPDATE booking_links SET approval = CASE auto_confirm WHEN 1 THEN 'default' ELSE 'manual' END`,
|
|
11
|
+
] };
|
|
12
|
+
|
|
13
|
+
// events that reserve bookable rooms/people (CalEvent.roomHold)
|
|
14
|
+
export const ROOM_HOLD_MIGRATION = { version: 41, statements: [
|
|
15
|
+
`ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`,
|
|
16
|
+
] };
|
|
@@ -105,9 +105,9 @@ const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
|
|
|
105
105
|
const chipHtml = (e: DisplayEvent, i: number) =>
|
|
106
106
|
`<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>`;
|
|
107
107
|
|
|
108
|
-
function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
|
|
108
|
+
function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
|
|
109
109
|
const first = dayNum(y, m, 1);
|
|
110
|
-
const start = first - dowOfDay(first);
|
|
110
|
+
const start = first - ((dowOfDay(first) - ws + 7) % 7);
|
|
111
111
|
let h = dows.map((d) => `<div class="pc-cal-dow">${esc(d)}</div>`).join('');
|
|
112
112
|
for (let i = 0; i < 42; i++) {
|
|
113
113
|
const n = start + i;
|
|
@@ -149,7 +149,7 @@ var body=root.querySelector('.pc-cal-body'),titleEl=root.querySelector('.pc-cal-
|
|
|
149
149
|
var D=864e5;
|
|
150
150
|
function dnum(a){return Math.round(Date.UTC(a[0],a[1]-1,a[2])/D)}
|
|
151
151
|
function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1,d.getUTCDate()]}
|
|
152
|
-
function dow(n){return((n+4)%7+7)%7}
|
|
152
|
+
function dow(n){return((n+4-(P.ws||0))%7+7)%7}
|
|
153
153
|
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)}});
|
|
154
154
|
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
155
155
|
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>'}
|
|
@@ -246,7 +246,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
246
246
|
new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(ms);
|
|
247
247
|
const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
|
|
248
248
|
const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
|
|
249
|
-
|
|
249
|
+
// Calendar settings → Defaults → "Weeks start on" (0 Sunday, 1 Monday)
|
|
250
|
+
const ws = settings.calendar?.weekStart ?? 0;
|
|
251
|
+
const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + ws + i))); // Aug 2 2026 = Sunday
|
|
250
252
|
|
|
251
253
|
const focusOk = opts.focus && (opts.focus.y * 12 + opts.focus.m) >= minY * 12 + minM && (opts.focus.y * 12 + opts.focus.m) <= maxY * 12 + maxM;
|
|
252
254
|
const [fy, fm] = focusOk ? [opts.focus!.y, opts.focus!.m] : [today.y, today.m];
|
|
@@ -254,7 +256,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
254
256
|
let bodyHtml: string;
|
|
255
257
|
let titleText: string;
|
|
256
258
|
if (opts.view === 'week') {
|
|
257
|
-
const weekStart = todayN - dowOfDay(todayN);
|
|
259
|
+
const weekStart = todayN - ((dowOfDay(todayN) - ws + 7) % 7);
|
|
258
260
|
const wa = fromDayNum(weekStart);
|
|
259
261
|
const wb = fromDayNum(weekStart + 6);
|
|
260
262
|
titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
|
|
@@ -267,7 +269,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
267
269
|
if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
|
|
268
270
|
} else {
|
|
269
271
|
titleText = `${months[fm - 1]} ${fy}`;
|
|
270
|
-
bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort);
|
|
272
|
+
bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort, ws);
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
const navButtons =
|
|
@@ -287,7 +289,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
|
|
|
287
289
|
month: [fy, fm],
|
|
288
290
|
min: [minY, minM],
|
|
289
291
|
max: [maxY, maxM],
|
|
290
|
-
months, monthsShort, dows, events,
|
|
292
|
+
months, monthsShort, dows, events, ws,
|
|
291
293
|
details: 'Details', cancelled: 'Cancelled',
|
|
292
294
|
};
|
|
293
295
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Writes behind the admin Schedule: quick add, drag to move (a whole series
|
|
2
|
+
// or one occurrence), undo, duplicate. Everything goes through the same
|
|
3
|
+
// save/apply path as the event editor, so hooks, feeds, search and webhooks
|
|
4
|
+
// see the same changes. A move applies at once, even on a published event —
|
|
5
|
+
// the editor's "stage until Update" flow is for content edits, not dates.
|
|
6
|
+
import type { ContentRepository } from '../repo/types';
|
|
7
|
+
import type { CalEvent } from '../schema';
|
|
8
|
+
import { slugify } from '../schema';
|
|
9
|
+
import { ApiError } from '../api';
|
|
10
|
+
import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
|
|
11
|
+
import { addWallDays, formatWall, parseWall } from './events';
|
|
12
|
+
import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
|
|
13
|
+
import { eventDeleted } from './hooks';
|
|
14
|
+
import { waitUntilFrom } from '../webhooks';
|
|
15
|
+
|
|
16
|
+
async function uniqueSlug(repo: ContentRepository, title: string) {
|
|
17
|
+
const base = slugify(title) || 'event';
|
|
18
|
+
let slug = base;
|
|
19
|
+
for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
|
|
20
|
+
return slug;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const live = (s: CalEvent['status']) => s === 'published' || s === 'scheduled';
|
|
24
|
+
|
|
25
|
+
// Save on a live event without staging: stage, then apply just this change.
|
|
26
|
+
// Refuses when the event already has unrelated unpublished edits, so a drag
|
|
27
|
+
// never publishes someone's half-finished copy.
|
|
28
|
+
async function saveNow(repo: ContentRepository, e: CalEvent, data: Parameters<typeof saveEvent>[2], hooks: SaveHooks) {
|
|
29
|
+
if (live(e.status) && e.draft) {
|
|
30
|
+
throw new ApiError(409, 'This event has unpublished changes. Open it and update or discard them first.');
|
|
31
|
+
}
|
|
32
|
+
const saved = await saveEvent(repo, e.id, data, hooks);
|
|
33
|
+
return live(e.status) ? applyEventUpdate(repo, e.id, hooks) : saved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QuickEvent {
|
|
37
|
+
title: string;
|
|
38
|
+
start: string; // YYYY-MM-DD or YYYY-MM-DDTHH:mm
|
|
39
|
+
end: string;
|
|
40
|
+
allDay: boolean;
|
|
41
|
+
calendarId: string;
|
|
42
|
+
rrule: string;
|
|
43
|
+
location: string;
|
|
44
|
+
publish: boolean;
|
|
45
|
+
// bookable rooms/people this event uses (held while it happens)
|
|
46
|
+
roomIds?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
|
|
50
|
+
const title = q.title.trim().slice(0, 300) || 'Untitled event';
|
|
51
|
+
const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
|
|
52
|
+
const created = await repo.createEvent({
|
|
53
|
+
title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
|
|
54
|
+
calendarIds: cal?.kind === 'native' ? [cal.id] : [],
|
|
55
|
+
location: { name: q.location.trim().slice(0, 300), address: '', url: '' },
|
|
56
|
+
});
|
|
57
|
+
if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
|
|
58
|
+
// times + repeat go through the editor's validation (end defaults, pairs)
|
|
59
|
+
await saveEvent(repo, created.id, { start: q.start, end: q.end || undefined, allDay: q.allDay, rrule: q.rrule }, hooks);
|
|
60
|
+
return q.publish ? saveEvent(repo, created.id, { status: 'published' }, hooks) : (await repo.getEvent(created.id))!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Shift a recurrence rule by whole days so "weekly on Wednesday" moved two
|
|
64
|
+
// days later becomes "weekly on Friday" (and nth-weekday / day-of-month
|
|
65
|
+
// rules follow the new first date). UNTIL moves with the series.
|
|
66
|
+
export function shiftRrule(rrule: string, days: number, newStart: string): string {
|
|
67
|
+
if (!rrule || !days) return rrule;
|
|
68
|
+
const p = rruleMap(rrule);
|
|
69
|
+
if (p.FREQ === 'WEEKLY' && p.BYDAY) {
|
|
70
|
+
p.BYDAY = p.BYDAY.split(',').map((c) => {
|
|
71
|
+
const i = (WEEKDAY_CODES as readonly string[]).indexOf(c);
|
|
72
|
+
return i < 0 ? c : WEEKDAY_CODES[(((i + days) % 7) + 7) % 7];
|
|
73
|
+
}).join(',');
|
|
74
|
+
}
|
|
75
|
+
if (p.FREQ === 'MONTHLY') {
|
|
76
|
+
const wd = weekdayOf(newStart);
|
|
77
|
+
if (p.BYDAY && /^-?\d(SU|MO|TU|WE|TH|FR|SA)$/.test(p.BYDAY)) p.BYDAY = `${wd.nth >= 5 || p.BYDAY.startsWith('-') ? -1 : wd.nth}${WEEKDAY_CODES[wd.dow]}`;
|
|
78
|
+
else if (p.BYMONTHDAY && !p.BYMONTHDAY.includes(',')) p.BYMONTHDAY = String(wd.day);
|
|
79
|
+
}
|
|
80
|
+
if (p.UNTIL && /^\d{8}$/.test(p.UNTIL)) {
|
|
81
|
+
const u = parseWall(`${p.UNTIL.slice(0, 4)}-${p.UNTIL.slice(4, 6)}-${p.UNTIL.slice(6, 8)}`);
|
|
82
|
+
if (u) p.UNTIL = formatWall(addWallDays(u, days)).replace(/-/g, '');
|
|
83
|
+
}
|
|
84
|
+
const order = ['FREQ', 'INTERVAL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'COUNT', 'UNTIL', 'WKST'];
|
|
85
|
+
return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const shift = (wall: string, days: number) => {
|
|
89
|
+
const w = parseWall(wall);
|
|
90
|
+
return w ? formatWall(addWallDays(w, days)) : wall;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type MoveUndo =
|
|
94
|
+
| { kind: 'series'; id: string; days: number }
|
|
95
|
+
| { kind: 'split'; id: string; seriesId: string; occurrence: string };
|
|
96
|
+
|
|
97
|
+
// Move a whole event (and every repeat) by `days`
|
|
98
|
+
export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
|
|
99
|
+
const e = await repo.getEvent(id);
|
|
100
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
101
|
+
if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
|
|
102
|
+
const start = shift(e.start, days);
|
|
103
|
+
const event = await saveNow(repo, e, {
|
|
104
|
+
start, end: shift(e.end, days), allDay: e.allDay,
|
|
105
|
+
...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
|
|
106
|
+
}, hooks);
|
|
107
|
+
return { event, undo: { kind: 'series', id, days: -days } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Move one occurrence of a repeating event: skip that date in the series and
|
|
111
|
+
// add a one-off copy on the new date. No schema change, and the copy is a
|
|
112
|
+
// normal event the editor can open.
|
|
113
|
+
export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
|
|
114
|
+
const e = await repo.getEvent(id);
|
|
115
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
116
|
+
if (!e.rrule) return moveSeries(repo, id, days, hooks);
|
|
117
|
+
const occ = parseWall(occurrence);
|
|
118
|
+
const first = parseWall(e.start);
|
|
119
|
+
if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
|
|
120
|
+
if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
|
|
121
|
+
// keep the occurrence's own length (first occurrence's start → end)
|
|
122
|
+
const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
|
|
123
|
+
const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
|
|
124
|
+
const newStart = addWallDays(occ, days);
|
|
125
|
+
const t = new Date(toMs(newStart) + lenMs);
|
|
126
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
127
|
+
const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
|
|
128
|
+
const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
|
|
129
|
+
|
|
130
|
+
await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
|
|
131
|
+
const copy = await repo.createEvent({
|
|
132
|
+
title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
|
|
133
|
+
timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
|
|
134
|
+
location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags, cancelled: e.cancelled, rsvp: e.rsvp,
|
|
135
|
+
template: e.template, featured: false,
|
|
136
|
+
});
|
|
137
|
+
const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
|
|
138
|
+
return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
|
|
142
|
+
if (undo.kind === 'series') {
|
|
143
|
+
await moveSeries(repo, undo.id, undo.days, hooks);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const series = await repo.getEvent(undo.seriesId);
|
|
147
|
+
const copy = await repo.getEvent(undo.id);
|
|
148
|
+
if (copy) {
|
|
149
|
+
await repo.deleteEvent(copy.id);
|
|
150
|
+
await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
|
|
151
|
+
}
|
|
152
|
+
if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
|
|
156
|
+
const e = await repo.getEvent(id);
|
|
157
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
158
|
+
const src = e.draft ? { ...e, ...(e.draft as Partial<CalEvent>) } : e;
|
|
159
|
+
const title = `${src.title} (copy)`;
|
|
160
|
+
return repo.createEvent({
|
|
161
|
+
title, slug: await uniqueSlug(repo, title), start: src.start, end: src.end, allDay: src.allDay, timezone: src.timezone,
|
|
162
|
+
rrule: src.rrule, exdates: src.exdates, status: 'draft', excerpt: src.excerpt, bodyJson: src.bodyJson, bodyHtml: src.bodyHtml,
|
|
163
|
+
cover: src.cover, coverAlt: src.coverAlt, location: src.location, cta: src.cta, calendarIds: src.calendarIds, fields: src.fields,
|
|
164
|
+
tags: src.tags, rsvp: src.rsvp, template: src.template,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -132,6 +132,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
|
|
|
132
132
|
throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.');
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
if (out.roomHold !== undefined) {
|
|
136
|
+
// only rooms/people that exist; order kept, duplicates dropped
|
|
137
|
+
const known = new Set((await repo.listResources()).map((r) => r.id));
|
|
138
|
+
out.roomHold = { ...out.roomHold, resourceIds: [...new Set(out.roomHold.resourceIds.filter((id) => known.has(id)))] };
|
|
139
|
+
}
|
|
135
140
|
if (out.calendarIds !== undefined) {
|
|
136
141
|
const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
|
|
137
142
|
out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
|
package/src/lib/drafts.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const PAGE_DRAFT_KEYS = [
|
|
|
25
25
|
export const EVENT_DRAFT_KEYS = [
|
|
26
26
|
'title', 'slug', 'bodyJson', 'bodyHtml', 'excerpt', 'tags', 'cover', 'coverAlt',
|
|
27
27
|
'seoTitle', 'seoDesc', 'featured', 'template', 'start', 'end', 'allDay',
|
|
28
|
-
'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations',
|
|
28
|
+
'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations', 'roomHold',
|
|
29
29
|
] as const;
|
|
30
30
|
|
|
31
31
|
type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
|