initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,219 @@
/* ============================================================================
iCALENDAR (RFC 5545)
----------------------------------------------------------------------------
Builds the file behind "Add to calendar". Pure, dependency-free and written
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",
},
});
}