81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
// AK-P16b · minimal RFC-5545 .ics builder (PURE). The booking confirmation mail
|
|
// attaches an "add to calendar" file; rather than pull a dependency we hand-roll the
|
|
// tiny VEVENT subset the spec needs (EU/supply-chain clean, unit-tested). Only the
|
|
// fields the confirmation needs: UID, DTSTAMP, DTSTART, DTEND, SUMMARY, DESCRIPTION,
|
|
// LOCATION, optional ORGANIZER + ATTENDEE.
|
|
|
|
export interface IcsEventInput {
|
|
readonly uid: string;
|
|
readonly start: Date;
|
|
readonly end: Date;
|
|
readonly dtstamp: Date;
|
|
readonly summary: string;
|
|
readonly description?: string;
|
|
readonly location?: string;
|
|
readonly organizerEmail?: string;
|
|
readonly organizerName?: string;
|
|
readonly attendeeEmail?: string;
|
|
}
|
|
|
|
/** Format a Date as an RFC-5545 UTC timestamp `YYYYMMDDTHHMMSSZ`. */
|
|
function toIcsUtc(date: Date): string {
|
|
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
}
|
|
|
|
/** Escape TEXT values per RFC 5545 §3.3.11 (backslash, semicolon, comma, newline). */
|
|
function escapeText(value: string): string {
|
|
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
|
}
|
|
|
|
/** Fold a content line to <=75 octets per RFC 5545 §3.1 (CRLF + leading space). */
|
|
function foldLine(line: string): string {
|
|
if (line.length <= 75) {
|
|
return line;
|
|
}
|
|
const parts: string[] = [line.slice(0, 75)];
|
|
let rest = line.slice(75);
|
|
while (rest.length > 74) {
|
|
parts.push(` ${rest.slice(0, 74)}`);
|
|
rest = rest.slice(74);
|
|
}
|
|
parts.push(` ${rest}`);
|
|
return parts.join("\r\n");
|
|
}
|
|
|
|
/** Build a single-VEVENT VCALENDAR string (CRLF line endings, as the spec mandates). */
|
|
export function buildIcs(input: IcsEventInput): string {
|
|
const lines: string[] = [
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//HiveGrid//Booking//EN",
|
|
"CALSCALE:GREGORIAN",
|
|
"METHOD:PUBLISH",
|
|
"BEGIN:VEVENT",
|
|
`UID:${input.uid}`,
|
|
`DTSTAMP:${toIcsUtc(input.dtstamp)}`,
|
|
`DTSTART:${toIcsUtc(input.start)}`,
|
|
`DTEND:${toIcsUtc(input.end)}`,
|
|
`SUMMARY:${escapeText(input.summary)}`,
|
|
];
|
|
if (input.description) {
|
|
lines.push(`DESCRIPTION:${escapeText(input.description)}`);
|
|
}
|
|
if (input.location) {
|
|
lines.push(`LOCATION:${escapeText(input.location)}`);
|
|
}
|
|
if (input.organizerEmail) {
|
|
const cn = input.organizerName ? `;CN=${escapeText(input.organizerName)}` : "";
|
|
lines.push(`ORGANIZER${cn}:mailto:${input.organizerEmail}`);
|
|
}
|
|
if (input.attendeeEmail) {
|
|
lines.push(`ATTENDEE;ROLE=REQ-PARTICIPANT:mailto:${input.attendeeEmail}`);
|
|
}
|
|
lines.push("STATUS:CONFIRMED", "END:VEVENT", "END:VCALENDAR");
|
|
return lines.map(foldLine).join("\r\n");
|
|
}
|
|
|
|
/** Base64-encode an .ics string for use as a mail attachment `content`. */
|
|
export function icsToBase64(ics: string): string {
|
|
return Buffer.from(ics, "utf8").toString("base64");
|
|
}
|