@acms-subir/core 0.1.0 → 0.3.0
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 +106 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -315
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +455 -47
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +216 -97
- package/src/components/NavManager.tsx +83 -38
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +331 -57
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/analytics.ts +121 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +112 -0
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +10 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +140 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ICS output: subscribe feeds (/calendar.ics, /calendar/<slug>.ics) and the
|
|
2
|
+
// single-event download behind "Add to calendar → Apple". One-off timed
|
|
3
|
+
// events are written in UTC (unambiguous everywhere); recurring timed events
|
|
4
|
+
// keep their wall clock with TZID=<IANA zone> so a weekly 10am stays at 10am
|
|
5
|
+
// across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
|
|
6
|
+
// events are VALUE=DATE with the RFC's exclusive DTEND.
|
|
7
|
+
import type { CalEvent, Calendar } from '../schema';
|
|
8
|
+
import { addWallDays, eventInstants, eventTimeZone, locationLine, parseWall, type Wall } from './events';
|
|
9
|
+
import { zonedToUtc } from './ics';
|
|
10
|
+
import { cleanRrule, rruleMap } from './rrule';
|
|
11
|
+
|
|
12
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
13
|
+
|
|
14
|
+
export function escapeText(s: string): string {
|
|
15
|
+
return s.replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// RFC 5545 §3.1: lines longer than 75 octets fold with CRLF + space. Folding
|
|
19
|
+
// counts UTF-8 bytes and never splits a multi-byte character.
|
|
20
|
+
export function foldLine(line: string): string {
|
|
21
|
+
const enc = new TextEncoder();
|
|
22
|
+
if (enc.encode(line).length <= 75) return line;
|
|
23
|
+
const out: string[] = [];
|
|
24
|
+
let cur = '';
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
for (const ch of line) {
|
|
27
|
+
const n = enc.encode(ch).length;
|
|
28
|
+
const limit = out.length === 0 ? 75 : 74; // continuation lines carry the leading space
|
|
29
|
+
if (bytes + n > limit) {
|
|
30
|
+
out.push(cur);
|
|
31
|
+
cur = '';
|
|
32
|
+
bytes = 0;
|
|
33
|
+
}
|
|
34
|
+
cur += ch;
|
|
35
|
+
bytes += n;
|
|
36
|
+
}
|
|
37
|
+
out.push(cur);
|
|
38
|
+
return out.join('\r\n ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const utcStamp = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
42
|
+
const dateValue = (w: Wall) => `${w.y}${pad(w.m)}${pad(w.d)}`;
|
|
43
|
+
const localValue = (w: Wall) => `${dateValue(w)}T${pad(w.hh)}${pad(w.mm)}00`;
|
|
44
|
+
|
|
45
|
+
// timed events: a date-only UNTIL ("through Oct 30") becomes the UTC instant
|
|
46
|
+
// of that day's end in the event zone, as RFC 5545 requires for DATE-TIME
|
|
47
|
+
// starts
|
|
48
|
+
function feedRrule(rrule: string, allDay: boolean, tz: string): string {
|
|
49
|
+
const clean = cleanRrule(rrule);
|
|
50
|
+
if (!clean) return '';
|
|
51
|
+
const parts = rruleMap(clean);
|
|
52
|
+
if (!allDay && parts.UNTIL && /^\d{8}$/.test(parts.UNTIL)) {
|
|
53
|
+
const y = +parts.UNTIL.slice(0, 4), m = +parts.UNTIL.slice(4, 6), d = +parts.UNTIL.slice(6, 8);
|
|
54
|
+
parts.UNTIL = utcStamp(zonedToUtc(y, m, d, 23, 59, 59, tz));
|
|
55
|
+
}
|
|
56
|
+
return Object.entries(parts).map(([k, v]) => `${k}=${v}`).join(';');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FeedOptions {
|
|
60
|
+
name: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
origin: string; // absolute site origin for URL + UID
|
|
63
|
+
host: string;
|
|
64
|
+
tz: string; // site zone, for X-WR-TIMEZONE
|
|
65
|
+
eventUrl: (e: CalEvent) => string;
|
|
66
|
+
calendars?: Map<string, Calendar>;
|
|
67
|
+
refreshMinutes?: number;
|
|
68
|
+
now?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): string[] {
|
|
72
|
+
const s = parseWall(e.start);
|
|
73
|
+
if (!s) return [];
|
|
74
|
+
const en = parseWall(e.end) ?? s;
|
|
75
|
+
const tz = eventTimeZone(e, { timezone: siteTz });
|
|
76
|
+
const rule = feedRrule(e.rrule, e.allDay, tz);
|
|
77
|
+
const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
|
|
78
|
+
if (e.allDay) {
|
|
79
|
+
lines.push(`DTSTART;VALUE=DATE:${dateValue(s)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...en, dateOnly: true }, 1))}`);
|
|
80
|
+
} else if (rule) {
|
|
81
|
+
lines.push(`DTSTART;TZID=${tz}:${localValue(s)}`, `DTEND;TZID=${tz}:${localValue(en)}`);
|
|
82
|
+
} else {
|
|
83
|
+
const { startAt, endAt } = eventInstants(e, tz);
|
|
84
|
+
lines.push(`DTSTART:${utcStamp(startAt)}`, `DTEND:${utcStamp(endAt)}`);
|
|
85
|
+
}
|
|
86
|
+
if (rule) lines.push(`RRULE:${rule}`);
|
|
87
|
+
const ex = e.exdates.map(parseWall).filter((w): w is Wall => w !== null);
|
|
88
|
+
if (ex.length) {
|
|
89
|
+
lines.push(e.allDay ? `EXDATE;VALUE=DATE:${ex.map(dateValue).join(',')}` : `EXDATE;TZID=${tz}:${ex.map(localValue).join(',')}`);
|
|
90
|
+
}
|
|
91
|
+
const url = opts.origin + opts.eventUrl(e);
|
|
92
|
+
lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
|
|
93
|
+
const loc = locationLine(e.location);
|
|
94
|
+
if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
|
|
95
|
+
const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
|
|
96
|
+
lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
|
|
97
|
+
const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
|
|
98
|
+
if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
|
|
99
|
+
if (e.cancelled) lines.push('STATUS:CANCELLED');
|
|
100
|
+
lines.push(`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`, 'END:VEVENT');
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildFeed(events: CalEvent[], siteTz: string, opts: FeedOptions): string {
|
|
105
|
+
const lines = [
|
|
106
|
+
'BEGIN:VCALENDAR',
|
|
107
|
+
'VERSION:2.0',
|
|
108
|
+
'PRODID:-//acms-subir//Calendar//EN',
|
|
109
|
+
'CALSCALE:GREGORIAN',
|
|
110
|
+
'METHOD:PUBLISH',
|
|
111
|
+
`X-WR-CALNAME:${escapeText(opts.name)}`,
|
|
112
|
+
...(opts.description ? [`X-WR-CALDESC:${escapeText(opts.description)}`] : []),
|
|
113
|
+
`X-WR-TIMEZONE:${siteTz || 'UTC'}`,
|
|
114
|
+
`REFRESH-INTERVAL;VALUE=DURATION:PT${opts.refreshMinutes ?? 60}M`,
|
|
115
|
+
`X-PUBLISHED-TTL:PT${opts.refreshMinutes ?? 60}M`,
|
|
116
|
+
...events.flatMap((e) => eventToVevent(e, siteTz, opts)),
|
|
117
|
+
'END:VCALENDAR',
|
|
118
|
+
];
|
|
119
|
+
return lines.map(foldLine).join('\r\n') + '\r\n';
|
|
120
|
+
}
|
package/src/lib/calendar/ics.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface CalendarEvent {
|
|
|
17
17
|
location: string;
|
|
18
18
|
description: string;
|
|
19
19
|
cal: number; // index into the card's sources list (drives the dot color)
|
|
20
|
+
// native events only: the event id, so views can link to the event page
|
|
21
|
+
ref?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface CalendarWindow {
|
|
@@ -26,15 +28,17 @@ export interface CalendarWindow {
|
|
|
26
28
|
now: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
interface IcsTime {
|
|
31
|
+
export interface IcsTime {
|
|
30
32
|
y: number; m: number; d: number; hh: number; mm: number; ss: number;
|
|
31
33
|
tz: string | null;
|
|
32
34
|
dateOnly: boolean;
|
|
33
35
|
utc: boolean;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
interface VEvent {
|
|
38
|
+
export interface VEvent {
|
|
37
39
|
uid: string;
|
|
40
|
+
// native events: the source event id, carried onto every occurrence
|
|
41
|
+
ref?: string;
|
|
38
42
|
title: string;
|
|
39
43
|
location: string;
|
|
40
44
|
description: string;
|
|
@@ -254,7 +258,7 @@ function expandEvent(
|
|
|
254
258
|
const push = (s: number) => {
|
|
255
259
|
const e = allDay ? s + spanDays * DAY : s + durMs;
|
|
256
260
|
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 });
|
|
261
|
+
out.push({ start: s, end: e, allDay, days: spanDays, title: ev.title || '(untitled)', location: ev.location, description: ev.description, cal: calIdx, ...(ev.ref ? { ref: ev.ref } : {}) });
|
|
258
262
|
};
|
|
259
263
|
|
|
260
264
|
const rule = ev.rrule && !ev.recurrenceId ? parseRRule(ev.rrule) : null;
|
|
@@ -272,7 +276,13 @@ function expandEvent(
|
|
|
272
276
|
? { y: ev.start.y, m: ev.start.m, d: ev.start.d, hh: 0, mm: 0, ss: 0 }
|
|
273
277
|
: tzParts(startMs, occTz);
|
|
274
278
|
const startDay = dayNum(ref.y, ref.m, ref.d);
|
|
275
|
-
|
|
279
|
+
// a date-only UNTIL covers that whole day (Google and Apple read it the
|
|
280
|
+
// same way); a date-time UNTIL is the exact last allowed start
|
|
281
|
+
const untilMs = !rule.until
|
|
282
|
+
? Infinity
|
|
283
|
+
: rule.until.dateOnly && !ev.start.dateOnly
|
|
284
|
+
? zonedToUtc(rule.until.y, rule.until.m, rule.until.d, 23, 59, 59, occTz)
|
|
285
|
+
: timeToMs(rule.until, timedTz, displayTz);
|
|
276
286
|
const windowEndDay = Math.ceil(win.windowEndMs / DAY) + 2;
|
|
277
287
|
const weekAnchor = startDay - dowOfDay(startDay);
|
|
278
288
|
const bydayDows = rule.byday.map((b) => b.dow);
|
|
@@ -350,7 +360,7 @@ const feedCache = new Map<string, CacheEntry>();
|
|
|
350
360
|
const FEED_TTL = 5 * 60 * 1000;
|
|
351
361
|
const FEED_CACHE_MAX = 24;
|
|
352
362
|
|
|
353
|
-
async function loadFeed(url: string, now: number): Promise<
|
|
363
|
+
export async function loadFeed(url: string, now: number): Promise<{ at: number; vevents: VEvent[]; calTz: string }> {
|
|
354
364
|
const cached = feedCache.get(url);
|
|
355
365
|
if (cached && now - cached.at < FEED_TTL) return cached;
|
|
356
366
|
const got = await fetchPublicUrl(url, {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Bring events in from elsewhere: an ICS file/feed (Google, Apple, Outlook
|
|
2
|
+
// exports) or a spreadsheet CSV. Both produce plain event inputs that the
|
|
3
|
+
// import action saves as DRAFTS — nothing goes live without a review.
|
|
4
|
+
// Recurring ICS events keep their RRULE/EXDATEs; per-instance overrides
|
|
5
|
+
// (RECURRENCE-ID) are skipped, since a native event can't hold them yet.
|
|
6
|
+
import type { EventLocation } from '../schema';
|
|
7
|
+
import { parseICS, tzParts, type IcsTime, type VEvent } from './ics';
|
|
8
|
+
import { isValidTimeZone, normalizeEventTimes, parseWall, addWallDays, formatWall } from './events';
|
|
9
|
+
import { cleanRrule } from './rrule';
|
|
10
|
+
|
|
11
|
+
export interface ImportedEvent {
|
|
12
|
+
title: string;
|
|
13
|
+
start: string;
|
|
14
|
+
end: string;
|
|
15
|
+
allDay: boolean;
|
|
16
|
+
timezone: string;
|
|
17
|
+
rrule: string;
|
|
18
|
+
exdates: string[];
|
|
19
|
+
excerpt: string;
|
|
20
|
+
bodyHtml: string;
|
|
21
|
+
location: EventLocation;
|
|
22
|
+
cancelled: boolean;
|
|
23
|
+
sourceUid: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
27
|
+
const escHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
28
|
+
const paragraphs = (text: string) =>
|
|
29
|
+
text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${escHtml(p).replace(/\n/g, '<br>')}</p>`).join('');
|
|
30
|
+
|
|
31
|
+
// ICS time → wall clock in `zone`. Floating and TZID times keep their wall
|
|
32
|
+
// clock (the TZID becomes the event zone when we know it); UTC times are
|
|
33
|
+
// converted into the site zone.
|
|
34
|
+
function wallOf(t: IcsTime, siteTz: string): { wall: string; zone: string } {
|
|
35
|
+
if (t.dateOnly) return { wall: `${t.y}-${pad(t.m)}-${pad(t.d)}`, zone: '' };
|
|
36
|
+
if (t.utc) {
|
|
37
|
+
const p = tzParts(Date.UTC(t.y, t.m - 1, t.d, t.hh, t.mm, t.ss), siteTz || 'UTC');
|
|
38
|
+
return { wall: `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`, zone: '' };
|
|
39
|
+
}
|
|
40
|
+
const zone = t.tz && isValidTimeZone(t.tz) && t.tz !== siteTz ? t.tz : '';
|
|
41
|
+
return { wall: `${t.y}-${pad(t.m)}-${pad(t.d)}T${pad(t.hh)}:${pad(t.mm)}`, zone };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function eventsFromIcs(text: string, siteTz: string): { events: ImportedEvent[]; skipped: number } {
|
|
45
|
+
const { vevents, calTz } = parseICS(text);
|
|
46
|
+
return eventsFromVEvents(vevents, calTz, siteTz);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function eventsFromVEvents(vevents: VEvent[], calTz: string, siteTz: string): { events: ImportedEvent[]; skipped: number } {
|
|
50
|
+
const out: ImportedEvent[] = [];
|
|
51
|
+
let skipped = 0;
|
|
52
|
+
for (const v of vevents) {
|
|
53
|
+
if (v.recurrenceId) { skipped++; continue; }
|
|
54
|
+
const s = wallOf(v.start, siteTz);
|
|
55
|
+
// floating times in a feed with X-WR-TIMEZONE belong to that zone
|
|
56
|
+
const zone = s.zone || (!v.start.dateOnly && !v.start.utc && !v.start.tz && calTz && isValidTimeZone(calTz) && calTz !== siteTz ? calTz : '');
|
|
57
|
+
let end = '';
|
|
58
|
+
if (v.end) {
|
|
59
|
+
if (v.start.dateOnly) {
|
|
60
|
+
const w = parseWall(wallOf(v.end, siteTz).wall);
|
|
61
|
+
end = w ? formatWall(addWallDays({ ...w, dateOnly: true }, -1)) : '';
|
|
62
|
+
} else end = wallOf(v.end, siteTz).wall;
|
|
63
|
+
}
|
|
64
|
+
let times: { start: string; end: string };
|
|
65
|
+
try {
|
|
66
|
+
times = normalizeEventTimes({ start: s.wall, end, allDay: v.start.dateOnly });
|
|
67
|
+
} catch {
|
|
68
|
+
skipped++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push({
|
|
72
|
+
title: (v.title || 'Untitled event').slice(0, 300),
|
|
73
|
+
start: times.start,
|
|
74
|
+
end: times.end,
|
|
75
|
+
allDay: v.start.dateOnly,
|
|
76
|
+
timezone: zone,
|
|
77
|
+
rrule: cleanRrule(v.rrule),
|
|
78
|
+
exdates: v.exdates.map((t) => wallOf(t, siteTz).wall),
|
|
79
|
+
excerpt: v.description.slice(0, 300),
|
|
80
|
+
bodyHtml: paragraphs(v.description),
|
|
81
|
+
location: { name: v.location.slice(0, 300), address: '', url: '' },
|
|
82
|
+
cancelled: v.cancelled,
|
|
83
|
+
sourceUid: v.uid,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { events: out.slice(0, 500), skipped: skipped + Math.max(0, out.length - 500) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- CSV -------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export function parseCsv(text: string): string[][] {
|
|
92
|
+
const rows: string[][] = [];
|
|
93
|
+
let row: string[] = [];
|
|
94
|
+
let cell = '';
|
|
95
|
+
let q = false;
|
|
96
|
+
const src = text.replace(/^/, '');
|
|
97
|
+
for (let i = 0; i < src.length; i++) {
|
|
98
|
+
const c = src[i];
|
|
99
|
+
if (q) {
|
|
100
|
+
if (c === '"' && src[i + 1] === '"') { cell += '"'; i++; }
|
|
101
|
+
else if (c === '"') q = false;
|
|
102
|
+
else cell += c;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === '"') q = true;
|
|
106
|
+
else if (c === ',') { row.push(cell); cell = ''; }
|
|
107
|
+
else if (c === '\n' || c === '\r') {
|
|
108
|
+
if (c === '\r' && src[i + 1] === '\n') i++;
|
|
109
|
+
row.push(cell); cell = '';
|
|
110
|
+
if (row.some((x) => x.trim())) rows.push(row);
|
|
111
|
+
row = [];
|
|
112
|
+
} else cell += c;
|
|
113
|
+
}
|
|
114
|
+
row.push(cell);
|
|
115
|
+
if (row.some((x) => x.trim())) rows.push(row);
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2026-10-03 · 10/3/2026 · 3 Oct 2026 → YYYY-MM-DD
|
|
120
|
+
function csvDate(raw: string): string {
|
|
121
|
+
const s = raw.trim();
|
|
122
|
+
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s);
|
|
123
|
+
if (m) return `${m[1]}-${pad(+m[2])}-${pad(+m[3])}`;
|
|
124
|
+
m = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/.exec(s);
|
|
125
|
+
if (m) return `${m[3].length === 2 ? `20${m[3]}` : m[3]}-${pad(+m[1])}-${pad(+m[2])}`;
|
|
126
|
+
const t = Date.parse(s);
|
|
127
|
+
if (!Number.isNaN(t)) {
|
|
128
|
+
const d = new Date(t);
|
|
129
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
130
|
+
}
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 19:00 · 7pm · 7:30 PM → HH:mm
|
|
135
|
+
function csvTime(raw: string): string {
|
|
136
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?$/i.exec(raw.trim());
|
|
137
|
+
if (!m) return '';
|
|
138
|
+
let h = +m[1];
|
|
139
|
+
const min = m[2] ? +m[2] : 0;
|
|
140
|
+
if (m[3]) {
|
|
141
|
+
const pm = m[3].toLowerCase().startsWith('p');
|
|
142
|
+
if (h === 12) h = pm ? 12 : 0;
|
|
143
|
+
else if (pm) h += 12;
|
|
144
|
+
}
|
|
145
|
+
return h <= 23 && min <= 59 ? `${pad(h)}:${pad(min)}` : '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const HEADERS: Record<string, string[]> = {
|
|
149
|
+
title: ['title', 'name', 'summary', 'event', 'subject'],
|
|
150
|
+
startDate: ['start date', 'start', 'date', 'starts', 'start_date'],
|
|
151
|
+
startTime: ['start time', 'time', 'start_time'],
|
|
152
|
+
endDate: ['end date', 'end', 'ends', 'end_date'],
|
|
153
|
+
endTime: ['end time', 'end_time'],
|
|
154
|
+
allDay: ['all day', 'all-day', 'allday', 'all day event'],
|
|
155
|
+
location: ['location', 'venue', 'place', 'where'],
|
|
156
|
+
address: ['address'],
|
|
157
|
+
description: ['description', 'details', 'notes', 'about'],
|
|
158
|
+
url: ['url', 'link', 'website'],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export function eventsFromCsv(text: string): { events: ImportedEvent[]; skipped: number; error?: string } {
|
|
162
|
+
const rows = parseCsv(text);
|
|
163
|
+
if (rows.length < 2) return { events: [], skipped: 0, error: 'The CSV needs a header row and at least one event.' };
|
|
164
|
+
const head = rows[0].map((h) => h.trim().toLowerCase());
|
|
165
|
+
const col = (key: string) => head.findIndex((h) => HEADERS[key].includes(h));
|
|
166
|
+
const idx = Object.fromEntries(Object.keys(HEADERS).map((k) => [k, col(k)])) as Record<string, number>;
|
|
167
|
+
if (idx.title < 0 || idx.startDate < 0) {
|
|
168
|
+
return { events: [], skipped: 0, error: 'The CSV needs at least a "Title" and a "Start date" column.' };
|
|
169
|
+
}
|
|
170
|
+
const get = (r: string[], k: string) => (idx[k] >= 0 ? (r[idx[k]] ?? '').trim() : '');
|
|
171
|
+
const events: ImportedEvent[] = [];
|
|
172
|
+
let skipped = 0;
|
|
173
|
+
for (const r of rows.slice(1, 501)) {
|
|
174
|
+
const title = get(r, 'title');
|
|
175
|
+
// "Start" columns sometimes hold a full datetime: split it
|
|
176
|
+
const rawStart = get(r, 'startDate');
|
|
177
|
+
const split = /^(.+?)[ T](\d{1,2}(?::\d{2})?\s*(?:[ap]\.?m\.?)?)$/i.exec(rawStart);
|
|
178
|
+
const date = csvDate(split ? split[1] : rawStart);
|
|
179
|
+
const time = csvTime(get(r, 'startTime') || (split ? split[2] : ''));
|
|
180
|
+
if (!title || !date) { skipped++; continue; }
|
|
181
|
+
const allDayCell = get(r, 'allDay').toLowerCase();
|
|
182
|
+
const allDay = ['true', 'yes', 'y', '1', 'x'].includes(allDayCell) || !time;
|
|
183
|
+
const endDate = csvDate(get(r, 'endDate')) || date;
|
|
184
|
+
const endTime = csvTime(get(r, 'endTime'));
|
|
185
|
+
try {
|
|
186
|
+
const times = normalizeEventTimes({
|
|
187
|
+
start: allDay ? date : `${date}T${time}`,
|
|
188
|
+
end: allDay ? endDate : endTime ? `${endDate}T${endTime}` : '',
|
|
189
|
+
allDay,
|
|
190
|
+
});
|
|
191
|
+
const desc = get(r, 'description');
|
|
192
|
+
events.push({
|
|
193
|
+
title: title.slice(0, 300), ...times, allDay, timezone: '', rrule: '', exdates: [],
|
|
194
|
+
excerpt: desc.slice(0, 300), bodyHtml: paragraphs(desc),
|
|
195
|
+
location: { name: get(r, 'location').slice(0, 300), address: get(r, 'address').slice(0, 300), url: get(r, 'url').slice(0, 500) },
|
|
196
|
+
cancelled: false, sourceUid: '',
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
skipped++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { events, skipped: skipped + Math.max(0, rows.length - 501) };
|
|
203
|
+
}
|
|
@@ -6,9 +6,18 @@
|
|
|
6
6
|
// site. Every element carries a stable .pc-cal-* class: that's the widget's
|
|
7
7
|
// public styling API (theme CSS and Settings → Code injection both restyle it).
|
|
8
8
|
import type { Settings } from '../schema';
|
|
9
|
+
import type { ContentRepository } from '../repo/types';
|
|
9
10
|
import { siteLocale } from '../dates';
|
|
10
|
-
import { getCalendarEvents, tzParts, zonedToUtc
|
|
11
|
+
import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
|
|
11
12
|
import { calendarPageUrl } from './src';
|
|
13
|
+
import { collectViewEvents, type ViewEvent } from './data';
|
|
14
|
+
|
|
15
|
+
// Card sources are ICS feed URLs or native site calendars:
|
|
16
|
+
// 'acms:all' every public calendar (+ uncategorized events)
|
|
17
|
+
// 'acms:cal:<slug>' one site calendar (public or unlisted)
|
|
18
|
+
export const NATIVE_ALL = 'acms:all';
|
|
19
|
+
export const nativeCalendarSource = (slug: string) => `acms:cal:${slug}`;
|
|
20
|
+
export const isNativeSource = (s: string) => s === NATIVE_ALL || /^acms:cal:[a-z0-9-]+$/.test(s);
|
|
12
21
|
|
|
13
22
|
export interface CalendarBlockAttrs {
|
|
14
23
|
sources: string[];
|
|
@@ -45,13 +54,16 @@ interface DisplayEvent {
|
|
|
45
54
|
l: string;
|
|
46
55
|
ds: string;
|
|
47
56
|
c: number; // source index (dot color)
|
|
57
|
+
u?: string; // native events: the event page
|
|
58
|
+
k?: string; // calendar color (#rrggbb), overrides the indexed dot color
|
|
59
|
+
x?: 1; // cancelled
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
function compactTime(s: string): string {
|
|
51
63
|
return s.replace(':00', '').replace(/\s?(AM|PM)/, (_, p: string) => p.toLowerCase());
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
function buildDisplay(events:
|
|
66
|
+
function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
|
|
55
67
|
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
56
68
|
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
57
69
|
return events.map((e) => {
|
|
@@ -68,6 +80,9 @@ function buildDisplay(events: CalendarEvent[], tz: string, locale: string): Disp
|
|
|
68
80
|
return {
|
|
69
81
|
d: [p.y, p.m, p.d], n: e.days, t: e.title, tm, rg,
|
|
70
82
|
l: e.location, ds: e.description.slice(0, 300), c: e.cal,
|
|
83
|
+
...(e.url ? { u: e.url } : {}),
|
|
84
|
+
...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
|
|
85
|
+
...(e.cancelled ? { x: 1 as const } : {}),
|
|
71
86
|
};
|
|
72
87
|
});
|
|
73
88
|
}
|
|
@@ -86,8 +101,9 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
|
|
|
86
101
|
|
|
87
102
|
/* ---- view fragments (client script mirrors these exactly) ---- */
|
|
88
103
|
|
|
104
|
+
const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
|
|
89
105
|
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>`;
|
|
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>`;
|
|
91
107
|
|
|
92
108
|
function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
|
|
93
109
|
const first = dayNum(y, m, 1);
|
|
@@ -119,7 +135,7 @@ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): s
|
|
|
119
135
|
if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
|
|
120
136
|
const items = order.slice(0, limit).map((i) => {
|
|
121
137
|
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>`;
|
|
138
|
+
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) || 'All day'}</span></button></li>`;
|
|
123
139
|
});
|
|
124
140
|
return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
|
|
125
141
|
}
|
|
@@ -136,7 +152,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
|
|
|
136
152
|
function dow(n){return((n+4)%7+7)%7}
|
|
137
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)}});
|
|
138
154
|
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>'}
|
|
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>'}
|
|
140
156
|
function chips(n){return(byDay[n]||[]).map(chip).join('')}
|
|
141
157
|
var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
|
|
142
158
|
function renderMonth(){
|
|
@@ -172,7 +188,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
|
|
|
172
188
|
var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
|
|
173
189
|
closePop();
|
|
174
190
|
pop=document.createElement('div');pop.className='pc-cal-pop';
|
|
175
|
-
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">×</button
|
|
191
|
+
pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">×</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<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>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' →</a>':'');
|
|
176
192
|
frame.appendChild(pop);
|
|
177
193
|
var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
|
|
178
194
|
pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
|
|
@@ -182,87 +198,103 @@ document.addEventListener('keydown',function(e){if(e.key==='Escape')closePop()})
|
|
|
182
198
|
document.addEventListener('click',function(e){if(pop&&e.target instanceof Node&&!root.contains(e.target))closePop()});
|
|
183
199
|
})();`;
|
|
184
200
|
|
|
185
|
-
/* ---- entry
|
|
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>` : '');
|
|
201
|
+
/* ---- entry points ---- */
|
|
191
202
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
203
|
+
export interface CalendarViewOptions {
|
|
204
|
+
view: 'month' | 'week' | 'upcoming';
|
|
205
|
+
title?: string;
|
|
206
|
+
subtitle?: string;
|
|
207
|
+
limit?: number;
|
|
208
|
+
// quiet footer link (the ICS card's "Open in Google Calendar ↗")
|
|
209
|
+
footer?: { href: string; label: string };
|
|
210
|
+
// show per-calendar dots (only meaningful with 2+ calendars)
|
|
211
|
+
multi?: boolean;
|
|
212
|
+
emptyText?: string;
|
|
213
|
+
// month/week views open on this local month instead of today's
|
|
214
|
+
focus?: { y: number; m: number };
|
|
215
|
+
}
|
|
195
216
|
|
|
217
|
+
export function calendarWindow(settings: Pick<Settings, 'timezone'>, now: number) {
|
|
196
218
|
const tz = settings.timezone || 'UTC';
|
|
197
|
-
const locale = siteLocale(settings);
|
|
198
219
|
const today = tzParts(now, tz);
|
|
199
220
|
const [minY, minM] = addMonths(today.y, today.m, -6);
|
|
200
|
-
const [maxY, maxM] = addMonths(today.y, today.m, 6);
|
|
201
221
|
const [endY, endM] = addMonths(today.y, today.m, 7);
|
|
202
|
-
|
|
222
|
+
return {
|
|
203
223
|
displayTz: tz, now,
|
|
204
224
|
windowStartMs: zonedToUtc(minY, minM, 1, 0, 0, 0, tz),
|
|
205
225
|
windowEndMs: zonedToUtc(endY, endM, 1, 0, 0, 0, tz),
|
|
206
226
|
};
|
|
207
|
-
|
|
227
|
+
}
|
|
208
228
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
229
|
+
// Pure renderer: occurrences in, markup out. Used by the calendar card, the
|
|
230
|
+
// /calendar page and embeds, so all three share one look and one script.
|
|
231
|
+
export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: number, opts: CalendarViewOptions): string {
|
|
232
|
+
const heading =
|
|
233
|
+
(opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
|
|
234
|
+
(opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
|
|
235
|
+
const tz = settings.timezone || 'UTC';
|
|
236
|
+
const locale = siteLocale(settings);
|
|
237
|
+
const today = tzParts(now, tz);
|
|
238
|
+
const [minY, minM] = addMonths(today.y, today.m, -6);
|
|
239
|
+
const [maxY, maxM] = addMonths(today.y, today.m, 6);
|
|
212
240
|
|
|
213
241
|
const events = buildDisplay(raw, tz, locale);
|
|
214
242
|
const byDay = indexByDay(events);
|
|
215
243
|
const todayN = dayNum(today.y, today.m, today.d);
|
|
216
244
|
|
|
217
|
-
const utcLabel = (
|
|
218
|
-
new Intl.DateTimeFormat(locale, { ...
|
|
245
|
+
const utcLabel = (o: Intl.DateTimeFormatOptions, ms: number) =>
|
|
246
|
+
new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(ms);
|
|
219
247
|
const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
|
|
220
248
|
const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
|
|
221
249
|
const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
|
|
222
250
|
|
|
251
|
+
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
|
+
const [fy, fm] = focusOk ? [opts.focus!.y, opts.focus!.m] : [today.y, today.m];
|
|
253
|
+
|
|
223
254
|
let bodyHtml: string;
|
|
224
255
|
let titleText: string;
|
|
225
|
-
if (
|
|
256
|
+
if (opts.view === 'week') {
|
|
226
257
|
const weekStart = todayN - dowOfDay(todayN);
|
|
227
258
|
const wa = fromDayNum(weekStart);
|
|
228
259
|
const wb = fromDayNum(weekStart + 6);
|
|
229
260
|
titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
|
|
230
261
|
bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
|
|
231
|
-
} else if (
|
|
262
|
+
} else if (opts.view === 'upcoming') {
|
|
232
263
|
titleText = 'Upcoming';
|
|
233
264
|
const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
|
|
234
265
|
const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
|
|
235
|
-
bodyHtml = upcomingHtml(events, order, Math.min(
|
|
266
|
+
bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
|
|
267
|
+
if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
|
|
236
268
|
} else {
|
|
237
|
-
titleText = `${months[
|
|
238
|
-
bodyHtml = monthGridHtml(
|
|
269
|
+
titleText = `${months[fm - 1]} ${fy}`;
|
|
270
|
+
bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort);
|
|
239
271
|
}
|
|
240
272
|
|
|
241
273
|
const navButtons =
|
|
242
|
-
|
|
274
|
+
opts.view === 'upcoming'
|
|
243
275
|
? ''
|
|
244
276
|
: `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
|
|
245
277
|
`<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
|
|
246
278
|
`<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
|
|
247
279
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(pageUrl)}" target="_blank" rel="noopener">Open in Google Calendar ↗</a></div>`
|
|
280
|
+
const footer = opts.footer
|
|
281
|
+
? `<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>`
|
|
251
282
|
: '';
|
|
252
283
|
|
|
253
284
|
const payload = {
|
|
254
|
-
view:
|
|
285
|
+
view: opts.view,
|
|
255
286
|
today: [today.y, today.m, today.d],
|
|
256
|
-
month: [
|
|
287
|
+
month: [fy, fm],
|
|
257
288
|
min: [minY, minM],
|
|
258
289
|
max: [maxY, maxM],
|
|
259
290
|
months, monthsShort, dows, events,
|
|
291
|
+
details: 'Details', cancelled: 'Cancelled',
|
|
260
292
|
};
|
|
261
293
|
const json = JSON.stringify(payload).replace(/</g, '\\u003c');
|
|
262
294
|
|
|
263
|
-
const multi =
|
|
295
|
+
const multi = opts.multi ? ' is-multi' : '';
|
|
264
296
|
return (
|
|
265
|
-
`<section class="pc-calendar pc-cal-view-${
|
|
297
|
+
`<section class="pc-calendar pc-cal-view-${opts.view}${multi}">${heading}` +
|
|
266
298
|
`<div class="pc-cal-frame">` +
|
|
267
299
|
`<div class="pc-cal-toolbar"><span class="pc-cal-title">${esc(titleText)}</span>${navButtons}</div>` +
|
|
268
300
|
`<div class="pc-cal-body">${bodyHtml}</div>` +
|
|
@@ -273,3 +305,54 @@ export async function renderCalendarBlock(a: CalendarBlockAttrs, settings: Setti
|
|
|
273
305
|
`</section>`
|
|
274
306
|
);
|
|
275
307
|
}
|
|
308
|
+
|
|
309
|
+
export async function renderCalendarBlock(
|
|
310
|
+
a: CalendarBlockAttrs,
|
|
311
|
+
settings: Settings,
|
|
312
|
+
now: number,
|
|
313
|
+
repo?: ContentRepository
|
|
314
|
+
): Promise<string> {
|
|
315
|
+
const heading =
|
|
316
|
+
(a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
|
|
317
|
+
(a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
|
|
318
|
+
|
|
319
|
+
const feeds = a.sources.filter((s) => s.startsWith('http'));
|
|
320
|
+
const natives = repo ? a.sources.filter(isNativeSource) : [];
|
|
321
|
+
if (!feeds.length && !natives.length) {
|
|
322
|
+
return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const win = calendarWindow(settings, now);
|
|
326
|
+
const { events: feedEvents, errors } = feeds.length ? await getCalendarEvents(feeds, win) : { events: [], errors: [] as string[] };
|
|
327
|
+
let native: ViewEvent[] = [];
|
|
328
|
+
let nativeCalendars = 0;
|
|
329
|
+
if (natives.length && repo) {
|
|
330
|
+
const all = natives.includes(NATIVE_ALL);
|
|
331
|
+
const slugs = natives.filter((s) => s !== NATIVE_ALL).map((s) => s.slice('acms:cal:'.length));
|
|
332
|
+
const cals = all ? [] : (await repo.listCalendars()).filter((c) => slugs.includes(c.slug));
|
|
333
|
+
const got = await collectViewEvents(repo, settings, win, {
|
|
334
|
+
audience: 'public',
|
|
335
|
+
...(all ? {} : { calendarIds: cals.map((c) => c.id) }),
|
|
336
|
+
});
|
|
337
|
+
// native calendars get their own colors; shift indexes past the feeds
|
|
338
|
+
native = got.events.map((e) => ({ ...e, cal: e.cal + feeds.length }));
|
|
339
|
+
nativeCalendars = got.calendars.length;
|
|
340
|
+
errors.push(...got.errors);
|
|
341
|
+
}
|
|
342
|
+
const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
|
|
343
|
+
|
|
344
|
+
if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
|
|
345
|
+
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>`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
|
|
349
|
+
return renderCalendarView(raw, settings, now, {
|
|
350
|
+
view: a.view,
|
|
351
|
+
title: a.title,
|
|
352
|
+
subtitle: a.subtitle,
|
|
353
|
+
limit: a.limit,
|
|
354
|
+
footer: pageUrl ? { href: pageUrl, label: 'Open in Google Calendar ↗' } : undefined,
|
|
355
|
+
// dots only mean something with 2+ calendars
|
|
356
|
+
multi: feeds.length + nativeCalendars > 1,
|
|
357
|
+
});
|
|
358
|
+
}
|