Repository Public
1/** iCalendar (RFC 5545) output. Confidential data (reason, answers, notes) never enters an ICS file. */23export interface IcsEvent {4 uid: string; sequence: number; start: Date; end: Date; summary: string; description?: string; location?: string;5 status: "CONFIRMED" | "CANCELLED"; organizerName: string; organizerEmail: string; attendeeName?: string; attendeeEmail?: string;6 lastModified?: Date; url?: string;7}89const utc = (d: Date) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");10export const escapeText = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");1112/** Lines are folded at 75 octets (RFC 5545 §3.1), never inside a multi-byte character. */13export function fold(line: string): string {14 const out: string[] = [];15 let cur = "", bytes = 0;16 for (const ch of line) {17 const n = Buffer.byteLength(ch);18 if (bytes + n > (out.length === 0 ? 75 : 74)) { out.push(cur); cur = ""; bytes = 0; }19 cur += ch; bytes += n;20 }21 out.push(cur);22 return out.join("\r\n ");23}2425function vevent(e: IcsEvent, stamp: Date): string[] {26 const l = [27 "BEGIN:VEVENT",28 `UID:${e.uid}`,29 `SEQUENCE:${e.sequence}`,30 `DTSTAMP:${utc(stamp)}`,31 `LAST-MODIFIED:${utc(e.lastModified ?? stamp)}`,32 `DTSTART:${utc(e.start)}`,33 `DTEND:${utc(e.end)}`,34 `SUMMARY:${escapeText(e.summary)}`,35 `STATUS:${e.status}`,36 "TRANSP:OPAQUE",37 `ORGANIZER;CN=${escapeParam(e.organizerName)}:mailto:${e.organizerEmail}`,38 ];39 if (e.attendeeEmail) l.push(`ATTENDEE;CN=${escapeParam(e.attendeeName ?? e.attendeeEmail)};ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:${e.attendeeEmail}`);40 if (e.description) l.push(`DESCRIPTION:${escapeText(e.description)}`);41 if (e.location) l.push(`LOCATION:${escapeText(e.location)}`);42 if (e.url) l.push(`URL:${e.url}`);43 l.push("END:VEVENT");44 return l;45}46const escapeParam = (s: string) => `"${s.replace(/["\r\n]/g, " ")}"`;4748/** One invitation (METHOD:REQUEST) or its cancellation (METHOD:CANCEL). Same UID, growing SEQUENCE. */49export function invitation(e: IcsEvent, stamp = new Date()): string {50 const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//spboucher.ai//SP Calendar//FR", "CALSCALE:GREGORIAN", `METHOD:${e.status === "CANCELLED" ? "CANCEL" : "REQUEST"}`, ...vevent(e, stamp), "END:VCALENDAR"];51 return lines.map(fold).join("\r\n") + "\r\n";52}5354/** Read-only feed (no METHOD) for calendar subscription. */55export function feed(name: string, events: IcsEvent[], stamp = new Date()): string {56 const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//spboucher.ai//SP Calendar//FR", "CALSCALE:GREGORIAN", `X-WR-CALNAME:${escapeText(name)}`, "REFRESH-INTERVAL;VALUE=DURATION:PT15M", "X-PUBLISHED-TTL:PT15M"];57 for (const e of events) lines.push(...vevent(e, stamp));58 lines.push("END:VCALENDAR");59 return lines.map(fold).join("\r\n") + "\r\n";60}61