2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
iCALENDAR (RFC 5545)
|
|
|
|
|
----------------------------------------------------------------------------
|
2026-10-02 12:49:11 +02:00
|
|
|
Builds the file behind "Download .ics file". Pure, dependency-free and written
|
2026-09-11 02:46:56 +02:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|