188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
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);
|
|
}
|