/* ============================================================================ iCALENDAR (RFC 5545) ---------------------------------------------------------------------------- Builds the file behind "Add to calendar". Pure, dependency-free and written against the spec rather than against one client's tolerance: * CRLF line endings, and content lines folded at 75 OCTETS - folded on character boundaries, so an Arabic title (2 bytes per letter) is never cut in half; * TEXT values escaped for \ ; , and newlines; * timed events written as UTC (`...Z`), all-day events as VALUE=DATE with a DTEND on the day AFTER the last day, which is what "exclusive end" means in the spec and what every calendar client expects; * a stable UID, so re-importing an updated file replaces the entry instead of duplicating it. No ORGANIZER property: it requires a real mailto: address, and the project inbox is still marked TBD in the draft copy. An invented one would end up in readers' calendars. ========================================================================= */ const CRLF = "\r\n"; /** RFC 5545 3.1: a content line is at most 75 octets, excluding the CRLF. */ const MAX_OCTETS = 75; const encoder = new TextEncoder(); export type IcsStatus = "CONFIRMED" | "TENTATIVE" | "CANCELLED"; export interface IcsEvent { /** Globally unique and STABLE for this event. */ readonly uid: string; /** ISO 8601. */ readonly start: string; readonly end?: string | undefined; /** Write the all-day form (VALUE=DATE) instead of a timed one. */ readonly allDay?: boolean; readonly summary: string; readonly description?: string | undefined; readonly location?: string | undefined; readonly url?: string | undefined; readonly status?: IcsStatus; /** DTSTAMP. Defaults to now; pass a fixed value in a test. */ readonly stamp?: Date; } export interface IcsCalendarOptions { /** PRODID. Identifies the software that wrote the file. */ readonly prodId?: string; /** X-WR-CALNAME, shown by some clients when a file is imported. */ readonly name?: string; } const DEFAULT_PROD_ID = "-//SHARP-ER//Events Calendar//EN"; /* --------------------------------------------------------------------------- VALUE ENCODING ------------------------------------------------------------------------ */ /** * Control characters a TEXT value may not carry. TAB is legal and the two * newline characters are escaped before this runs, so neither is in the class. */ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; /** RFC 5545 3.3.11 TEXT escaping, plus control characters dropped. */ export function escapeIcsText(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/;/g, "\\;") .replace(/,/g, "\\,") .replace(/\r\n|\r|\n/g, "\\n") .replace(CONTROL_CHARS, ""); } /** "20260312T140000Z" */ function toUtcStamp(date: Date): string { return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); } /** "20260312" - the calendar day of a date-only value, read in UTC. */ function toDateValue(date: Date): string { return date.toISOString().slice(0, 10).replace(/-/g, ""); } function addDays(date: Date, days: number): Date { return new Date(date.getTime() + days * 86_400_000); } /** * Folds one content line to 75 octets. Continuation lines begin with a single * space, which the parser strips again. */ export function foldIcsLine(line: string): string { if (encoder.encode(line).length <= MAX_OCTETS) return line; const parts: string[] = []; let current = ""; let octets = 0; // Iterating the string yields whole code points, so a multi-byte character // is never split across two lines. for (const char of line) { const size = encoder.encode(char).length; if (octets + size > MAX_OCTETS) { parts.push(current); current = " "; octets = 1; } current += char; octets += size; } parts.push(current); return parts.join(CRLF); } /* --------------------------------------------------------------------------- BUILDER ------------------------------------------------------------------------ */ function property(name: string, value: string): string { return foldIcsLine(`${name}:${value}`); } function eventLines(event: IcsEvent): string[] { const start = new Date(event.start); if (Number.isNaN(start.getTime())) return []; const parsedEnd = event.end ? new Date(event.end) : undefined; const end = parsedEnd && !Number.isNaN(parsedEnd.getTime()) ? parsedEnd : undefined; const lines: string[] = ["BEGIN:VEVENT"]; lines.push(property("UID", escapeIcsText(event.uid))); lines.push(property("DTSTAMP", toUtcStamp(event.stamp ?? new Date()))); if (event.allDay) { lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`); // DTEND is exclusive: the day after the last day of the event. lines.push(`DTEND;VALUE=DATE:${toDateValue(addDays(end ?? start, 1))}`); } else { lines.push(property("DTSTART", toUtcStamp(start))); if (end && end.getTime() > start.getTime()) { lines.push(property("DTEND", toUtcStamp(end))); } } lines.push(property("SUMMARY", escapeIcsText(event.summary))); if (event.description) { lines.push(property("DESCRIPTION", escapeIcsText(event.description))); } if (event.location) { lines.push(property("LOCATION", escapeIcsText(event.location))); } if (event.url) { // A URI value is not TEXT and must not be escaped. lines.push(property("URL", event.url)); } lines.push(property("STATUS", event.status ?? "CONFIRMED")); lines.push("END:VEVENT"); return lines; } /** A complete VCALENDAR document, CRLF-terminated as the spec requires. */ export function buildIcsCalendar( events: readonly IcsEvent[], options: IcsCalendarOptions = {}, ): string { const lines: string[] = [ "BEGIN:VCALENDAR", "VERSION:2.0", property("PRODID", options.prodId ?? DEFAULT_PROD_ID), "CALSCALE:GREGORIAN", "METHOD:PUBLISH", ]; if (options.name) { lines.push(property("X-WR-CALNAME", escapeIcsText(options.name))); } for (const event of events) lines.push(...eventLines(event)); lines.push("END:VCALENDAR"); return `${lines.join(CRLF)}${CRLF}`; } /* --------------------------------------------------------------------------- RESPONSE ------------------------------------------------------------------------ */ /** ASCII-safe file name, with the UTF-8 original kept in `filename*`. */ function contentDisposition(filename: string): string { const ascii = filename.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, ""); const fallback = ascii.length > 4 ? ascii : "event.ics"; return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent( filename, )}`; } /** The whole HTTP response for a .ics download. */ export function icsResponse(body: string, filename: string): Response { return new Response(body, { status: 200, headers: { "content-type": "text/calendar; charset=utf-8", "content-disposition": contentDisposition(filename), // The file changes whenever the event does, and it is cheap to rebuild. "cache-control": "public, max-age=0, must-revalidate", }, }); }