116 lines
4.1 KiB
TypeScript
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;
|
|
}
|