import { EMAIL_PROCESSING_CODES, EmailProcessingError, } from "../error-codes/error-codes.js"; import type { CalendarEvent } from "./calendar-parser.types.js"; /** Parse an iCal/ICS string into calendar events. */ export function parseICalendar(icsContent: string): CalendarEvent[] { if (typeof icsContent !== "string") { throw new EmailProcessingError( EMAIL_PROCESSING_CODES.INVALID_INPUT, "email_processing.invalid_input.ics" ); } return parseICalendarInner(icsContent); } function parseICalendarInner(icsContent: string): CalendarEvent[] { const events: CalendarEvent[] = []; const lines = unfoldIcsLines(icsContent); let method: string | undefined; const methodLine = lines.find((l) => l.startsWith("METHOD:")); if (methodLine) { method = methodLine.slice("METHOD:".length).trim(); } let inEvent = false; let current: Partial = {}; for (const line of lines) { if (line === "BEGIN:VEVENT") { inEvent = true; current = method !== undefined ? { attendees: [], method } : { attendees: [] }; continue; } if (line === "END:VEVENT") { inEvent = false; const event: CalendarEvent = { // current.attendees is always set by BEGIN:VEVENT above. attendees: current.attendees as string[], }; if (current.summary !== undefined) event.summary = current.summary; if (current.description !== undefined) event.description = current.description; if (current.dtStart !== undefined) event.dtStart = current.dtStart; if (current.dtEnd !== undefined) event.dtEnd = current.dtEnd; if (current.location !== undefined) event.location = current.location; if (current.organizer !== undefined) event.organizer = current.organizer; if (current.uid !== undefined) event.uid = current.uid; if (current.method !== undefined) event.method = current.method; events.push(event); continue; } if (!inEvent) continue; if (line.startsWith("SUMMARY:")) { current.summary = unescapeIcs(line.slice("SUMMARY:".length)); } else if (line.startsWith("DESCRIPTION:")) { current.description = unescapeIcs( line.slice("DESCRIPTION:".length) ); } else if (line.startsWith("DTSTART")) { current.dtStart = extractIcsDateTime(line); } else if (line.startsWith("DTEND")) { current.dtEnd = extractIcsDateTime(line); } else if (line.startsWith("LOCATION:")) { current.location = unescapeIcs(line.slice("LOCATION:".length)); } else if (line.startsWith("ORGANIZER")) { const org = extractMailto(line); if (org !== undefined) current.organizer = org; } else if (line.startsWith("ATTENDEE")) { const email = extractMailto(line); // current.attendees is always set by BEGIN:VEVENT above. if (email) (current.attendees as string[]).push(email); } else if (line.startsWith("UID:")) { current.uid = line.slice("UID:".length).trim(); } } return events; } function unfoldIcsLines(content: string): string[] { return content .replace(/\r\n /g, "") .replace(/\r\n\t/g, "") .split(/\r?\n/); } function unescapeIcs(text: string): string { return text .replace(/\\n/g, "\n") .replace(/\\,/g, ",") .replace(/\\;/g, ";") .replace(/\\\\/g, "\\"); } function extractIcsDateTime(line: string): string { const colonIdx = line.indexOf(":"); if (colonIdx === -1) return ""; return line.slice(colonIdx + 1).trim(); } function extractMailto(line: string): string | undefined { const match = line.match(/mailto:([^\s;]+)/i); // The regex's capture group always matches when the overall regex // matches, so match[1] is non-undefined here. return match ? match[1] : undefined; }