// ── Calendar .ics Import ────────────────────────────────────── // Parse iCalendar (.ics) files and import events as timers import type { Timer } from './timer-engine'; import type { UrgencyLevel } from './urgency'; import type { CascadeConfig } from './cascade'; import { v4 as uuidv4 } from 'uuid'; import { calculateCascadeWarnings, getCascadeIntervals } from './cascade'; // ── Types ───────────────────────────────────────────────────── export interface IcsEvent { uid: string; summary: string; description?: string; dtstart: Date; dtend?: Date; location?: string; priority?: number; // 1-9, lower = higher priority } export interface ImportedEvent { event: IcsEvent; timer: Timer; conflicts: string[]; // IDs of existing timers that overlap } export interface CalendarImportResult { events: ImportedEvent[]; errors: string[]; totalParsed: number; } // ── .ics Parser ─────────────────────────────────────────────── /** * Parse an .ics file content into events. */ export function parseIcs(content: string): { events: IcsEvent[]; errors: string[] } { const events: IcsEvent[] = []; const errors: string[] = []; // Unfold lines (RFC 5545: continuation lines start with space/tab) const unfolded = content.replace(/\r\n[ \t]/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = unfolded.split('\n'); let inEvent = false; let currentEvent: Partial = {}; for (const line of lines) { const trimmed = line.trim(); if (trimmed === 'BEGIN:VEVENT') { inEvent = true; currentEvent = {}; continue; } if (trimmed === 'END:VEVENT') { inEvent = false; if (currentEvent.summary && currentEvent.dtstart) { events.push({ uid: currentEvent.uid ?? uuidv4(), summary: currentEvent.summary, description: currentEvent.description, dtstart: currentEvent.dtstart, dtend: currentEvent.dtend, location: currentEvent.location, priority: currentEvent.priority, }); } else { errors.push(`Event missing required fields (SUMMARY or DTSTART)`); } continue; } if (!inEvent) continue; // Parse property const colonIdx = trimmed.indexOf(':'); if (colonIdx === -1) continue; const propPart = trimmed.slice(0, colonIdx); const value = trimmed.slice(colonIdx + 1); // Strip parameters (e.g., DTSTART;TZID=America/New_York:20260315T090000) const propName = propPart.split(';')[0].toUpperCase(); switch (propName) { case 'UID': currentEvent.uid = value; break; case 'SUMMARY': currentEvent.summary = unescapeIcsText(value); break; case 'DESCRIPTION': currentEvent.description = unescapeIcsText(value); break; case 'LOCATION': currentEvent.location = unescapeIcsText(value); break; case 'DTSTART': currentEvent.dtstart = parseIcsDateTime(value); break; case 'DTEND': currentEvent.dtend = parseIcsDateTime(value); break; case 'PRIORITY': currentEvent.priority = parseInt(value, 10) || undefined; break; } } return { events, errors }; } /** * Parse iCalendar date-time string. * Formats: 20260315T090000, 20260315T090000Z, 20260315 */ function parseIcsDateTime(value: string): Date { const cleaned = value.trim(); // Date only: YYYYMMDD if (cleaned.length === 8) { const y = parseInt(cleaned.slice(0, 4)); const m = parseInt(cleaned.slice(4, 6)) - 1; const d = parseInt(cleaned.slice(6, 8)); return new Date(y, m, d); } // DateTime: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ const isUtc = cleaned.endsWith('Z'); const dt = isUtc ? cleaned.slice(0, -1) : cleaned; const parts = dt.split('T'); const y = parseInt(parts[0].slice(0, 4)); const m = parseInt(parts[0].slice(4, 6)) - 1; const d = parseInt(parts[0].slice(6, 8)); const h = parts[1] ? parseInt(parts[1].slice(0, 2)) : 0; const min = parts[1] ? parseInt(parts[1].slice(2, 4)) : 0; const s = parts[1] ? parseInt(parts[1].slice(4, 6)) : 0; if (isUtc) { return new Date(Date.UTC(y, m, d, h, min, s)); } return new Date(y, m, d, h, min, s); } /** * Unescape iCalendar text values. */ function unescapeIcsText(text: string): string { return text .replace(/\\n/g, '\n') .replace(/\\,/g, ',') .replace(/\\;/g, ';') .replace(/\\\\/g, '\\'); } // ── Event → Timer Conversion ────────────────────────────────── /** * Map iCalendar priority (1-9) to ChronoMind urgency level. * 1-2: critical, 3-4: important, 5: standard, 6-7: gentle, 8-9: passive */ export function mapPriorityToUrgency(priority?: number): UrgencyLevel { if (!priority || priority < 1 || priority > 9) return 'standard'; if (priority <= 2) return 'critical'; if (priority <= 4) return 'important'; if (priority <= 5) return 'standard'; if (priority <= 7) return 'gentle'; return 'passive'; } /** * Convert an IcsEvent to a Timer with auto-generated cascade. */ export function eventToTimer(event: IcsEvent, now: number = Date.now()): Timer { const urgency = mapPriorityToUrgency(event.priority); const targetTime = event.dtstart.getTime(); // Auto-cascade based on urgency const cascadePresetMap: Record = { critical: { preset: 'aggressive', intervals: [] }, important: { preset: 'standard', intervals: [] }, standard: { preset: 'light', intervals: [] }, gentle: { preset: 'minimal', intervals: [] }, passive: { preset: 'none', intervals: [] }, }; const cascade = cascadePresetMap[urgency]; const intervals = getCascadeIntervals(cascade); return { id: uuidv4(), type: 'alarm', label: event.summary, description: [event.description, event.location].filter(Boolean).join(' — '), urgency, state: targetTime > now ? 'active' : 'dismissed', targetTime, duration: event.dtend ? event.dtend.getTime() - targetTime : null, createdAt: now, startedAt: targetTime > now ? now : null, pausedAt: null, firedAt: targetTime <= now ? targetTime : null, dismissedAt: targetTime <= now ? now : null, completedAt: null, elapsedBeforePause: 0, cascade, warnings: calculateCascadeWarnings(targetTime, intervals, now), snoozeCount: 0, snoozedUntil: null, }; } // ── Conflict Detection ──────────────────────────────────────── /** * Detect conflicts between an event and existing timers. * Conflict = existing timer fires within 15 minutes of event start. */ export function detectConflicts( event: IcsEvent, existingTimers: Timer[], conflictWindowMs: number = 15 * 60 * 1000 ): string[] { const eventStart = event.dtstart.getTime(); const eventEnd = event.dtend?.getTime() ?? eventStart + 60 * 60 * 1000; return existingTimers .filter((t) => { if (['dismissed', 'completed'].includes(t.state)) return false; const timerTime = t.targetTime; // Timer fires within event window or event starts within timer's range return ( (timerTime >= eventStart - conflictWindowMs && timerTime <= eventEnd + conflictWindowMs) ); }) .map((t) => t.id); } // ── Full Import Pipeline ────────────────────────────────────── /** * Parse .ics content and convert to importable timers with conflict detection. */ export function importCalendar( icsContent: string, existingTimers: Timer[], now: number = Date.now() ): CalendarImportResult { const { events: icsEvents, errors: parseErrors } = parseIcs(icsContent); const importedEvents: ImportedEvent[] = icsEvents.map((event) => { const timer = eventToTimer(event, now); const conflicts = detectConflicts(event, existingTimers); return { event, timer, conflicts }; }); return { events: importedEvents, errors: parseErrors, totalParsed: icsEvents.length, }; }