@acms-subir/core 0.0.1
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/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// ICS ingestion for the calendar card: fetch a public feed (Google Calendar's
|
|
2
|
+
// basic.ics, or any ICS URL), parse VEVENTs, expand recurrences over a bounded
|
|
3
|
+
// window, and hand back flat display-ready events. Runs at request time inside
|
|
4
|
+
// expandDynamicBlocks, so results are cached per-isolate for 5 minutes — the
|
|
5
|
+
// same spirit as middleware's redirectCache. Recurrence covers what real
|
|
6
|
+
// church/team calendars use (DAILY/WEEKLY/MONTHLY/YEARLY, INTERVAL, COUNT,
|
|
7
|
+
// UNTIL, BYDAY incl. nth-weekday, BYMONTHDAY, EXDATE, RECURRENCE-ID
|
|
8
|
+
// overrides); exotic RRULE parts are ignored rather than guessed at.
|
|
9
|
+
import { fetchPublicUrl } from '../net';
|
|
10
|
+
|
|
11
|
+
export interface CalendarEvent {
|
|
12
|
+
start: number; // UTC ms
|
|
13
|
+
end: number; // UTC ms, exclusive
|
|
14
|
+
allDay: boolean;
|
|
15
|
+
days: number; // calendar days covered (all-day spans); timed events = 1
|
|
16
|
+
title: string;
|
|
17
|
+
location: string;
|
|
18
|
+
description: string;
|
|
19
|
+
cal: number; // index into the card's sources list (drives the dot color)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CalendarWindow {
|
|
23
|
+
displayTz: string; // site timezone; '' falls back to UTC
|
|
24
|
+
windowStartMs: number;
|
|
25
|
+
windowEndMs: number;
|
|
26
|
+
now: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface IcsTime {
|
|
30
|
+
y: number; m: number; d: number; hh: number; mm: number; ss: number;
|
|
31
|
+
tz: string | null;
|
|
32
|
+
dateOnly: boolean;
|
|
33
|
+
utc: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface VEvent {
|
|
37
|
+
uid: string;
|
|
38
|
+
title: string;
|
|
39
|
+
location: string;
|
|
40
|
+
description: string;
|
|
41
|
+
cancelled: boolean;
|
|
42
|
+
start: IcsTime;
|
|
43
|
+
end: IcsTime | null;
|
|
44
|
+
rrule: string;
|
|
45
|
+
exdates: IcsTime[];
|
|
46
|
+
recurrenceId: IcsTime | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DAY = 86_400_000;
|
|
50
|
+
|
|
51
|
+
/* ---------------- timezone math (no dependencies) ---------------- */
|
|
52
|
+
|
|
53
|
+
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
|
54
|
+
function tzFormatter(tz: string): Intl.DateTimeFormat | null {
|
|
55
|
+
let f = fmtCache.get(tz);
|
|
56
|
+
if (f) return f;
|
|
57
|
+
try {
|
|
58
|
+
f = new Intl.DateTimeFormat('en-US', {
|
|
59
|
+
timeZone: tz, hour12: false,
|
|
60
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
61
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
return null; // unknown TZID (Outlook's "Customized Time Zone" etc.)
|
|
65
|
+
}
|
|
66
|
+
fmtCache.set(tz, f);
|
|
67
|
+
return f;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function tzParts(ms: number, tz: string): { y: number; m: number; d: number; hh: number; mm: number; ss: number } {
|
|
71
|
+
const f = tzFormatter(tz) ?? tzFormatter('UTC')!;
|
|
72
|
+
const p: Record<string, number> = {};
|
|
73
|
+
for (const part of f.formatToParts(ms)) if (part.type !== 'literal') p[part.type] = Number(part.value);
|
|
74
|
+
return { y: p.year, m: p.month, d: p.day, hh: p.hour === 24 ? 0 : p.hour, mm: p.minute, ss: p.second };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Wall-clock in `tz` → UTC ms. Two-pass offset guess handles DST boundaries.
|
|
78
|
+
export function zonedToUtc(y: number, m: number, d: number, hh: number, mm: number, ss: number, tz: string): number {
|
|
79
|
+
const guess = Date.UTC(y, m - 1, d, hh, mm, ss);
|
|
80
|
+
if (!tzFormatter(tz)) return guess;
|
|
81
|
+
const offsetAt = (ms: number) => {
|
|
82
|
+
const p = tzParts(ms, tz);
|
|
83
|
+
return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - ms;
|
|
84
|
+
};
|
|
85
|
+
const utc = guess - offsetAt(guess);
|
|
86
|
+
return guess - offsetAt(utc);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const dayNum = (y: number, m: number, d: number) => Math.round(Date.UTC(y, m - 1, d) / DAY);
|
|
90
|
+
const fromDayNum = (n: number) => {
|
|
91
|
+
const dt = new Date(n * DAY);
|
|
92
|
+
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
93
|
+
};
|
|
94
|
+
const dowOfDay = (n: number) => (((n + 4) % 7) + 7) % 7; // epoch day 0 = Thursday
|
|
95
|
+
const daysInMonth = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
96
|
+
|
|
97
|
+
/* ---------------- ICS parsing ---------------- */
|
|
98
|
+
|
|
99
|
+
function unescapeText(s: string): string {
|
|
100
|
+
return s.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// "NAME;PARAM=V;PARAM2="Q V":value" → parts, respecting quoted params
|
|
104
|
+
function splitProp(line: string): { name: string; params: Record<string, string>; value: string } | null {
|
|
105
|
+
let inQ = false;
|
|
106
|
+
let colon = -1;
|
|
107
|
+
for (let i = 0; i < line.length; i++) {
|
|
108
|
+
const c = line[i];
|
|
109
|
+
if (c === '"') inQ = !inQ;
|
|
110
|
+
else if (c === ':' && !inQ) { colon = i; break; }
|
|
111
|
+
}
|
|
112
|
+
if (colon < 0) return null;
|
|
113
|
+
const head = line.slice(0, colon);
|
|
114
|
+
const value = line.slice(colon + 1);
|
|
115
|
+
const segs: string[] = [];
|
|
116
|
+
let cur = '';
|
|
117
|
+
inQ = false;
|
|
118
|
+
for (const c of head) {
|
|
119
|
+
if (c === '"') inQ = !inQ;
|
|
120
|
+
if (c === ';' && !inQ) { segs.push(cur); cur = ''; } else cur += c;
|
|
121
|
+
}
|
|
122
|
+
segs.push(cur);
|
|
123
|
+
const params: Record<string, string> = {};
|
|
124
|
+
for (const seg of segs.slice(1)) {
|
|
125
|
+
const eq = seg.indexOf('=');
|
|
126
|
+
if (eq > 0) params[seg.slice(0, eq).toUpperCase()] = seg.slice(eq + 1).replace(/^"|"$/g, '');
|
|
127
|
+
}
|
|
128
|
+
return { name: segs[0].toUpperCase(), params, value };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseIcsDate(value: string, params: Record<string, string>): IcsTime | null {
|
|
132
|
+
const v = value.trim();
|
|
133
|
+
const dateOnly = params.VALUE === 'DATE' || /^\d{8}$/.test(v);
|
|
134
|
+
if (dateOnly) {
|
|
135
|
+
const m = v.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
return { y: +m[1], m: +m[2], d: +m[3], hh: 0, mm: 0, ss: 0, tz: null, dateOnly: true, utc: false };
|
|
138
|
+
}
|
|
139
|
+
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
|
|
140
|
+
if (!m) return null;
|
|
141
|
+
return {
|
|
142
|
+
y: +m[1], m: +m[2], d: +m[3], hh: +m[4], mm: +m[5], ss: +m[6],
|
|
143
|
+
tz: params.TZID ?? null, dateOnly: false, utc: m[7] === 'Z',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseICS(text: string): { vevents: VEvent[]; calTz: string } {
|
|
148
|
+
// unfold: a CRLF (or LF) followed by a space/tab continues the previous line
|
|
149
|
+
const lines = text.replace(/\r\n[ \t]|\n[ \t]|\r[ \t]/g, '').split(/\r\n|\n|\r/);
|
|
150
|
+
const vevents: VEvent[] = [];
|
|
151
|
+
let calTz = '';
|
|
152
|
+
let ev: Partial<VEvent> & { exdates: IcsTime[] } | null = null;
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (line === 'BEGIN:VEVENT') {
|
|
155
|
+
ev = { uid: '', title: '', location: '', description: '', cancelled: false, rrule: '', exdates: [], recurrenceId: null };
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (line === 'END:VEVENT') {
|
|
159
|
+
if (ev?.start) vevents.push(ev as VEvent);
|
|
160
|
+
ev = null;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const prop = splitProp(line);
|
|
164
|
+
if (!prop) continue;
|
|
165
|
+
if (!ev) {
|
|
166
|
+
if (prop.name === 'X-WR-TIMEZONE') calTz = prop.value.trim();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
switch (prop.name) {
|
|
170
|
+
case 'UID': ev.uid = prop.value.trim(); break;
|
|
171
|
+
case 'SUMMARY': ev.title = unescapeText(prop.value).trim(); break;
|
|
172
|
+
case 'LOCATION': ev.location = unescapeText(prop.value).trim(); break;
|
|
173
|
+
case 'DESCRIPTION':
|
|
174
|
+
// Google descriptions may carry HTML; events render as text everywhere
|
|
175
|
+
ev.description = unescapeText(prop.value).replace(/<[^>]+>/g, ' ').replace(/[ \t]+/g, ' ').trim().slice(0, 600);
|
|
176
|
+
break;
|
|
177
|
+
case 'STATUS': ev.cancelled = prop.value.trim().toUpperCase() === 'CANCELLED'; break;
|
|
178
|
+
case 'DTSTART': ev.start = parseIcsDate(prop.value, prop.params) ?? undefined; break;
|
|
179
|
+
case 'DTEND': ev.end = parseIcsDate(prop.value, prop.params); break;
|
|
180
|
+
case 'RRULE': ev.rrule = prop.value.trim(); break;
|
|
181
|
+
case 'RECURRENCE-ID': ev.recurrenceId = parseIcsDate(prop.value, prop.params); break;
|
|
182
|
+
case 'EXDATE':
|
|
183
|
+
for (const part of prop.value.split(',')) {
|
|
184
|
+
const t = parseIcsDate(part, prop.params);
|
|
185
|
+
if (t) ev.exdates.push(t);
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { vevents, calTz };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ---------------- recurrence expansion ---------------- */
|
|
194
|
+
|
|
195
|
+
interface Rule {
|
|
196
|
+
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
|
197
|
+
interval: number;
|
|
198
|
+
count: number | null;
|
|
199
|
+
until: IcsTime | null;
|
|
200
|
+
byday: { ord: number; dow: number }[];
|
|
201
|
+
bymonthday: number[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const DOW_CODES: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
|
205
|
+
|
|
206
|
+
function parseRRule(s: string): Rule | null {
|
|
207
|
+
const parts: Record<string, string> = {};
|
|
208
|
+
for (const seg of s.split(';')) {
|
|
209
|
+
const eq = seg.indexOf('=');
|
|
210
|
+
if (eq > 0) parts[seg.slice(0, eq).toUpperCase()] = seg.slice(eq + 1);
|
|
211
|
+
}
|
|
212
|
+
const freq = parts.FREQ as Rule['freq'];
|
|
213
|
+
if (!['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(freq)) return null;
|
|
214
|
+
const byday = (parts.BYDAY ?? '')
|
|
215
|
+
.split(',')
|
|
216
|
+
.map((t) => {
|
|
217
|
+
const m = t.trim().match(/^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/);
|
|
218
|
+
return m ? { ord: m[1] ? Number(m[1]) : 0, dow: DOW_CODES[m[2]] } : null;
|
|
219
|
+
})
|
|
220
|
+
.filter((x): x is { ord: number; dow: number } => x !== null);
|
|
221
|
+
return {
|
|
222
|
+
freq,
|
|
223
|
+
interval: Math.max(1, Number(parts.INTERVAL) || 1),
|
|
224
|
+
count: parts.COUNT ? Math.max(1, Number(parts.COUNT) || 1) : null,
|
|
225
|
+
until: parts.UNTIL ? parseIcsDate(parts.UNTIL, {}) : null,
|
|
226
|
+
byday,
|
|
227
|
+
bymonthday: (parts.BYMONTHDAY ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 31),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function timeToMs(t: IcsTime, timedTz: string, displayTz: string): number {
|
|
232
|
+
if (t.dateOnly) return zonedToUtc(t.y, t.m, t.d, 0, 0, 0, displayTz); // all-day: midnight in the display tz, so day bucketing is exact
|
|
233
|
+
if (t.utc) return Date.UTC(t.y, t.m - 1, t.d, t.hh, t.mm, t.ss);
|
|
234
|
+
return zonedToUtc(t.y, t.m, t.d, t.hh, t.mm, t.ss, t.tz ?? timedTz);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function expandEvent(
|
|
238
|
+
ev: VEvent,
|
|
239
|
+
calTz: string,
|
|
240
|
+
win: CalendarWindow,
|
|
241
|
+
calIdx: number,
|
|
242
|
+
skipStarts: Set<number>,
|
|
243
|
+
out: CalendarEvent[]
|
|
244
|
+
): void {
|
|
245
|
+
const displayTz = win.displayTz || 'UTC';
|
|
246
|
+
const timedTz = calTz || displayTz;
|
|
247
|
+
if (ev.cancelled) return;
|
|
248
|
+
const startMs = timeToMs(ev.start, timedTz, displayTz);
|
|
249
|
+
const endMs = ev.end ? timeToMs(ev.end, timedTz, displayTz) : NaN;
|
|
250
|
+
const allDay = ev.start.dateOnly;
|
|
251
|
+
const spanDays = allDay ? Math.max(1, Number.isFinite(endMs) ? Math.round((endMs - startMs) / DAY) : 1) : 1;
|
|
252
|
+
const durMs = allDay ? spanDays * DAY : Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : 3_600_000;
|
|
253
|
+
|
|
254
|
+
const push = (s: number) => {
|
|
255
|
+
const e = allDay ? s + spanDays * DAY : s + durMs;
|
|
256
|
+
if (e <= win.windowStartMs || s >= win.windowEndMs) return;
|
|
257
|
+
out.push({ start: s, end: e, allDay, days: spanDays, title: ev.title || '(untitled)', location: ev.location, description: ev.description, cal: calIdx });
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const rule = ev.rrule && !ev.recurrenceId ? parseRRule(ev.rrule) : null;
|
|
261
|
+
if (!rule) {
|
|
262
|
+
if (!skipStarts.has(startMs)) push(startMs);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// exceptions, matched by occurrence start (plus local-date for all-day)
|
|
267
|
+
const exMs = new Set(ev.exdates.map((t) => timeToMs(t, timedTz, displayTz)));
|
|
268
|
+
|
|
269
|
+
// local reference: DTSTART's wall clock in the tz occurrences are generated in
|
|
270
|
+
const occTz = ev.start.dateOnly ? displayTz : ev.start.tz ?? timedTz;
|
|
271
|
+
const ref = ev.start.dateOnly
|
|
272
|
+
? { y: ev.start.y, m: ev.start.m, d: ev.start.d, hh: 0, mm: 0, ss: 0 }
|
|
273
|
+
: tzParts(startMs, occTz);
|
|
274
|
+
const startDay = dayNum(ref.y, ref.m, ref.d);
|
|
275
|
+
const untilMs = rule.until ? timeToMs(rule.until, timedTz, displayTz) : Infinity;
|
|
276
|
+
const windowEndDay = Math.ceil(win.windowEndMs / DAY) + 2;
|
|
277
|
+
const weekAnchor = startDay - dowOfDay(startDay);
|
|
278
|
+
const bydayDows = rule.byday.map((b) => b.dow);
|
|
279
|
+
|
|
280
|
+
// COUNT needs every occurrence counted from DTSTART; otherwise skip ahead
|
|
281
|
+
let day = rule.count ? startDay : Math.max(startDay, Math.floor(win.windowStartMs / DAY) - Math.max(35, spanDays + 1));
|
|
282
|
+
let produced = 0;
|
|
283
|
+
for (let steps = 0; day <= windowEndDay && steps < 40_000; steps++, day++) {
|
|
284
|
+
const { y, m, d } = fromDayNum(day);
|
|
285
|
+
let hit = false;
|
|
286
|
+
switch (rule.freq) {
|
|
287
|
+
case 'DAILY':
|
|
288
|
+
hit = (day - startDay) % rule.interval === 0;
|
|
289
|
+
break;
|
|
290
|
+
case 'WEEKLY': {
|
|
291
|
+
const inWeek = Math.floor((day - weekAnchor) / 7) % rule.interval === 0;
|
|
292
|
+
hit = inWeek && (bydayDows.length ? bydayDows.includes(dowOfDay(day)) : day === startDay || dowOfDay(day) === dowOfDay(startDay));
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case 'MONTHLY': {
|
|
296
|
+
const monthsSince = (y - ref.y) * 12 + (m - ref.m);
|
|
297
|
+
if (monthsSince % rule.interval !== 0) break;
|
|
298
|
+
if (rule.bymonthday.length) hit = rule.bymonthday.includes(d);
|
|
299
|
+
else if (rule.byday.length) {
|
|
300
|
+
const dow = dowOfDay(day);
|
|
301
|
+
hit = rule.byday.some((b) => {
|
|
302
|
+
if (b.dow !== dow) return false;
|
|
303
|
+
if (b.ord > 0) return Math.floor((d - 1) / 7) + 1 === b.ord;
|
|
304
|
+
if (b.ord < 0) return Math.floor((daysInMonth(y, m) - d) / 7) + 1 === -b.ord;
|
|
305
|
+
return true; // every matching weekday of the month
|
|
306
|
+
});
|
|
307
|
+
} else hit = d === ref.d;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case 'YEARLY':
|
|
311
|
+
hit = m === ref.m && d === ref.d && (y - ref.y) % rule.interval === 0;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (!hit) continue;
|
|
315
|
+
if (day < startDay) continue;
|
|
316
|
+
|
|
317
|
+
const occStart = ev.start.dateOnly ? zonedToUtc(y, m, d, 0, 0, 0, displayTz) : zonedToUtc(y, m, d, ref.hh, ref.mm, ref.ss, occTz);
|
|
318
|
+
if (occStart > untilMs) break;
|
|
319
|
+
produced++;
|
|
320
|
+
if (rule.count && produced > rule.count) break;
|
|
321
|
+
if (exMs.has(occStart) || skipStarts.has(occStart)) continue;
|
|
322
|
+
push(occStart);
|
|
323
|
+
if (produced >= 1000) break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function expandVEvents(vevents: VEvent[], calTz: string, win: CalendarWindow, calIdx: number): CalendarEvent[] {
|
|
328
|
+
const displayTz = win.displayTz || 'UTC';
|
|
329
|
+
const timedTz = calTz || displayTz;
|
|
330
|
+
// RECURRENCE-ID overrides replace their master's generated instance
|
|
331
|
+
const skipByUid = new Map<string, Set<number>>();
|
|
332
|
+
for (const ev of vevents) {
|
|
333
|
+
if (!ev.recurrenceId || !ev.uid) continue;
|
|
334
|
+
const ms = timeToMs(ev.recurrenceId, timedTz, displayTz);
|
|
335
|
+
if (!skipByUid.has(ev.uid)) skipByUid.set(ev.uid, new Set());
|
|
336
|
+
skipByUid.get(ev.uid)!.add(ms);
|
|
337
|
+
}
|
|
338
|
+
const out: CalendarEvent[] = [];
|
|
339
|
+
const none = new Set<number>();
|
|
340
|
+
for (const ev of vevents) {
|
|
341
|
+
expandEvent(ev, calTz, win, calIdx, ev.recurrenceId ? none : skipByUid.get(ev.uid) ?? none, out);
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* ---------------- fetch + cache ---------------- */
|
|
347
|
+
|
|
348
|
+
interface CacheEntry { at: number; vevents: VEvent[]; calTz: string }
|
|
349
|
+
const feedCache = new Map<string, CacheEntry>();
|
|
350
|
+
const FEED_TTL = 5 * 60 * 1000;
|
|
351
|
+
const FEED_CACHE_MAX = 24;
|
|
352
|
+
|
|
353
|
+
async function loadFeed(url: string, now: number): Promise<CacheEntry> {
|
|
354
|
+
const cached = feedCache.get(url);
|
|
355
|
+
if (cached && now - cached.at < FEED_TTL) return cached;
|
|
356
|
+
const got = await fetchPublicUrl(url, {
|
|
357
|
+
accept: 'text/calendar, text/plain;q=0.8, */*;q=0.5',
|
|
358
|
+
userAgent: 'Mozilla/5.0 (compatible; acms-calendar/1.0)',
|
|
359
|
+
});
|
|
360
|
+
if (!got || !got.res.ok) {
|
|
361
|
+
if (cached) return cached; // stale beats broken
|
|
362
|
+
throw new Error(`Couldn’t load the calendar feed (${got?.res.status ?? 'unreachable'}).`);
|
|
363
|
+
}
|
|
364
|
+
const text = (await got.res.text()).slice(0, 2_000_000);
|
|
365
|
+
if (!text.includes('BEGIN:VCALENDAR')) {
|
|
366
|
+
if (cached) return cached;
|
|
367
|
+
throw new Error('That URL isn’t an ICS calendar feed.');
|
|
368
|
+
}
|
|
369
|
+
const entry: CacheEntry = { at: now, ...parseICS(text) };
|
|
370
|
+
feedCache.delete(url);
|
|
371
|
+
feedCache.set(url, entry);
|
|
372
|
+
if (feedCache.size > FEED_CACHE_MAX) {
|
|
373
|
+
const oldest = feedCache.keys().next().value;
|
|
374
|
+
if (oldest !== undefined) feedCache.delete(oldest);
|
|
375
|
+
}
|
|
376
|
+
return entry;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Fetch + expand every source. Individual feed failures don't sink the widget:
|
|
380
|
+
// events from healthy sources still render and failures come back as messages.
|
|
381
|
+
export async function getCalendarEvents(
|
|
382
|
+
sources: string[],
|
|
383
|
+
win: CalendarWindow
|
|
384
|
+
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
|
|
385
|
+
const errors: string[] = [];
|
|
386
|
+
const perSource = await Promise.all(
|
|
387
|
+
sources.slice(0, 6).map(async (url, i) => {
|
|
388
|
+
try {
|
|
389
|
+
const feed = await loadFeed(url, win.now);
|
|
390
|
+
return expandVEvents(feed.vevents, feed.calTz, win, i);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
errors.push(err instanceof Error ? err.message : 'Calendar feed failed.');
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const events = perSource
|
|
398
|
+
.flat()
|
|
399
|
+
.sort((a, b) => a.start - b.start || a.title.localeCompare(b.title))
|
|
400
|
+
.slice(0, 800);
|
|
401
|
+
return { events, errors };
|
|
402
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Request-time HTML for the calendar card (data-dyn="calendar"), called from
|
|
2
|
+
// expandDynamicBlocks. Server-renders the card's chosen view — month grid,
|
|
3
|
+
// week strip, or upcoming list — so the widget works with JS disabled, then
|
|
4
|
+
// attaches a display-ready JSON payload + a small vanilla script for month/
|
|
5
|
+
// week navigation and the event popover. No framework code reaches the public
|
|
6
|
+
// site. Every element carries a stable .pc-cal-* class: that's the widget's
|
|
7
|
+
// public styling API (theme CSS and Settings → Code injection both restyle it).
|
|
8
|
+
import type { Settings } from '../schema';
|
|
9
|
+
import { siteLocale } from '../dates';
|
|
10
|
+
import { getCalendarEvents, tzParts, zonedToUtc, type CalendarEvent } from './ics';
|
|
11
|
+
import { calendarPageUrl } from './src';
|
|
12
|
+
|
|
13
|
+
export interface CalendarBlockAttrs {
|
|
14
|
+
sources: string[];
|
|
15
|
+
view: 'month' | 'week' | 'upcoming';
|
|
16
|
+
title: string;
|
|
17
|
+
subtitle: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
showOpenLink: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DAY = 86_400_000;
|
|
23
|
+
|
|
24
|
+
const esc = (s: string) =>
|
|
25
|
+
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
26
|
+
|
|
27
|
+
const dayNum = (y: number, m: number, d: number) => Math.round(Date.UTC(y, m - 1, d) / DAY);
|
|
28
|
+
const fromDayNum = (n: number) => {
|
|
29
|
+
const dt = new Date(n * DAY);
|
|
30
|
+
return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
|
|
31
|
+
};
|
|
32
|
+
const dowOfDay = (n: number) => (((n + 4) % 7) + 7) % 7;
|
|
33
|
+
const addMonths = (y: number, m: number, delta: number): [number, number] => {
|
|
34
|
+
const t = y * 12 + (m - 1) + delta;
|
|
35
|
+
return [Math.floor(t / 12), (((t % 12) + 12) % 12) + 1];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// one display-ready entry per event; the client script never touches timezones
|
|
39
|
+
interface DisplayEvent {
|
|
40
|
+
d: [number, number, number]; // local start day
|
|
41
|
+
n: number; // days covered
|
|
42
|
+
t: string; // title
|
|
43
|
+
tm: string; // chip time label ('' for all-day)
|
|
44
|
+
rg: string; // popover "when" line
|
|
45
|
+
l: string;
|
|
46
|
+
ds: string;
|
|
47
|
+
c: number; // source index (dot color)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function compactTime(s: string): string {
|
|
51
|
+
return s.replace(':00', '').replace(/\s?(AM|PM)/, (_, p: string) => p.toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildDisplay(events: CalendarEvent[], tz: string, locale: string): DisplayEvent[] {
|
|
55
|
+
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
56
|
+
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
57
|
+
return events.map((e) => {
|
|
58
|
+
const p = tzParts(e.start, tz);
|
|
59
|
+
const when = dayFmt.format(e.start);
|
|
60
|
+
let tm = '';
|
|
61
|
+
let rg: string;
|
|
62
|
+
if (e.allDay) {
|
|
63
|
+
rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · All day`;
|
|
64
|
+
} else {
|
|
65
|
+
tm = compactTime(timeFmt.format(e.start));
|
|
66
|
+
rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
d: [p.y, p.m, p.d], n: e.days, t: e.title, tm, rg,
|
|
70
|
+
l: e.location, ds: e.description.slice(0, 300), c: e.cal,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
|
|
76
|
+
const byDay = new Map<number, number[]>();
|
|
77
|
+
events.forEach((e, i) => {
|
|
78
|
+
const n0 = dayNum(e.d[0], e.d[1], e.d[2]);
|
|
79
|
+
for (let k = 0; k < Math.min(e.n, 60); k++) {
|
|
80
|
+
if (!byDay.has(n0 + k)) byDay.set(n0 + k, []);
|
|
81
|
+
byDay.get(n0 + k)!.push(i);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return byDay;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ---- view fragments (client script mirrors these exactly) ---- */
|
|
88
|
+
|
|
89
|
+
const chipHtml = (e: DisplayEvent, i: number) =>
|
|
90
|
+
`<button type="button" class="pc-cal-event" data-ev="${i}" data-cal="${e.c}"><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
|
|
91
|
+
|
|
92
|
+
function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
|
|
93
|
+
const first = dayNum(y, m, 1);
|
|
94
|
+
const start = first - dowOfDay(first);
|
|
95
|
+
let h = dows.map((d) => `<div class="pc-cal-dow">${esc(d)}</div>`).join('');
|
|
96
|
+
for (let i = 0; i < 42; i++) {
|
|
97
|
+
const n = start + i;
|
|
98
|
+
const p = fromDayNum(n);
|
|
99
|
+
const out = p.m !== m;
|
|
100
|
+
const label = p.d === 1 ? `${esc(monthsShort[p.m - 1])} 1` : String(p.d);
|
|
101
|
+
const chips = out ? '' : (byDay.get(n) ?? []).map((idx) => chipHtml(events[idx], idx)).join('');
|
|
102
|
+
h += `<div class="pc-cal-day${out ? ' is-out' : ''}${n === todayN ? ' is-today' : ''}"><span class="pc-cal-date">${label}</span>${chips}</div>`;
|
|
103
|
+
}
|
|
104
|
+
return `<div class="pc-cal-grid">${h}</div>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[]): string {
|
|
108
|
+
let h = '';
|
|
109
|
+
for (let i = 0; i < 7; i++) {
|
|
110
|
+
const n = weekStart + i;
|
|
111
|
+
const p = fromDayNum(n);
|
|
112
|
+
const chips = (byDay.get(n) ?? []).map((idx) => chipHtml(events[idx], idx)).join('');
|
|
113
|
+
h += `<div class="pc-cal-wday${n === todayN ? ' is-today' : ''}"><div class="whead"><span class="pc-cal-dow">${esc(dows[i])}</span><span class="pc-cal-date">${p.d}</span></div><div class="wevs">${chips}</div></div>`;
|
|
114
|
+
}
|
|
115
|
+
return `<div class="pc-cal-week">${h}</div>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
|
|
119
|
+
if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
|
|
120
|
+
const items = order.slice(0, limit).map((i) => {
|
|
121
|
+
const e = events[i];
|
|
122
|
+
return `<li><button type="button" class="pc-cal-row" data-ev="${i}" data-cal="${e.c}"><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || 'All day'}</span></button></li>`;
|
|
123
|
+
});
|
|
124
|
+
return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---- the client script: nav + popover, ~1.6 KB minified-ish ---- */
|
|
128
|
+
|
|
129
|
+
const CLIENT_JS = `(function(){
|
|
130
|
+
var sc=document.currentScript,root=sc&&sc.closest('.pc-calendar');if(!root)return;
|
|
131
|
+
var P=JSON.parse(root.querySelector('script[type="application/json"]').textContent);
|
|
132
|
+
var body=root.querySelector('.pc-cal-body'),titleEl=root.querySelector('.pc-cal-title'),frame=root.querySelector('.pc-cal-frame');
|
|
133
|
+
var D=864e5;
|
|
134
|
+
function dnum(a){return Math.round(Date.UTC(a[0],a[1]-1,a[2])/D)}
|
|
135
|
+
function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1,d.getUTCDate()]}
|
|
136
|
+
function dow(n){return((n+4)%7+7)%7}
|
|
137
|
+
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)}});
|
|
138
|
+
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
139
|
+
function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event" data-ev="'+i+'" data-cal="'+e.c+'"><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
|
|
140
|
+
function chips(n){return(byDay[n]||[]).map(chip).join('')}
|
|
141
|
+
var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
|
|
142
|
+
function renderMonth(){
|
|
143
|
+
titleEl.textContent=P.months[mM-1]+' '+mY;
|
|
144
|
+
var first=dnum([mY,mM,1]),start=first-dow(first),h='',i,n,p;
|
|
145
|
+
for(i=0;i<7;i++)h+='<div class="pc-cal-dow">'+esc(P.dows[i])+'</div>';
|
|
146
|
+
for(i=0;i<42;i++){n=start+i;p=fday(n);var out=p[1]!==mM;
|
|
147
|
+
h+='<div class="pc-cal-day'+(out?' is-out':'')+(n===todayN?' is-today':'')+'"><span class="pc-cal-date">'+(p[2]===1?esc(P.monthsShort[p[1]-1])+' 1':p[2])+'</span>'+(out?'':chips(n))+'</div>'}
|
|
148
|
+
body.innerHTML='<div class="pc-cal-grid">'+h+'</div>'}
|
|
149
|
+
function renderWeek(){
|
|
150
|
+
var a=fday(weekN),b=fday(weekN+6);
|
|
151
|
+
titleEl.textContent=P.monthsShort[a[1]-1]+' '+a[2]+' – '+(a[1]===b[1]?'':P.monthsShort[b[1]-1]+' ')+b[2]+', '+b[0];
|
|
152
|
+
var h='',i,n,p;
|
|
153
|
+
for(i=0;i<7;i++){n=weekN+i;p=fday(n);
|
|
154
|
+
h+='<div class="pc-cal-wday'+(n===todayN?' is-today':'')+'"><div class="whead"><span class="pc-cal-dow">'+esc(P.dows[i])+'</span><span class="pc-cal-date">'+p[2]+'</span></div><div class="wevs">'+chips(n)+'</div></div>'}
|
|
155
|
+
body.innerHTML='<div class="pc-cal-week">'+h+'</div>'}
|
|
156
|
+
function rerender(){if(P.view==='month')renderMonth();else if(P.view==='week')renderWeek()}
|
|
157
|
+
function inRange(y,m){var v=y*12+m;return v>=P.min[0]*12+P.min[1]&&v<=P.max[0]*12+P.max[1]}
|
|
158
|
+
function nav(dir){
|
|
159
|
+
if(P.view==='month'){var next=mM+dir,y=mY;if(next<1){next=12;y--}if(next>12){next=1;y++}
|
|
160
|
+
if(!inRange(y,next))return;mY=y;mM=next}
|
|
161
|
+
else{var n=weekN+dir*7,p=fday(n);if(!inRange(p[0],p[1]))return;weekN=n}
|
|
162
|
+
closePop();rerender()}
|
|
163
|
+
var bp=root.querySelector('[data-nav="prev"]'),bn=root.querySelector('[data-nav="next"]'),bt=root.querySelector('[data-nav="today"]');
|
|
164
|
+
if(bp){bp.addEventListener('click',function(){nav(-1)});bn.addEventListener('click',function(){nav(1)});
|
|
165
|
+
bt.addEventListener('click',function(){closePop();mY=P.today[0];mM=P.today[1];weekN=todayN-dow(todayN);rerender()})}
|
|
166
|
+
var pop=null;
|
|
167
|
+
function closePop(){if(pop){pop.remove();pop=null}}
|
|
168
|
+
root.addEventListener('click',function(ev){
|
|
169
|
+
var t=ev.target;if(!t.closest)return;
|
|
170
|
+
var hit=t.closest('[data-ev]');
|
|
171
|
+
if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
|
|
172
|
+
var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
|
|
173
|
+
closePop();
|
|
174
|
+
pop=document.createElement('div');pop.className='pc-cal-pop';
|
|
175
|
+
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">×</button><strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</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>':'');
|
|
176
|
+
frame.appendChild(pop);
|
|
177
|
+
var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
|
|
178
|
+
pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
|
|
179
|
+
pop.style.top=(cr.bottom-fr.top+6)+'px';
|
|
180
|
+
pop.querySelector('.pc-cal-pop-x').addEventListener('click',closePop)});
|
|
181
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape')closePop()});
|
|
182
|
+
document.addEventListener('click',function(e){if(pop&&e.target instanceof Node&&!root.contains(e.target))closePop()});
|
|
183
|
+
})();`;
|
|
184
|
+
|
|
185
|
+
/* ---- entry point ---- */
|
|
186
|
+
|
|
187
|
+
export async function renderCalendarBlock(a: CalendarBlockAttrs, settings: Settings, now: number): Promise<string> {
|
|
188
|
+
const heading =
|
|
189
|
+
(a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
|
|
190
|
+
(a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
|
|
191
|
+
|
|
192
|
+
if (!a.sources.length) {
|
|
193
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tz = settings.timezone || 'UTC';
|
|
197
|
+
const locale = siteLocale(settings);
|
|
198
|
+
const today = tzParts(now, tz);
|
|
199
|
+
const [minY, minM] = addMonths(today.y, today.m, -6);
|
|
200
|
+
const [maxY, maxM] = addMonths(today.y, today.m, 6);
|
|
201
|
+
const [endY, endM] = addMonths(today.y, today.m, 7);
|
|
202
|
+
const win = {
|
|
203
|
+
displayTz: tz, now,
|
|
204
|
+
windowStartMs: zonedToUtc(minY, minM, 1, 0, 0, 0, tz),
|
|
205
|
+
windowEndMs: zonedToUtc(endY, endM, 1, 0, 0, 0, tz),
|
|
206
|
+
};
|
|
207
|
+
const { events: raw, errors } = await getCalendarEvents(a.sources, win);
|
|
208
|
+
|
|
209
|
+
if (!raw.length && errors.length === a.sources.length) {
|
|
210
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">Calendar unavailable right now.</p></div></section>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const events = buildDisplay(raw, tz, locale);
|
|
214
|
+
const byDay = indexByDay(events);
|
|
215
|
+
const todayN = dayNum(today.y, today.m, today.d);
|
|
216
|
+
|
|
217
|
+
const utcLabel = (opts: Intl.DateTimeFormatOptions, ms: number) =>
|
|
218
|
+
new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(ms);
|
|
219
|
+
const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
|
|
220
|
+
const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
|
|
221
|
+
const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
|
|
222
|
+
|
|
223
|
+
let bodyHtml: string;
|
|
224
|
+
let titleText: string;
|
|
225
|
+
if (a.view === 'week') {
|
|
226
|
+
const weekStart = todayN - dowOfDay(todayN);
|
|
227
|
+
const wa = fromDayNum(weekStart);
|
|
228
|
+
const wb = fromDayNum(weekStart + 6);
|
|
229
|
+
titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
|
|
230
|
+
bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
|
|
231
|
+
} else if (a.view === 'upcoming') {
|
|
232
|
+
titleText = 'Upcoming';
|
|
233
|
+
const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
|
|
234
|
+
const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
|
|
235
|
+
bodyHtml = upcomingHtml(events, order, Math.min(20, Math.max(1, a.limit || 6)));
|
|
236
|
+
} else {
|
|
237
|
+
titleText = `${months[today.m - 1]} ${today.y}`;
|
|
238
|
+
bodyHtml = monthGridHtml(today.y, today.m, todayN, byDay, events, dows, monthsShort);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const navButtons =
|
|
242
|
+
a.view === 'upcoming'
|
|
243
|
+
? ''
|
|
244
|
+
: `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
|
|
245
|
+
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
|
|
246
|
+
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
|
|
247
|
+
|
|
248
|
+
const pageUrl = a.showOpenLink ? a.sources.map(calendarPageUrl).find(Boolean) ?? '' : '';
|
|
249
|
+
const footer = pageUrl
|
|
250
|
+
? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(pageUrl)}" target="_blank" rel="noopener">Open in Google Calendar ↗</a></div>`
|
|
251
|
+
: '';
|
|
252
|
+
|
|
253
|
+
const payload = {
|
|
254
|
+
view: a.view,
|
|
255
|
+
today: [today.y, today.m, today.d],
|
|
256
|
+
month: [today.y, today.m],
|
|
257
|
+
min: [minY, minM],
|
|
258
|
+
max: [maxY, maxM],
|
|
259
|
+
months, monthsShort, dows, events,
|
|
260
|
+
};
|
|
261
|
+
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
262
|
+
|
|
263
|
+
const multi = a.sources.length > 1 ? ' is-multi' : ''; // dots only mean something with 2+ calendars
|
|
264
|
+
return (
|
|
265
|
+
`<section class="pc-calendar pc-cal-view-${a.view}${multi}">${heading}` +
|
|
266
|
+
`<div class="pc-cal-frame">` +
|
|
267
|
+
`<div class="pc-cal-toolbar"><span class="pc-cal-title">${esc(titleText)}</span>${navButtons}</div>` +
|
|
268
|
+
`<div class="pc-cal-body">${bodyHtml}</div>` +
|
|
269
|
+
footer +
|
|
270
|
+
`</div>` +
|
|
271
|
+
`<script type="application/json">${json}</script>` +
|
|
272
|
+
`<script>${CLIENT_JS}</script>` +
|
|
273
|
+
`</section>`
|
|
274
|
+
);
|
|
275
|
+
}
|