111 lines
3.1 KiB
TypeScript
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}`;
|
||
|
|
}
|