feat(events): add calendar email support
Help registrants add events to their calendars and keep waitlisted places distinct from confirmed attendance.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user