/** * Calendar service — fetches and parses iCal (.ics) files. * Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies). */ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes const FETCH_TIMEOUT_MS = 10_000; const DEFAULT_DAYS_AHEAD = 14; interface CacheEntry { readonly data: string; // raw ical text readonly expiresAt: number; } export interface CalendarEvent { readonly summary: string; readonly start: string; readonly end: string; readonly location: string | null; readonly calendarLabel: string; readonly calendarColor: string; } export interface CalendarSource { readonly url: string; readonly color?: string; readonly label?: string; } const cache = new Map(); function getCached(key: string): string | null { const entry = cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { cache.delete(key); return null; } return entry.data; } function setCache(key: string, data: string): void { cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); } /** * Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss). */ function parseIcalDate(dateStr: string): Date | null { if (!dateStr) return null; // Remove TZID parameter prefix if present const clean = dateStr.replace(/^.*:/, '').trim(); // All-day event: YYYYMMDD if (/^\d{8}$/.test(clean)) { const year = parseInt(clean.substring(0, 4), 10); const month = parseInt(clean.substring(4, 6), 10) - 1; const day = parseInt(clean.substring(6, 8), 10); return new Date(year, month, day); } // DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/); if (dtMatch) { const [, year, month, day, hour, minute, second, utc] = dtMatch; if (utc === 'Z') { return new Date( Date.UTC( parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10) ) ); } return new Date( parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10) ); } return null; } /** * Extract a property value from an iCal VEVENT block. * Handles folded lines (continuation lines starting with space/tab). */ function extractProperty(block: string, property: string): string { // Match property with optional parameters (e.g., DTSTART;TZID=...:value) const regex = new RegExp(`^${property}[;:](.*)$`, 'im'); const match = block.match(regex); if (!match) return ''; const value = match[1]; // If the property had parameters (;PARAM=value:actualValue), extract just the value if (property === 'DTSTART' || property === 'DTEND') { // Keep the full string — parseIcalDate handles TZID prefix return value.trim(); } return value.trim(); } /** * Parse VEVENT blocks from iCal text. */ function parseVEvents( icalText: string ): Array<{ summary: string; start: string; end: string; location: string }> { const events: Array<{ summary: string; start: string; end: string; location: string }> = []; // Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations) const unfolded = icalText.replace(/\r?\n[ \t]/g, ''); const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi; let match: RegExpExecArray | null; while ((match = eventRegex.exec(unfolded)) !== null) { const block = match[1]; const summary = extractProperty(block, 'SUMMARY'); const dtStart = extractProperty(block, 'DTSTART'); const dtEnd = extractProperty(block, 'DTEND'); const location = extractProperty(block, 'LOCATION'); const startDate = parseIcalDate(dtStart); if (!startDate) continue; const endDate = parseIcalDate(dtEnd); events.push({ summary: summary || 'Untitled Event', start: startDate.toISOString(), end: endDate ? endDate.toISOString() : startDate.toISOString(), location: location || '' }); } return events; } /** * Fetch iCal text from a URL. */ async function fetchIcalText(url: string): Promise { const cached = getCached(url); if (cached) return cached; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'WebAppLauncher/1.0', Accept: 'text/calendar, application/ics' } }); if (!response.ok) { throw new Error(`Calendar source returned ${response.status}`); } const text = await response.text(); setCache(url, text); return text; } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('Calendar request timed out'); } throw err; } finally { clearTimeout(timeoutId); } } /** * Fetch and parse events from multiple iCal URLs, merged and sorted by start time. */ export async function fetchCalendarEvents( sources: readonly CalendarSource[], daysAhead?: number ): Promise { const days = daysAhead ?? DEFAULT_DAYS_AHEAD; const now = new Date(); const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000); const allEvents: CalendarEvent[] = []; const results = await Promise.allSettled( sources.map(async (source) => { const icalText = await fetchIcalText(source.url); const events = parseVEvents(icalText); return events .filter((event) => { const start = new Date(event.start); return start >= now && start <= cutoff; }) .map((event) => ({ summary: event.summary, start: event.start, end: event.end, location: event.location || null, calendarLabel: source.label ?? 'Calendar', calendarColor: source.color ?? '#6366f1' })); }) ); for (const result of results) { if (result.status === 'fulfilled') { allEvents.push(...result.value); } } // Sort by start time ascending return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); } /** * Clear the calendar cache. */ export function clearCache(): void { cache.clear(); }