Files
sharper.ly/apps/cms/src/lib/event-calendar.ts
T
Illusion 5310272715 feat(events): add calendar email support
Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
2026-09-27 15:57:43 +02:00

111 lines
3.1 KiB
TypeScript

const CRLF = '\r\n';
const MAX_OCTETS = 75;
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
export type CalendarEvent = {
uid: string;
start: string | Date;
end?: string | Date | null;
allDay?: boolean;
summary: string;
description?: string;
location?: string;
url?: string;
status?: 'CONFIRMED' | 'TENTATIVE' | 'CANCELLED';
};
function escapeText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n')
.replace(CONTROL_CHARS, '');
}
function foldLine(line: string): string {
if (Buffer.byteLength(line, 'utf8') <= MAX_OCTETS) return line;
const folded: string[] = [];
let current = '';
let octets = 0;
for (const character of line) {
const size = Buffer.byteLength(character, 'utf8');
if (octets + size > MAX_OCTETS) {
folded.push(current);
current = ' ';
octets = 1;
}
current += character;
octets += size;
}
folded.push(current);
return folded.join(CRLF);
}
function property(name: string, value: string): string {
return foldLine(`${name}:${value}`);
}
function parseDate(value: string | Date | null | undefined): Date | undefined {
if (!value) return undefined;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function toUtcStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function toDateValue(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
export function buildEventCalendar(event: CalendarEvent): string {
const start = parseDate(event.start);
const end = parseDate(event.end);
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
property('PRODID', '-//SHARP-ER//Events Calendar//EN'),
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
property('X-WR-CALNAME', 'SHARP-ER'),
];
if (start) {
lines.push('BEGIN:VEVENT');
lines.push(property('UID', escapeText(event.uid)));
lines.push(property('DTSTAMP', toUtcStamp(new Date())));
if (event.allDay) {
lines.push(`DTSTART;VALUE=DATE:${toDateValue(start)}`);
const exclusiveEnd = new Date((end ?? start).getTime() + 86_400_000);
lines.push(`DTEND;VALUE=DATE:${toDateValue(exclusiveEnd)}`);
} else {
lines.push(property('DTSTART', toUtcStamp(start)));
if (end && end.getTime() > start.getTime()) {
lines.push(property('DTEND', toUtcStamp(end)));
}
}
lines.push(property('SUMMARY', escapeText(event.summary)));
if (event.description) {
lines.push(property('DESCRIPTION', escapeText(event.description)));
}
if (event.location) {
lines.push(property('LOCATION', escapeText(event.location)));
}
if (event.url && /^https?:\/\//i.test(event.url)) {
lines.push(property('URL', event.url));
}
lines.push(property('STATUS', event.status ?? 'CONFIRMED'));
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return `${lines.join(CRLF)}${CRLF}`;
}