Something went wrong. Try again.
your personal website on atproto - mirror blento.app
Something went wrong. Try again.
TypeScript
at v2-refactor
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216import type { EventData } from '$lib/cards/social/EventCard';
/** * Escape text for iCal fields (RFC 5545 Section 3.3.11). * Backslashes, semicolons, commas, and newlines must be escaped. */function escapeText(text: string): string { return text .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\n/g, '\\n');}
/** * Fold long lines per RFC 5545 (max 75 octets per line). * Continuation lines start with a single space. */function foldLine(line: string): string { const maxLen = 75; if (line.length <= maxLen) return line;
const parts: string[] = []; parts.push(line.slice(0, maxLen)); let i = maxLen; while (i < line.length) { parts.push(' ' + line.slice(i, i + maxLen - 1)); i += maxLen - 1; } return parts.join('\r\n');}
/** * Convert an ISO 8601 date string to iCal DATETIME format (UTC). * e.g. "2026-02-22T15:00:00Z" -> "20260222T150000Z" */function toICalDate(isoString: string): string { const d = new Date(isoString); const pad = (n: number) => n.toString().padStart(2, '0'); return ( d.getUTCFullYear().toString() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + 'T' + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + 'Z' );}
/** * Extract a location string from event locations array. */function getLocationString(locations: EventData['locations']): string | undefined { if (!locations || locations.length === 0) return undefined;
const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); if (!loc) return undefined;
const flat = loc as Record<string, unknown>; const nested = loc.address;
const street = (flat.street as string) || undefined; const locality = (flat.locality as string) || nested?.locality; const region = (flat.region as string) || nested?.region;
const parts = [street, locality, region].filter(Boolean); return parts.length > 0 ? parts.join(', ') : undefined;}
function getModeLabel(mode: string): string { if (mode.includes('virtual')) return 'Virtual'; if (mode.includes('hybrid')) return 'Hybrid'; if (mode.includes('inperson')) return 'In-Person'; return 'Event';}
export interface ICalAttendee { name: string; status: 'going' | 'interested'; url?: string;}
export interface ICalEvent { eventData: EventData; uid: string; url?: string; organizer?: string; imageUrl?: string; attendees?: ICalAttendee[];}
/** * Generate a single VEVENT block. */function generateVEvent(event: ICalEvent): string | null { const { eventData, uid, url, organizer, imageUrl } = event;
// Skip events with invalid or missing start dates const startTime = new Date(eventData.startsAt); if (isNaN(startTime.getTime())) return null;
const lines: string[] = [];
lines.push('BEGIN:VEVENT'); lines.push(`UID:${escapeText(uid)}`); lines.push(`DTSTART:${toICalDate(eventData.startsAt)}`);
if (eventData.endsAt) { lines.push(`DTEND:${toICalDate(eventData.endsAt)}`); } else { // Default to 1 hour duration when no end time is specified const defaultEnd = new Date(startTime.getTime() + 60 * 60 * 1000); lines.push(`DTEND:${toICalDate(defaultEnd.toISOString())}`); }
lines.push(`SUMMARY:${escapeText(eventData.name)}`);
// Description: text + links const descParts: string[] = []; if (eventData.description) { descParts.push(eventData.description); } if (eventData.uris && eventData.uris.length > 0) { descParts.push(''); descParts.push('Links:'); for (const link of eventData.uris) { descParts.push(link.name ? `${link.name}: ${link.uri}` : link.uri); } } if (url) { descParts.push(''); descParts.push(`Event page: ${url}`); } if (descParts.length > 0) { lines.push(`DESCRIPTION:${escapeText(descParts.join('\n'))}`); }
const location = getLocationString(eventData.locations); if (location) { lines.push(`LOCATION:${escapeText(location)}`); }
if (url) { lines.push(`URL:${url}`); }
// Categories from event mode if (eventData.mode) { lines.push(`CATEGORIES:${escapeText(getModeLabel(eventData.mode))}`); }
// Organizer if (organizer) { lines.push( `ORGANIZER;CN=${escapeText(organizer)}:https://bsky.app/profile/${encodeURIComponent(organizer)}` ); }
// Attendees if (event.attendees) { for (const attendee of event.attendees) { const partstat = attendee.status === 'going' ? 'ACCEPTED' : 'TENTATIVE'; lines.push( `ATTENDEE;CN=${escapeText(attendee.name)};PARTSTAT=${partstat}:${attendee.url || `https://bsky.app/profile/${encodeURIComponent(attendee.name)}`}` ); } }
// Image (supported by Apple Calendar, Google Calendar) if (imageUrl) { lines.push(`IMAGE;VALUE=URI;DISPLAY=BADGE:${imageUrl}`); }
lines.push(`DTSTAMP:${toICalDate(new Date().toISOString())}`);
// Reminder 15 minutes before lines.push('BEGIN:VALARM'); lines.push('TRIGGER:-PT15M'); lines.push('ACTION:DISPLAY'); lines.push(`DESCRIPTION:${escapeText(eventData.name)}`); lines.push('END:VALARM');
lines.push('END:VEVENT');
return lines.map(foldLine).join('\r\n');}
/** * Generate a complete iCal feed from multiple events. */export function generateICalFeed(events: ICalEvent[], calendarName: string): string { const lines: string[] = [];
lines.push('BEGIN:VCALENDAR'); lines.push('VERSION:2.0'); lines.push('PRODID:-//Blento//Events//EN'); lines.push(`X-WR-CALNAME:${escapeText(calendarName)}`); lines.push('CALSCALE:GREGORIAN'); lines.push('METHOD:PUBLISH');
const vevents = events.map(generateVEvent).filter((v): v is string => v !== null);
const result = lines.map(foldLine).join('\r\n') + '\r\n' + vevents.join('\r\n') + '\r\nEND:VCALENDAR\r\n'; return result;}
/** * Generate iCal content for a single event (for client-side download). */export function generateICalEvent(eventData: EventData, atUri: string, eventUrl?: string): string { return generateICalFeed([{ eventData, uid: atUri, url: eventUrl }], eventData.name);}