70 lines
2.2 KiB
TypeScript
70 lines
2.2 KiB
TypeScript
// Minimal RFC 5545 (iCalendar) generator — plain text, no dependencies.
|
|
|
|
export type IcsEvent = {
|
|
uid: string;
|
|
title: string;
|
|
description?: string;
|
|
location?: string;
|
|
url?: string;
|
|
start: Date;
|
|
end: Date;
|
|
organizerName?: string;
|
|
organizerEmail?: string;
|
|
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED';
|
|
stamp?: Date;
|
|
};
|
|
|
|
/** Format an instant as a UTC iCalendar DATE-TIME: YYYYMMDDTHHMMSSZ. */
|
|
export function toIcsDate(date: Date): string {
|
|
return `${date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')}`;
|
|
}
|
|
|
|
function escapeText(value: string): string {
|
|
return value
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/;/g, '\\;')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/\r?\n/g, '\\n');
|
|
}
|
|
|
|
/** Folds a content line at 75 octets per RFC 5545 (continuation lines start with a space). */
|
|
function fold(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);
|
|
}
|
|
if (rest.length) parts.push(' ' + rest);
|
|
return parts.join('\r\n');
|
|
}
|
|
|
|
/** Build a single-event VCALENDAR document (CRLF line endings). */
|
|
export function buildIcs(event: IcsEvent): string {
|
|
const stamp = event.stamp ?? new Date();
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'PRODID:-//w4c//Booking//EN',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:PUBLISH',
|
|
'BEGIN:VEVENT',
|
|
`UID:${escapeText(event.uid)}`,
|
|
`DTSTAMP:${toIcsDate(stamp)}`,
|
|
`DTSTART:${toIcsDate(event.start)}`,
|
|
`DTEND:${toIcsDate(event.end)}`,
|
|
`SUMMARY:${escapeText(event.title)}`,
|
|
];
|
|
if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);
|
|
if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);
|
|
if (event.url) lines.push(`URL:${escapeText(event.url)}`);
|
|
if (event.organizerEmail) {
|
|
const cn = event.organizerName ? `;CN=${escapeText(event.organizerName)}` : '';
|
|
lines.push(`ORGANIZER${cn}:mailto:${escapeText(event.organizerEmail)}`);
|
|
}
|
|
if (event.status) lines.push(`STATUS:${event.status}`);
|
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
|
return lines.map(fold).join('\r\n') + '\r\n';
|
|
}
|