your personal website on atproto - mirror blento.app
at update-link-card 215 lines 5.9 kB view raw
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}