Files
bewcloud/lib/utils/calendar.ts
Bruno Bernardino 49dbc724c8 Fix creating and deleting calendars
This fixes the creation and deletion of calendars to include a color and the proper chosen name.
2025-09-06 19:17:48 +01:00

962 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',
'bg-gray-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',
'#384354',
] as const;
export function getColorAsHex(
calendarColor: (typeof CALENDAR_COLOR_OPTIONS)[number],
): (typeof CALENDAR_COLOR_OPTIONS_HEX)[number] {
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;
}