Files
suaveplan/packages/communication/emails/email-processing/src/calendar-parser/calendar-parser.ts
2026-05-27 23:29:53 +01:00

116 lines
4.1 KiB
TypeScript

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<CalendarEvent> = {};
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;
}