import type { CalendarEvent } from "@/lib/jmap/types"; const MAX_LINE_OCTETS = 74; function foldLine(line: string): string { if (line.length <= MAX_LINE_OCTETS) return line; const chunks: string[] = [line.slice(0, MAX_LINE_OCTETS)]; let pos = MAX_LINE_OCTETS; while (pos < line.length) { chunks.push(" " + line.slice(pos, pos + MAX_LINE_OCTETS - 1)); pos += MAX_LINE_OCTETS - 1; } return chunks.join("\r\n"); } // RFC 5545 ยง3.3.11 - escape backslash, semicolon, comma, and newline in TEXT values. function escapeText(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/;/g, "\\;") .replace(/,/g, "\\,") .replace(/\r\n|\r|\n/g, "\\n"); } function stripDateSeparators(value: string): string { return value.replace(/[-:]/g, "").replace(/\.\d{3}/, ""); } function dateOnly(value: string): string { return value.replace(/-/g, "").substring(0, 8); } function formatNow(): string { return stripDateSeparators(new Date().toISOString().replace(/\.\d{3}/, "")); } function pushDateProp( lines: string[], prop: "DTSTART" | "DTEND", value: string, showWithoutTime: boolean, tz?: string | null, ): void { if (showWithoutTime) { lines.push(`${prop};VALUE=DATE:${dateOnly(value)}`); return; } if (value.endsWith("Z")) { lines.push(`${prop}:${stripDateSeparators(value)}`); return; } const basic = stripDateSeparators(value); if (tz) { lines.push(`${prop};TZID=${tz}:${basic}`); } else { lines.push(`${prop}:${basic}`); } } function pushFrequencyRule(lines: string[], event: CalendarEvent): void { const rule = event.recurrenceRules?.[0]; if (!rule) return; const parts: string[] = [`FREQ=${rule.frequency.toUpperCase()}`]; if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`); if (rule.count != null) parts.push(`COUNT=${rule.count}`); if (rule.until) parts.push(`UNTIL=${stripDateSeparators(rule.until)}`); if (rule.byDay?.length) { const days = rule.byDay .map((d) => `${d.nthOfPeriod ?? ""}${d.day.toUpperCase()}`) .join(","); parts.push(`BYDAY=${days}`); } if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`); if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`); lines.push(`RRULE:${parts.join(";")}`); } function pushAlerts(lines: string[], event: CalendarEvent): void { if (!event.alerts) return; for (const alert of Object.values(event.alerts)) { const trigger = alert.trigger; if (!trigger) continue; lines.push("BEGIN:VALARM"); lines.push(`ACTION:${(alert.action || "display").toUpperCase()}`); if (trigger["@type"] === "OffsetTrigger") { const related = trigger.relativeTo === "end" ? ";RELATED=END" : ""; lines.push(`TRIGGER${related}:${trigger.offset}`); } else if (trigger["@type"] === "AbsoluteTrigger") { lines.push(`TRIGGER;VALUE=DATE-TIME:${stripDateSeparators(trigger.when)}`); } lines.push(`DESCRIPTION:${escapeText(event.title || "Reminder")}`); lines.push("END:VALARM"); } } export function eventToICS(event: CalendarEvent): string { const now = formatNow(); const lines: string[] = [ "BEGIN:VCALENDAR", "PRODID:-//JMAP-Webmail//EN", "VERSION:2.0", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", `UID:${event.uid}`, `DTSTAMP:${now}`, ]; if (event.created) lines.push(`CREATED:${stripDateSeparators(event.created)}`); if (event.updated) lines.push(`LAST-MODIFIED:${stripDateSeparators(event.updated)}`); if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); if (event.start) { pushDateProp(lines, "DTSTART", event.start, event.showWithoutTime, event.timeZone); } if (event.utcEnd) { pushDateProp(lines, "DTEND", event.utcEnd, event.showWithoutTime, event.timeZone); } else if (event.duration) { lines.push(`DURATION:${event.duration}`); } if (event.title) lines.push(`SUMMARY:${escapeText(event.title)}`); if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`); if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`); if (event.privacy) lines.push(`CLASS:${event.privacy.toUpperCase()}`); if (event.freeBusyStatus) { lines.push(`TRANSP:${event.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`); } if (event.locations) { const first = Object.values(event.locations)[0]; if (first?.name) lines.push(`LOCATION:${escapeText(first.name)}`); } if (event.virtualLocations) { for (const loc of Object.values(event.virtualLocations)) { if (loc.uri) lines.push(`URL:${loc.uri}`); } } if (event.participants) { const organizer = Object.values(event.participants).find((p) => p.roles?.owner); if (organizer) { const email = organizer.email || organizer.sendTo?.imip?.replace("mailto:", ""); if (email) { const cn = organizer.name ? `;CN=${escapeText(organizer.name)}` : ""; lines.push(`ORGANIZER${cn}:mailto:${email}`); } } for (const p of Object.values(event.participants)) { if (p.roles?.owner) continue; const email = p.email || p.sendTo?.imip?.replace("mailto:", ""); if (!email) continue; const cn = p.name ? `;CN=${escapeText(p.name)}` : ""; const partstat = p.participationStatus ? `;PARTSTAT=${p.participationStatus.toUpperCase()}` : ";PARTSTAT=NEEDS-ACTION"; const rsvp = p.expectReply ? ";RSVP=TRUE" : ""; lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`); } } pushFrequencyRule(lines, event); pushAlerts(lines, event); lines.push("END:VEVENT"); lines.push("END:VCALENDAR"); return lines.map(foldLine).join("\r\n") + "\r\n"; } function sanitizeFilename(name: string): string { const cleaned = name.replace(/[\\/:*?"<>|]/g, "_").trim(); return cleaned || "event"; } export function downloadEventICS(event: CalendarEvent): void { const ics = eventToICS(event); const blob = new Blob([ics], { type: "text/calendar;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${sanitizeFilename(event.title || "event")}.ics`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }