This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description. You can also import and export ICS (VCALENDAR + VEVENT) files. It allows editing the ICS directly, for power users. Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar". You can also change the display timezone for the calendar from the settings. Finally, there's some minor documentation fixes and some other minor tweaks. Closes #56 Closes #89
958 lines
28 KiB
TypeScript
958 lines
28 KiB
TypeScript
import { Calendar, CalendarEvent, CalendarEventAttendee, CalendarEventReminder } from '/lib/models/calendar.ts';
|
|
|
|
export const CALENDAR_COLOR_OPTIONS = [
|
|
'bg-red-700',
|
|
'bg-red-950',
|
|
'bg-orange-700',
|
|
'bg-orange-950',
|
|
'bg-amber-700',
|
|
'bg-yellow-800',
|
|
'bg-lime-700',
|
|
'bg-lime-950',
|
|
'bg-green-700',
|
|
'bg-emerald-800',
|
|
'bg-teal-700',
|
|
'bg-cyan-700',
|
|
'bg-sky-800',
|
|
'bg-blue-900',
|
|
'bg-indigo-700',
|
|
'bg-violet-700',
|
|
'bg-purple-800',
|
|
'bg-fuchsia-700',
|
|
'bg-pink-800',
|
|
'bg-rose-700',
|
|
] as const;
|
|
|
|
const CALENDAR_COLOR_OPTIONS_HEX = [
|
|
'#B51E1F',
|
|
'#450A0A',
|
|
'#BF4310',
|
|
'#431407',
|
|
'#B0550F',
|
|
'#834F13',
|
|
'#4D7D16',
|
|
'#1A2E05',
|
|
'#148041',
|
|
'#066048',
|
|
'#107873',
|
|
'#0E7490',
|
|
'#075985',
|
|
'#1E3A89',
|
|
'#423BCA',
|
|
'#6A2BD9',
|
|
'#6923A9',
|
|
'#9D21B1',
|
|
'#9C174D',
|
|
'#BC133D',
|
|
] as const;
|
|
|
|
export function getColorAsHex(calendarColor: string) {
|
|
const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor);
|
|
|
|
return CALENDAR_COLOR_OPTIONS_HEX[colorIndex] || '#384354';
|
|
}
|
|
|
|
export function getIdFromVEvent(vEvent: string): string {
|
|
const lines = vEvent.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
|
|
// Loop through every line and find the UID line
|
|
for (const line of lines) {
|
|
if (line.startsWith('UID:')) {
|
|
const uid = line.replace('UID:', '');
|
|
return uid.trim();
|
|
}
|
|
}
|
|
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
export function splitTextIntoVEvents(text: string): string[] {
|
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
const vEvents: string[] = [];
|
|
const currentVEvent: string[] = [];
|
|
let hasFoundBeginVEvent = false;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('BEGIN:VEVENT')) {
|
|
hasFoundBeginVEvent = true;
|
|
}
|
|
|
|
if (!hasFoundBeginVEvent) {
|
|
continue;
|
|
}
|
|
|
|
currentVEvent.push(line);
|
|
|
|
if (line.startsWith('END:VEVENT')) {
|
|
vEvents.push(currentVEvent.join('\n'));
|
|
currentVEvent.length = 0;
|
|
hasFoundBeginVEvent = false;
|
|
}
|
|
}
|
|
|
|
return vEvents;
|
|
}
|
|
|
|
export function getDateRangeForCalendarView(
|
|
calendarStartDate: string,
|
|
calendarView: 'day' | 'week' | 'month',
|
|
): { start: Date; end: Date } {
|
|
const dateRange = { start: new Date(calendarStartDate), end: new Date(calendarStartDate) };
|
|
|
|
if (calendarView === 'day') {
|
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 1);
|
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 1);
|
|
} else if (calendarView === 'week') {
|
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 7);
|
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 7);
|
|
} else {
|
|
dateRange.start.setUTCDate(dateRange.start.getUTCDate() - 7);
|
|
dateRange.end.setUTCDate(dateRange.end.getUTCDate() + 31);
|
|
}
|
|
|
|
return dateRange;
|
|
}
|
|
|
|
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
|
|
if (status === 'accepted' || status === 'rejected') {
|
|
return status.toUpperCase();
|
|
}
|
|
|
|
return `NEEDS-ACTION`;
|
|
}
|
|
|
|
function getAttendeeStatusFromVCalendar(
|
|
status: 'NEEDS-ACTION' | 'ACCEPTED' | 'REJECTED',
|
|
): CalendarEventAttendee['status'] {
|
|
if (status === 'ACCEPTED' || status === 'REJECTED') {
|
|
return status.toLowerCase() as CalendarEventAttendee['status'];
|
|
}
|
|
|
|
return 'invited';
|
|
}
|
|
|
|
export function getVCalendarDate(date: Date | string) {
|
|
return new Date(date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
|
|
}
|
|
|
|
function getSafelyEscapedTextForVCalendar(text: string) {
|
|
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
|
|
}
|
|
|
|
function getSafelyUnescapedTextFromVCalendar(text: string) {
|
|
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
|
}
|
|
|
|
export function generateVCalendar(
|
|
events: CalendarEvent[],
|
|
createdDate: Date = new Date(),
|
|
): string {
|
|
const vCalendarText = events.map((event) => generateVEvent(event, createdDate)).join('\n');
|
|
|
|
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
|
line.trim()
|
|
).filter(
|
|
Boolean,
|
|
).join('\n');
|
|
}
|
|
|
|
export function generateVEvent(calendarEvent: CalendarEvent, createdDate: Date = new Date()): string {
|
|
const vEventText = `BEGIN:VEVENT
|
|
DTSTAMP:${getVCalendarDate(createdDate)}
|
|
${
|
|
calendarEvent.isAllDay
|
|
? `DTSTART;VALUE=DATE:${getVCalendarDate(calendarEvent.startDate).substring(0, 8)}`
|
|
: `DTSTART:${getVCalendarDate(calendarEvent.startDate)}`
|
|
}
|
|
${
|
|
calendarEvent.isAllDay
|
|
? `DTEND;VALUE=DATE:${getVCalendarDate(calendarEvent.endDate).substring(0, 8)}`
|
|
: `DTEND:${getVCalendarDate(calendarEvent.endDate)}`
|
|
}
|
|
ORGANIZER;CN=:mailto:${calendarEvent.organizerEmail}
|
|
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
|
|
TRANSP:${calendarEvent.transparency.toUpperCase()}
|
|
UID:${calendarEvent.uid}
|
|
${calendarEvent.isRecurring && calendarEvent.recurringRrule ? `RRULE:${calendarEvent.recurringRrule}` : ''}
|
|
${calendarEvent.sequence && calendarEvent.sequence > 0 ? `SEQUENCE:${calendarEvent.sequence}` : 'SEQUENCE:0'}
|
|
CREATED:${getVCalendarDate(createdDate)}
|
|
LAST-MODIFIED:${getVCalendarDate(createdDate)}
|
|
${
|
|
calendarEvent.description
|
|
? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(calendarEvent.description.replaceAll('\r', ''))}`
|
|
: ''
|
|
}
|
|
${calendarEvent.location ? `LOCATION:${getSafelyEscapedTextForVCalendar(calendarEvent.location)}` : ''}
|
|
${calendarEvent.eventUrl ? `URL:${getSafelyEscapedTextForVCalendar(calendarEvent.eventUrl)}` : ''}
|
|
${
|
|
calendarEvent.attendees?.map((attendee) =>
|
|
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
|
|
getSafelyEscapedTextForVCalendar(attendee.name || '')
|
|
}:mailto:${attendee.email}`
|
|
).join('\n') || ''
|
|
}
|
|
${
|
|
calendarEvent.reminders?.map((reminder) =>
|
|
`BEGIN:VALARM
|
|
ACTION:${reminder.type.toUpperCase()}
|
|
${
|
|
reminder.description
|
|
? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description.replaceAll('\r', ''))}`
|
|
: ''
|
|
}
|
|
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.startDate)}
|
|
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
|
${reminder.acknowledgedAt ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledgedAt)}` : ''}
|
|
END:VALARM`
|
|
).join('\n') || ''
|
|
}
|
|
END:VEVENT`;
|
|
|
|
return vEventText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
|
}
|
|
|
|
export function updateIcs(
|
|
ics: string,
|
|
event: CalendarEvent,
|
|
): string {
|
|
const lines = ics.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
|
|
let replacedTitle = false;
|
|
let replacedStartDate = false;
|
|
let replacedEndDate = false;
|
|
let replacedStatus = false;
|
|
let replacedDescription = false;
|
|
let replacedEventUrl = false;
|
|
let replacedLocation = false;
|
|
let replacedTransparency = false;
|
|
let replacedLastModified = false;
|
|
let hasFoundFirstEventLine = false;
|
|
const lastModifiedDate = new Date();
|
|
|
|
const updatedIcsLines = lines.map((line) => {
|
|
// Skip everything until finding the first event
|
|
if (!hasFoundFirstEventLine) {
|
|
hasFoundFirstEventLine = line.startsWith('BEGIN:VEVENT');
|
|
return line;
|
|
}
|
|
|
|
if (line.startsWith('SUMMARY:') && event.title && !replacedTitle) {
|
|
replacedTitle = true;
|
|
return `SUMMARY:${getSafelyEscapedTextForVCalendar(event.title)}`;
|
|
}
|
|
|
|
if ((line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) && event.startDate && !replacedStartDate) {
|
|
replacedStartDate = true;
|
|
if (event.isAllDay) {
|
|
return `DTSTART;VALUE=DATE:${getVCalendarDate(event.startDate).substring(0, 8)}`;
|
|
}
|
|
return `DTSTART:${getVCalendarDate(event.startDate)}`;
|
|
}
|
|
|
|
if ((line.startsWith('DTEND:') || line.startsWith('DTEND;')) && event.endDate && !replacedEndDate) {
|
|
replacedEndDate = true;
|
|
if (event.isAllDay) {
|
|
return `DTEND;VALUE=DATE:${getVCalendarDate(event.endDate).substring(0, 8)}`;
|
|
}
|
|
return `DTEND:${getVCalendarDate(event.endDate)}`;
|
|
}
|
|
|
|
if (line.startsWith('STATUS:') && event.status && !replacedStatus) {
|
|
replacedStatus = true;
|
|
return `STATUS:${getSafelyEscapedTextForVCalendar(event.status)}`;
|
|
}
|
|
|
|
if (line.startsWith('DESCRIPTION:') && event.description && !replacedDescription) {
|
|
replacedDescription = true;
|
|
return `DESCRIPTION:${getSafelyEscapedTextForVCalendar(event.description.replaceAll('\r', ''))}`;
|
|
}
|
|
|
|
if (line.startsWith('URL:') && event.eventUrl && !replacedEventUrl) {
|
|
replacedEventUrl = true;
|
|
return `URL:${getSafelyEscapedTextForVCalendar(event.eventUrl)}`;
|
|
}
|
|
|
|
if (line.startsWith('LOCATION:') && event.location && !replacedLocation) {
|
|
replacedLocation = true;
|
|
return `LOCATION:${getSafelyEscapedTextForVCalendar(event.location)}`;
|
|
}
|
|
|
|
if (line.startsWith('TRANSP:') && event.transparency && !replacedTransparency) {
|
|
replacedTransparency = true;
|
|
return `TRANSP:${getSafelyEscapedTextForVCalendar(event.transparency.toUpperCase())}`;
|
|
}
|
|
|
|
if (line.startsWith('LAST-MODIFIED:') && !replacedLastModified) {
|
|
replacedLastModified = true;
|
|
return `LAST-MODIFIED:${getVCalendarDate(lastModifiedDate)}`;
|
|
}
|
|
|
|
return line;
|
|
});
|
|
|
|
// Find last line with END:VEVENT, extract it and what's after it
|
|
const endLineIndex = updatedIcsLines.findIndex((line) => line.startsWith('END:VEVENT'));
|
|
const endLines = updatedIcsLines.splice(endLineIndex, updatedIcsLines.length - endLineIndex);
|
|
|
|
if (!replacedDescription && event.description) {
|
|
updatedIcsLines.push(`DESCRIPTION:${getSafelyEscapedTextForVCalendar(event.description)}`);
|
|
}
|
|
|
|
if (!replacedEventUrl && event.eventUrl) {
|
|
updatedIcsLines.push(`URL:${getSafelyEscapedTextForVCalendar(event.eventUrl)}`);
|
|
}
|
|
|
|
if (!replacedLocation && event.location) {
|
|
updatedIcsLines.push(`LOCATION:${getSafelyEscapedTextForVCalendar(event.location)}`);
|
|
}
|
|
|
|
// Put the final lines back
|
|
updatedIcsLines.push(...endLines);
|
|
|
|
const updatedIcs = updatedIcsLines.map((line) => line.trim()).filter(Boolean).join('\n');
|
|
|
|
return updatedIcs;
|
|
}
|
|
|
|
export function parseIcsDate(date: string): Date {
|
|
const [dateInfo, hourInfo] = date.split('T');
|
|
|
|
const year = dateInfo.substring(0, 4);
|
|
const month = dateInfo.substring(4, 6);
|
|
const day = dateInfo.substring(6, 8);
|
|
|
|
const hours = hourInfo.substring(0, 2);
|
|
const minutes = hourInfo.substring(2, 4);
|
|
const seconds = hourInfo.substring(4, 6);
|
|
|
|
return new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
|
}
|
|
|
|
type VCalendarVersion = '1.0' | '2.0';
|
|
|
|
export function parseVCalendar(text: string): CalendarEvent[] {
|
|
// Lines that start with a space should be moved to the line above them, as it's the same field/value to parse
|
|
const lines = text.split('\n').reduce((previousLines, currentLine) => {
|
|
if (currentLine.startsWith(' ')) {
|
|
previousLines[previousLines.length - 1] = `${previousLines[previousLines.length - 1]} ${
|
|
currentLine.substring(1).replaceAll('\r', '')
|
|
}`;
|
|
} else {
|
|
previousLines.push(currentLine.replaceAll('\r', ''));
|
|
}
|
|
|
|
return previousLines;
|
|
}, [] as string[]).map((line) => line.trim()).filter(Boolean);
|
|
|
|
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
|
|
|
let partialCalendarEvent: Partial<CalendarEvent> = {};
|
|
let partialCalendarReminder: Partial<CalendarEventReminder> = {};
|
|
let vCalendarVersion: VCalendarVersion = '2.0';
|
|
const partialRecurringMasterEvent: Pick<CalendarEvent, 'uid'> = {};
|
|
|
|
// Loop through every line
|
|
for (const line of lines) {
|
|
// Start new vCard version
|
|
if (line.startsWith('BEGIN:VCALENDAR')) {
|
|
vCalendarVersion = '2.0';
|
|
continue;
|
|
}
|
|
|
|
// Start new event
|
|
if (line.startsWith('BEGIN:VEVENT')) {
|
|
partialCalendarEvent = {};
|
|
continue;
|
|
}
|
|
|
|
// Finish event
|
|
if (line.startsWith('END:VEVENT')) {
|
|
partialCalendarEvents.push(partialCalendarEvent);
|
|
continue;
|
|
}
|
|
|
|
// Start new reminder
|
|
if (line.startsWith('BEGIN:VALARM')) {
|
|
partialCalendarReminder = {};
|
|
continue;
|
|
}
|
|
|
|
// Finish reminder
|
|
if (line.startsWith('END:VALARM')) {
|
|
partialCalendarEvent.reminders = [
|
|
...(partialCalendarEvent?.reminders || []),
|
|
partialCalendarReminder as CalendarEventReminder,
|
|
];
|
|
|
|
partialCalendarReminder = {};
|
|
continue;
|
|
}
|
|
|
|
// Select proper vCalendar version
|
|
if (line.startsWith('VERSION:')) {
|
|
if (line.startsWith('VERSION:1.0')) {
|
|
vCalendarVersion = '1.0';
|
|
} else if (line.startsWith('VERSION:2.0')) {
|
|
vCalendarVersion = '2.0';
|
|
} else {
|
|
// Default to 2.0, log warning
|
|
vCalendarVersion = '2.0';
|
|
console.warn(`Invalid vCalendar version found: "${line}". Defaulting to 2.0 parser.`);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (vCalendarVersion !== '1.0' && vCalendarVersion !== '2.0') {
|
|
console.warn(`Invalid vCalendar version found: "${vCalendarVersion}". Defaulting to 2.0 parser.`);
|
|
vCalendarVersion = '2.0';
|
|
}
|
|
|
|
if (line.startsWith('UID:')) {
|
|
const uid = line.replace('UID:', '').trim();
|
|
|
|
if (!uid) {
|
|
continue;
|
|
}
|
|
|
|
if (Object.keys(partialCalendarReminder).length > 0) {
|
|
partialCalendarReminder.uid = uid;
|
|
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.uid = uid;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('RECURRENCE-ID:')) {
|
|
const recurrenceId = line.replace('RECURRENCE-ID:', '').trim();
|
|
|
|
if (!recurrenceId) {
|
|
continue;
|
|
}
|
|
|
|
// If we haven't found the master event yet, use the current event as the master (the UID from the ICS will be the master's, and the same for all instances)
|
|
if (Object.keys(partialRecurringMasterEvent).length === 0) {
|
|
partialRecurringMasterEvent.uid = partialCalendarEvent.uid;
|
|
}
|
|
|
|
partialCalendarEvent.recurrenceMasterUid = partialCalendarEvent.uid || partialRecurringMasterEvent.uid;
|
|
partialCalendarEvent.uid = `${partialCalendarEvent.recurrenceMasterUid}:${recurrenceId}`;
|
|
partialCalendarEvent.isRecurring = true;
|
|
partialCalendarEvent.recurrenceId = recurrenceId;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('DESCRIPTION:')) {
|
|
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
|
|
|
|
if (!description) {
|
|
continue;
|
|
}
|
|
|
|
if (Object.keys(partialCalendarReminder).length > 0) {
|
|
partialCalendarReminder.description = description;
|
|
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.description = description;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('SUMMARY:')) {
|
|
const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim());
|
|
|
|
if (!title) {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.title = title;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('URL:')) {
|
|
const eventUrl = getSafelyUnescapedTextFromVCalendar((line.split('URL:')[1] || '').trim());
|
|
|
|
if (!eventUrl) {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.eventUrl = eventUrl;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('LOCATION:')) {
|
|
const location = getSafelyUnescapedTextFromVCalendar((line.split('LOCATION:')[1] || '').trim());
|
|
|
|
if (!location) {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.location = location;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) {
|
|
const startDateInfo = line.split(':')[1] || '';
|
|
const startDate = parseIcsDate(startDateInfo);
|
|
|
|
partialCalendarEvent.startDate = startDate;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) {
|
|
const endDateInfo = line.split(':')[1] || '';
|
|
const endDate = parseIcsDate(endDateInfo);
|
|
|
|
partialCalendarEvent.endDate = endDate;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('ORGANIZER;')) {
|
|
const organizerInfo = line.split(':');
|
|
const organizerEmail = organizerInfo.slice(-1)[0] || '';
|
|
|
|
if (!organizerEmail) {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.organizerEmail = organizerEmail;
|
|
}
|
|
|
|
if (line.startsWith('TRANSP:')) {
|
|
const transparency = (line.split('TRANSP:')[1] || 'opaque')
|
|
.toLowerCase() as CalendarEvent['transparency'];
|
|
|
|
partialCalendarEvent.transparency = transparency;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('ATTENDEE;')) {
|
|
const attendeeInfo = line.split(':');
|
|
const attendeeEmail = attendeeInfo.slice(-1)[0] || '';
|
|
const attendeeStatusInfo = line.split('PARTSTAT=')[1] || '';
|
|
const attendeeStatus = getAttendeeStatusFromVCalendar(
|
|
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
|
);
|
|
const attendeeNameInfo = line.split('CN=')[1] || '';
|
|
const attendeeName = getSafelyUnescapedTextFromVCalendar((attendeeNameInfo.split(';')[0] || '').trim());
|
|
|
|
if (!attendeeEmail) {
|
|
continue;
|
|
}
|
|
|
|
const attendee: CalendarEventAttendee = {
|
|
email: attendeeEmail,
|
|
status: attendeeStatus,
|
|
};
|
|
|
|
if (attendeeName) {
|
|
attendee.name = attendeeName;
|
|
}
|
|
|
|
partialCalendarEvent.attendees = [...(partialCalendarEvent?.attendees || []), attendee];
|
|
}
|
|
|
|
if (line.startsWith('ACTION:')) {
|
|
const reminderType =
|
|
(line.replace('ACTION:', '').trim().toLowerCase() || 'display') as CalendarEventReminder['type'];
|
|
|
|
partialCalendarReminder.type = reminderType;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('TRIGGER:') || line.startsWith('TRIGGER;')) {
|
|
const triggerInfo = line.split(':')[1] || '';
|
|
let triggerDate = new Date(partialCalendarEvent.startDate || new Date());
|
|
|
|
if (line.includes('DATE-TIME')) {
|
|
triggerDate = parseIcsDate(triggerInfo);
|
|
} else {
|
|
const triggerHoursMatch = triggerInfo.match(/(\d+(?:H))/);
|
|
const triggerMinutesMatch = triggerInfo.match(/(\d+(?:M))/);
|
|
const triggerSecondsMatch = triggerInfo.match(/(\d+(?:S))/);
|
|
|
|
const isNegative = triggerInfo.startsWith('-');
|
|
|
|
if (triggerHoursMatch && triggerHoursMatch.length > 0) {
|
|
const triggerHours = parseInt(triggerHoursMatch[0], 10);
|
|
|
|
if (isNegative) {
|
|
triggerDate.setUTCHours(triggerDate.getUTCHours() - triggerHours);
|
|
} else {
|
|
triggerDate.setUTCHours(triggerHours);
|
|
}
|
|
}
|
|
|
|
if (triggerMinutesMatch && triggerMinutesMatch.length > 0) {
|
|
const triggerMinutes = parseInt(triggerMinutesMatch[0], 10);
|
|
|
|
if (isNegative) {
|
|
triggerDate.setUTCMinutes(triggerDate.getUTCMinutes() - triggerMinutes);
|
|
} else {
|
|
triggerDate.setUTCMinutes(triggerMinutes);
|
|
}
|
|
}
|
|
|
|
if (triggerSecondsMatch && triggerSecondsMatch.length > 0) {
|
|
const triggerSeconds = parseInt(triggerSecondsMatch[0], 10);
|
|
|
|
if (isNegative) {
|
|
triggerDate.setUTCSeconds(triggerDate.getUTCSeconds() - triggerSeconds);
|
|
} else {
|
|
triggerDate.setUTCSeconds(triggerSeconds);
|
|
}
|
|
}
|
|
}
|
|
|
|
partialCalendarReminder.startDate = triggerDate.toISOString();
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('RRULE:')) {
|
|
const rRule = line.replace('RRULE:', '').trim();
|
|
|
|
if (!rRule) {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.isRecurring = true;
|
|
partialCalendarEvent.recurringRrule = rRule;
|
|
partialCalendarEvent.sequence = partialCalendarEvent.sequence || 0;
|
|
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith('SEQUENCE:')) {
|
|
const sequence = line.replace('SEQUENCE:', '').trim();
|
|
|
|
if (!sequence || sequence === '0') {
|
|
continue;
|
|
}
|
|
|
|
partialCalendarEvent.sequence = parseInt(sequence, 10);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return partialCalendarEvents as CalendarEvent[];
|
|
}
|
|
|
|
// NOTE: Considers weeks starting Monday, not Sunday
|
|
export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] {
|
|
const year = date.getUTCFullYear();
|
|
const month = date.getUTCMonth();
|
|
|
|
const firstOfMonth = new Date(year, month, 1);
|
|
const lastOfMonth = new Date(year, month + 1, 0);
|
|
|
|
const daysToShow = firstOfMonth.getUTCDay() + (firstOfMonth.getUTCDay() === 0 ? 6 : -1) + lastOfMonth.getUTCDate();
|
|
|
|
const weekCount = Math.ceil(daysToShow / 7);
|
|
|
|
const weeks: { date: Date; isSameMonth: boolean }[][] = [];
|
|
|
|
const startingDate = new Date(firstOfMonth);
|
|
startingDate.setUTCDate(
|
|
startingDate.getUTCDate() - Math.abs(firstOfMonth.getUTCDay() === 0 ? 6 : (firstOfMonth.getUTCDay() - 1)),
|
|
);
|
|
|
|
for (let weekIndex = 0; weeks.length < weekCount; ++weekIndex) {
|
|
for (let dayIndex = 0; dayIndex < 7; ++dayIndex) {
|
|
if (!Array.isArray(weeks[weekIndex])) {
|
|
weeks[weekIndex] = [];
|
|
}
|
|
|
|
const weekDayDate = new Date(startingDate);
|
|
weekDayDate.setUTCDate(weekDayDate.getUTCDate() + (dayIndex + weekIndex * 7));
|
|
|
|
const isSameMonth = weekDayDate.getUTCMonth() === month;
|
|
|
|
weeks[weekIndex].push({ date: weekDayDate, isSameMonth });
|
|
}
|
|
}
|
|
|
|
return weeks;
|
|
}
|
|
|
|
// NOTE: Considers week starting Monday, not Sunday
|
|
export function getDaysForWeek(
|
|
date: Date,
|
|
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
|
|
const shortIsoDate = new Date().toISOString().substring(0, 10);
|
|
const currentHour = new Date().getUTCHours();
|
|
|
|
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
|
|
|
const startingDate = new Date(date);
|
|
startingDate.setUTCDate(
|
|
startingDate.getUTCDate() - Math.abs(startingDate.getUTCDay() === 0 ? 6 : (startingDate.getUTCDay() - 1)),
|
|
);
|
|
|
|
for (let dayIndex = 0; days.length < 7; ++dayIndex) {
|
|
const dayDate = new Date(startingDate);
|
|
dayDate.setUTCDate(dayDate.getUTCDate() + dayIndex);
|
|
|
|
const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate;
|
|
|
|
days[dayIndex] = {
|
|
date: dayDate,
|
|
isSameDay,
|
|
hours: [],
|
|
};
|
|
|
|
for (let hourIndex = 0; hourIndex < 24; ++hourIndex) {
|
|
const dayHourDate = new Date(dayDate);
|
|
dayHourDate.setUTCHours(hourIndex);
|
|
|
|
const isCurrentHour = isSameDay && hourIndex === currentHour;
|
|
|
|
days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour });
|
|
}
|
|
}
|
|
|
|
return days;
|
|
}
|
|
|
|
export function getCalendarEventStyle(
|
|
calendarEvent: CalendarEvent,
|
|
calendars: Calendar[],
|
|
): { backgroundColor?: string; border?: string } {
|
|
const matchingCalendar = calendars.find((calendar) => calendar.uid === calendarEvent.calendarId);
|
|
const hexColor = matchingCalendar?.calendarColor || getColorAsHex('bg-gray-700');
|
|
|
|
return calendarEvent.transparency === 'opaque'
|
|
? {
|
|
backgroundColor: hexColor,
|
|
}
|
|
: {
|
|
border: `1px solid ${hexColor}`,
|
|
};
|
|
}
|
|
|
|
type RRuleFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
|
type RRuleWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
|
|
type RRuleType = 'FREQ' | 'BYDAY' | 'BYMONTHDAY' | 'BYHOUR' | 'BYMINUTE' | 'COUNT' | 'INTERVAL' | 'UNTIL';
|
|
|
|
const rRuleToFrequencyOrWeekDay = new Map<RRuleFrequency | RRuleWeekDay, string>([
|
|
['DAILY', 'day'],
|
|
['WEEKLY', 'week'],
|
|
['MONTHLY', 'month'],
|
|
['MO', 'Monday'],
|
|
['TU', 'Tuesday'],
|
|
['WE', 'Wednesday'],
|
|
['TH', 'Thursday'],
|
|
['FR', 'Friday'],
|
|
['SA', 'Saturday'],
|
|
['SU', 'Sunday'],
|
|
]);
|
|
|
|
function convertRRuleDaysToWords(day: string | RRuleFrequency | RRuleWeekDay): string {
|
|
if (day.includes(',')) {
|
|
const days = day.split(',') as (typeof day)[];
|
|
return days.map((individualDay) => rRuleToFrequencyOrWeekDay.get(individualDay as RRuleFrequency | RRuleWeekDay))
|
|
.join(', ');
|
|
}
|
|
|
|
return rRuleToFrequencyOrWeekDay.get(day as RRuleFrequency | RRuleWeekDay)!;
|
|
}
|
|
|
|
function getOrdinalSuffix(number: number) {
|
|
const text = ['th', 'st', 'nd', 'rd'] as const;
|
|
const value = number % 100;
|
|
return `${number}${(text[(value - 20) % 10] || text[value] || text[0])}`;
|
|
}
|
|
|
|
export function convertRRuleToWords(
|
|
rRule: string,
|
|
{ capitalizeSentence = true }: { capitalizeSentence?: boolean } = {},
|
|
): string {
|
|
const rulePart = rRule.replace('RRULE:', '');
|
|
|
|
const rulePieces = rulePart.split(';');
|
|
|
|
const parsedRRule: Partial<Record<RRuleType, string>> = {};
|
|
|
|
rulePieces.forEach(function (rulePiece) {
|
|
const keyAndValue = rulePiece.split('=') as [RRuleType, string];
|
|
const [key, value] = keyAndValue;
|
|
|
|
parsedRRule[key] = value;
|
|
});
|
|
|
|
const frequency = parsedRRule.FREQ;
|
|
const byDay = parsedRRule.BYDAY;
|
|
const byMonthDay = parsedRRule.BYMONTHDAY;
|
|
const byHour = parsedRRule.BYHOUR;
|
|
const byMinute = parsedRRule.BYMINUTE;
|
|
const count = parsedRRule.COUNT;
|
|
const interval = parsedRRule.INTERVAL;
|
|
const until = parsedRRule.UNTIL;
|
|
|
|
const words: string[] = [];
|
|
|
|
if (frequency === 'DAILY') {
|
|
if (byHour) {
|
|
if (byMinute) {
|
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day at ${byHour}:${byMinute}`);
|
|
} else {
|
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day at ${byHour}:00`);
|
|
}
|
|
} else {
|
|
words.push(`${capitalizeSentence ? 'Every' : 'every'} day`);
|
|
}
|
|
|
|
if (count) {
|
|
if (count === '1') {
|
|
words.push(`for 1 time`);
|
|
} else {
|
|
words.push(`for ${count} times`);
|
|
}
|
|
}
|
|
|
|
if (until) {
|
|
const untilDate = parseIcsDate(until);
|
|
|
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
|
}
|
|
|
|
return words.join(' ');
|
|
}
|
|
|
|
if (frequency === 'WEEKLY') {
|
|
if (byDay) {
|
|
if (interval && parseInt(interval, 10) > 1) {
|
|
words.push(
|
|
`${capitalizeSentence ? 'Every' : 'every'} ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on ${
|
|
convertRRuleDaysToWords(byDay)
|
|
}`,
|
|
);
|
|
} else {
|
|
words.push(
|
|
`${capitalizeSentence ? 'Every' : 'every'} ${rRuleToFrequencyOrWeekDay.get(frequency)} on ${
|
|
convertRRuleDaysToWords(byDay)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (byMonthDay) {
|
|
words.push(`the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`);
|
|
}
|
|
|
|
if (count) {
|
|
if (count === '1') {
|
|
words.push(`for 1 time`);
|
|
} else {
|
|
words.push(`for ${count} times`);
|
|
}
|
|
}
|
|
|
|
if (until) {
|
|
const untilDate = parseIcsDate(until);
|
|
|
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
|
}
|
|
|
|
return words.join(' ');
|
|
}
|
|
|
|
// monthly
|
|
if (frequency === 'MONTHLY' && byMonthDay) {
|
|
if (interval && parseInt(interval, 10) > 1) {
|
|
words.push(
|
|
`${capitalizeSentence ? 'Every' : 'every'} ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on the ${
|
|
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
|
}`,
|
|
);
|
|
} else {
|
|
words.push(
|
|
`${capitalizeSentence ? 'Every' : 'every'} ${rRuleToFrequencyOrWeekDay.get(frequency)} on the ${
|
|
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
|
}`,
|
|
);
|
|
}
|
|
|
|
if (count) {
|
|
if (count === '1') {
|
|
words.push(` for 1 time`);
|
|
} else {
|
|
words.push(` for ${count} times`);
|
|
}
|
|
}
|
|
|
|
if (until) {
|
|
const untilDate = parseIcsDate(until);
|
|
|
|
words.push(`until ${untilDate.toISOString().substring(0, 10)}`);
|
|
}
|
|
|
|
return words.join(' ');
|
|
}
|
|
|
|
return words.join(' ');
|
|
}
|
|
|
|
type ParsedTimeZone = {
|
|
id: string;
|
|
label: string;
|
|
utcOffset: number;
|
|
};
|
|
|
|
function offsetStringToNumeric(offsetString: string): number {
|
|
const sign = offsetString.startsWith('GMT-') ? -1 : 1;
|
|
const [hours, minutes] = offsetString.slice(4).split(':').map(Number);
|
|
return sign * (hours * 60 + minutes) || 0;
|
|
}
|
|
|
|
export function getTimeZones(): ParsedTimeZone[] {
|
|
const supportedTimeZones = Intl.supportedValuesOf('timeZone');
|
|
|
|
const timezones: ParsedTimeZone[] = [{
|
|
id: 'UTC',
|
|
label: 'UTC',
|
|
utcOffset: 0,
|
|
}];
|
|
|
|
const now = new Date();
|
|
|
|
for (const tz of supportedTimeZones) {
|
|
// Some browsers return UTC as a timezone, so we can skip it to avoid duplicates
|
|
if (timezones.find((timezone) => timezone.id === tz)) {
|
|
continue;
|
|
}
|
|
|
|
const offsetFormat = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: tz,
|
|
timeZoneName: 'longOffset',
|
|
});
|
|
|
|
const formattedOffset = offsetFormat.format(now);
|
|
const [, offsetString] = formattedOffset.split(', ');
|
|
|
|
const offsetNumeric = offsetStringToNumeric(offsetString);
|
|
|
|
timezones.push({
|
|
id: tz,
|
|
label: `${tz} (${offsetString})`,
|
|
utcOffset: offsetNumeric,
|
|
});
|
|
}
|
|
|
|
return timezones;
|
|
}
|