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}`; }