your personal website on atproto - mirror
blento.app
1import type { EventData } from '$lib/cards/social/EventCard';
2
3/**
4 * Escape text for iCal fields (RFC 5545 Section 3.3.11).
5 * Backslashes, semicolons, commas, and newlines must be escaped.
6 */
7function escapeText(text: string): string {
8 return text
9 .replace(/\\/g, '\\\\')
10 .replace(/;/g, '\\;')
11 .replace(/,/g, '\\,')
12 .replace(/\n/g, '\\n');
13}
14
15/**
16 * Fold long lines per RFC 5545 (max 75 octets per line).
17 * Continuation lines start with a single space.
18 */
19function foldLine(line: string): string {
20 const maxLen = 75;
21 if (line.length <= maxLen) return line;
22
23 const parts: string[] = [];
24 parts.push(line.slice(0, maxLen));
25 let i = maxLen;
26 while (i < line.length) {
27 parts.push(' ' + line.slice(i, i + maxLen - 1));
28 i += maxLen - 1;
29 }
30 return parts.join('\r\n');
31}
32
33/**
34 * Convert an ISO 8601 date string to iCal DATETIME format (UTC).
35 * e.g. "2026-02-22T15:00:00Z" -> "20260222T150000Z"
36 */
37function toICalDate(isoString: string): string {
38 const d = new Date(isoString);
39 const pad = (n: number) => n.toString().padStart(2, '0');
40 return (
41 d.getUTCFullYear().toString() +
42 pad(d.getUTCMonth() + 1) +
43 pad(d.getUTCDate()) +
44 'T' +
45 pad(d.getUTCHours()) +
46 pad(d.getUTCMinutes()) +
47 pad(d.getUTCSeconds()) +
48 'Z'
49 );
50}
51
52/**
53 * Extract a location string from event locations array.
54 */
55function getLocationString(locations: EventData['locations']): string | undefined {
56 if (!locations || locations.length === 0) return undefined;
57
58 const loc = locations.find((v) => v.$type === 'community.lexicon.location.address');
59 if (!loc) return undefined;
60
61 const flat = loc as Record<string, unknown>;
62 const nested = loc.address;
63
64 const street = (flat.street as string) || undefined;
65 const locality = (flat.locality as string) || nested?.locality;
66 const region = (flat.region as string) || nested?.region;
67
68 const parts = [street, locality, region].filter(Boolean);
69 return parts.length > 0 ? parts.join(', ') : undefined;
70}
71
72function getModeLabel(mode: string): string {
73 if (mode.includes('virtual')) return 'Virtual';
74 if (mode.includes('hybrid')) return 'Hybrid';
75 if (mode.includes('inperson')) return 'In-Person';
76 return 'Event';
77}
78
79export interface ICalAttendee {
80 name: string;
81 status: 'going' | 'interested';
82 url?: string;
83}
84
85export interface ICalEvent {
86 eventData: EventData;
87 uid: string;
88 url?: string;
89 organizer?: string;
90 imageUrl?: string;
91 attendees?: ICalAttendee[];
92}
93
94/**
95 * Generate a single VEVENT block.
96 */
97function generateVEvent(event: ICalEvent): string | null {
98 const { eventData, uid, url, organizer, imageUrl } = event;
99
100 // Skip events with invalid or missing start dates
101 const startTime = new Date(eventData.startsAt);
102 if (isNaN(startTime.getTime())) return null;
103
104 const lines: string[] = [];
105
106 lines.push('BEGIN:VEVENT');
107 lines.push(`UID:${escapeText(uid)}`);
108 lines.push(`DTSTART:${toICalDate(eventData.startsAt)}`);
109
110 if (eventData.endsAt) {
111 lines.push(`DTEND:${toICalDate(eventData.endsAt)}`);
112 } else {
113 // Default to 1 hour duration when no end time is specified
114 const defaultEnd = new Date(startTime.getTime() + 60 * 60 * 1000);
115 lines.push(`DTEND:${toICalDate(defaultEnd.toISOString())}`);
116 }
117
118 lines.push(`SUMMARY:${escapeText(eventData.name)}`);
119
120 // Description: text + links
121 const descParts: string[] = [];
122 if (eventData.description) {
123 descParts.push(eventData.description);
124 }
125 if (eventData.uris && eventData.uris.length > 0) {
126 descParts.push('');
127 descParts.push('Links:');
128 for (const link of eventData.uris) {
129 descParts.push(link.name ? `${link.name}: ${link.uri}` : link.uri);
130 }
131 }
132 if (url) {
133 descParts.push('');
134 descParts.push(`Event page: ${url}`);
135 }
136 if (descParts.length > 0) {
137 lines.push(`DESCRIPTION:${escapeText(descParts.join('\n'))}`);
138 }
139
140 const location = getLocationString(eventData.locations);
141 if (location) {
142 lines.push(`LOCATION:${escapeText(location)}`);
143 }
144
145 if (url) {
146 lines.push(`URL:${url}`);
147 }
148
149 // Categories from event mode
150 if (eventData.mode) {
151 lines.push(`CATEGORIES:${escapeText(getModeLabel(eventData.mode))}`);
152 }
153
154 // Organizer
155 if (organizer) {
156 lines.push(
157 `ORGANIZER;CN=${escapeText(organizer)}:https://bsky.app/profile/${encodeURIComponent(organizer)}`
158 );
159 }
160
161 // Attendees
162 if (event.attendees) {
163 for (const attendee of event.attendees) {
164 const partstat = attendee.status === 'going' ? 'ACCEPTED' : 'TENTATIVE';
165 lines.push(
166 `ATTENDEE;CN=${escapeText(attendee.name)};PARTSTAT=${partstat}:${attendee.url || `https://bsky.app/profile/${encodeURIComponent(attendee.name)}`}`
167 );
168 }
169 }
170
171 // Image (supported by Apple Calendar, Google Calendar)
172 if (imageUrl) {
173 lines.push(`IMAGE;VALUE=URI;DISPLAY=BADGE:${imageUrl}`);
174 }
175
176 lines.push(`DTSTAMP:${toICalDate(new Date().toISOString())}`);
177
178 // Reminder 15 minutes before
179 lines.push('BEGIN:VALARM');
180 lines.push('TRIGGER:-PT15M');
181 lines.push('ACTION:DISPLAY');
182 lines.push(`DESCRIPTION:${escapeText(eventData.name)}`);
183 lines.push('END:VALARM');
184
185 lines.push('END:VEVENT');
186
187 return lines.map(foldLine).join('\r\n');
188}
189
190/**
191 * Generate a complete iCal feed from multiple events.
192 */
193export function generateICalFeed(events: ICalEvent[], calendarName: string): string {
194 const lines: string[] = [];
195
196 lines.push('BEGIN:VCALENDAR');
197 lines.push('VERSION:2.0');
198 lines.push('PRODID:-//Blento//Events//EN');
199 lines.push(`X-WR-CALNAME:${escapeText(calendarName)}`);
200 lines.push('CALSCALE:GREGORIAN');
201 lines.push('METHOD:PUBLISH');
202
203 const vevents = events.map(generateVEvent).filter((v): v is string => v !== null);
204
205 const result =
206 lines.map(foldLine).join('\r\n') + '\r\n' + vevents.join('\r\n') + '\r\nEND:VCALENDAR\r\n';
207 return result;
208}
209
210/**
211 * Generate iCal content for a single event (for client-side download).
212 */
213export function generateICalEvent(eventData: EventData, atUri: string, eventUrl?: string): string {
214 return generateICalFeed([{ eventData, uid: atUri, url: eventUrl }], eventData.name);
215}