SPB Git

Repository Public

ics.ts 2.8 KB 61 lines typescript

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
Files main
Menu

Theme