diff --git a/README.md b/README.md index f898872..4b787ab 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Just push to the `main` branch. ## How does Contacts/CardDav and Calendar/CalDav work? -CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and for CalDav is not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). +CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and the "Calendar" client for CalDav is available since [v2.5.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.5.0). [Check this tag/release for custom-made server code where it was all mostly working, except for many edge cases, if you're interested](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s //collections/collection-root// //collections/collection-root//`). @@ -107,7 +107,7 @@ In order to share a calendar, you can either have a shared user, or you can syml ## How does private file sharing work? -Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks). +Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working, if you're interested](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks). You can simply `ln -s /// ///` to create a shared directory between two users, and the same directory can have different names, now. diff --git a/bewcloud.config.sample.ts b/bewcloud.config.sample.ts index 0e80a1a..3c176e0 100644 --- a/bewcloud.config.sample.ts +++ b/bewcloud.config.sample.ts @@ -20,7 +20,7 @@ const config: PartialDeep = { // allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) // }, // core: { - // enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts], // dashboard and files cannot be disabled + // enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // dashboard and files cannot be disabled // }, // visuals: { // title: 'My own cloud', diff --git a/components/Header.tsx b/components/Header.tsx index 6258e05..53bb605 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -62,6 +62,12 @@ export default function Header({ route, user, enabledApps }: Data) { label: 'Contacts', } : null, + enabledApps.includes('calendar') + ? { + url: '/calendar', + label: 'Calendar', + } + : null, ]; const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[]; @@ -87,6 +93,10 @@ export default function Header({ route, user, enabledApps }: Data) { pageLabel = 'Contacts'; } + if (route.startsWith('/calendar')) { + pageLabel = 'Calendar'; + } + return ( <> diff --git a/components/calendar/AddEventModal.tsx b/components/calendar/AddEventModal.tsx new file mode 100644 index 0000000..124c990 --- /dev/null +++ b/components/calendar/AddEventModal.tsx @@ -0,0 +1,163 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; + +interface AddEventModalProps { + isOpen: boolean; + initialStartDate?: Date; + initiallyAllDay?: boolean; + calendars: Calendar[]; + onClickSave: (newEvent: CalendarEvent) => Promise; + onClose: () => void; +} + +export default function AddEventModal( + { isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps, +) { + const newEvent = useSignal(null); + + useEffect(() => { + if (!isOpen) { + newEvent.value = null; + } else { + const startDate = new Date(initialStartDate || new Date()); + + startDate.setUTCMinutes(0); + startDate.setUTCSeconds(0); + startDate.setUTCMilliseconds(0); + + const endDate = new Date(startDate); + endDate.setUTCHours(startDate.getUTCHours() + 1); + + if (initiallyAllDay) { + startDate.setUTCHours(9); + endDate.setUTCHours(18); + } + + newEvent.value = { + uid: 'new', + url: '', + title: '', + calendarId: calendars[0]!.uid!, + startDate: startDate, + endDate: endDate, + isAllDay: initiallyAllDay || false, + organizerEmail: '', + transparency: 'opaque', + }; + } + }, [isOpen]); + + return ( + <> +
+
+ +
+

New Event

+
+
+ + newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }} + placeholder='Dentist' + /> +
+
+ +
+ + calendar.uid === newEvent.value?.calendarId) + ?.calendarColor, + }} + title={calendars.find((calendar) => calendar.uid === newEvent.value?.calendarId)?.calendarColor} + > + +
+
+
+ + + newEvent.value = { ...newEvent.value!, startDate: new Date(event.currentTarget.value) }} + /> + +
+
+ + newEvent.value = { ...newEvent.value!, endDate: new Date(event.currentTarget.value) }} + /> + +
+
+ + newEvent.value = { ...newEvent.value!, isAllDay: event.currentTarget.checked }} + /> +
+
+
+ + +
+
+ + ); +} diff --git a/components/calendar/CalendarViewDay.tsx b/components/calendar/CalendarViewDay.tsx new file mode 100644 index 0000000..f15aa99 --- /dev/null +++ b/components/calendar/CalendarViewDay.tsx @@ -0,0 +1,229 @@ +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { getCalendarEventStyle } from '/lib/utils/calendar.ts'; + +interface CalendarViewDayProps { + startDate: Date; + visibleCalendars: Calendar[]; + calendarEvents: CalendarEvent[]; + onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; + onClickOpenEvent: (calendarEvent: CalendarEvent) => void; + timezoneId: string; +} + +export default function CalendarViewDay( + { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewDayProps, +) { + const today = new Date().toISOString().substring(0, 10); + + const hourFormat = new Intl.DateTimeFormat('en-GB', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: timezoneId, // Calendar dates are parsed are stored without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + const dayFormat = new Intl.DateTimeFormat('en-GB', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + + const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => { + if (!calendarEvent.isAllDay) { + return false; + } + + const startDayDate = new Date(startDate); + const endDayDate = new Date(startDate); + endDayDate.setUTCHours(23); + endDayDate.setUTCMinutes(59); + endDayDate.setUTCSeconds(59); + endDayDate.setUTCMilliseconds(999); + + const eventStartDate = new Date(calendarEvent.startDate); + const eventEndDate = new Date(calendarEvent.endDate); + + // Event starts and ends on this day + if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) { + return true; + } + + // Event starts before and ends after this day + if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) { + return true; + } + + // Event starts on and ends after this day + if ( + eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate + ) { + return true; + } + + // Event starts before and ends on this day + if ( + eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate + ) { + return true; + } + + return false; + }); + + const hours: { date: Date; isCurrentHour: boolean }[] = Array.from({ length: 24 }).map((_, index) => { + const hourNumber = index; + + const date = new Date(startDate); + date.setUTCHours(hourNumber); + + const shortIsoDate = date.toISOString().substring(0, 10); + + const isCurrentHour = shortIsoDate === today && new Date().getUTCHours() === hourNumber; + + return { + date, + isCurrentHour, + }; + }); + + return ( +
+
+
+ {dayFormat.format(startDate)} +
+
+
+
+ {allDayEvents.length > 0 + ? ( +
+ +
    + {allDayEvents.map((calendarEvent) => ( +
  1. + onClickOpenEvent(calendarEvent)} + > +

    + {calendarEvent.title} +

    +
    +
  2. + ))} +
+
+ ) + : null} + {hours.map((hour, hourIndex) => { + const shortIsoDate = hour.date.toISOString().substring(0, 10); + + const startHourDate = new Date(shortIsoDate); + startHourDate.setUTCHours(hour.date.getUTCHours()); + const endHourDate = new Date(shortIsoDate); + endHourDate.setUTCHours(hour.date.getUTCHours()); + endHourDate.setUTCMinutes(59); + endHourDate.setUTCSeconds(59); + endHourDate.setUTCMilliseconds(999); + + const isLastHour = hourIndex === 23; + + const hourEvents = calendarEvents.filter((calendarEvent) => { + if (calendarEvent.isAllDay) { + return false; + } + + const eventStartDate = new Date(calendarEvent.startDate); + const eventEndDate = new Date(calendarEvent.endDate); + eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour + + // Event starts and ends on this hour + if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) { + return true; + } + + // Event starts before and ends after this hour + if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) { + return true; + } + + // Event starts on and ends after this hour + if ( + eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate + ) { + return true; + } + + // Event starts before and ends on this hour + if ( + eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate + ) { + return true; + } + + return false; + }); + + return ( +
+ + {hourEvents.length > 0 + ? ( +
    + {hourEvents.map((hourEvent) => ( +
  1. + onClickOpenEvent(hourEvent)} + > + +

    + {hourEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ ); + })} +
+
+
+ ); +} diff --git a/components/calendar/CalendarViewMonth.tsx b/components/calendar/CalendarViewMonth.tsx new file mode 100644 index 0000000..6ca239a --- /dev/null +++ b/components/calendar/CalendarViewMonth.tsx @@ -0,0 +1,166 @@ +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { getCalendarEventStyle, getWeeksForMonth } from '/lib/utils/calendar.ts'; + +interface CalendarViewWeekProps { + startDate: Date; + visibleCalendars: Calendar[]; + calendarEvents: CalendarEvent[]; + onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; + onClickOpenEvent: (calendarEvent: CalendarEvent) => void; + timezoneId: string; +} + +export default function CalendarViewWeek( + { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps, +) { + const today = new Date().toISOString().substring(0, 10); + + const hourFormat = new Intl.DateTimeFormat('en-GB', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + + const weeks = getWeeksForMonth(new Date(startDate)); + + return ( +
+
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+
+ {weeks.map((week, weekIndex) => + week.map((day, dayIndex) => { + const shortIsoDate = day.date.toISOString().substring(0, 10); + + const startDayDate = new Date(shortIsoDate); + const endDayDate = new Date(shortIsoDate); + endDayDate.setUTCHours(23); + endDayDate.setUTCMinutes(59); + endDayDate.setUTCSeconds(59); + endDayDate.setUTCMilliseconds(999); + + const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0; + const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1; + + const isToday = today === shortIsoDate; + + const dayEvents = calendarEvents.filter((calendarEvent) => { + const eventStartDate = new Date(calendarEvent.startDate); + const eventEndDate = new Date(calendarEvent.endDate); + + // Event starts and ends on this day + if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) { + return true; + } + + // Event starts before and ends after this day + if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) { + return true; + } + + // Event starts on and ends after this day + if ( + eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate + ) { + return true; + } + + // Event starts before and ends on this day + if ( + eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate + ) { + return true; + } + + return false; + }); + + return ( +
+ + {dayEvents.length > 0 + ? ( +
    + {[...dayEvents].slice(0, 2).map((dayEvent) => ( +
  1. + onClickOpenEvent(dayEvent)} + > + +

    + {dayEvent.title} +

    +
    +
  2. + ))} + {dayEvents.length > 2 + ? ( +
  3. + +

    + ...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'} +

    +
    +
  4. + ) + : null} +
+ ) + : null} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/components/calendar/CalendarViewWeek.tsx b/components/calendar/CalendarViewWeek.tsx new file mode 100644 index 0000000..14662c7 --- /dev/null +++ b/components/calendar/CalendarViewWeek.tsx @@ -0,0 +1,225 @@ +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { getCalendarEventStyle, getDaysForWeek } from '/lib/utils/calendar.ts'; + +interface CalendarViewWeekProps { + startDate: Date; + visibleCalendars: Calendar[]; + calendarEvents: CalendarEvent[]; + onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; + onClickOpenEvent: (calendarEvent: CalendarEvent) => void; + timezoneId: string; +} + +export default function CalendarViewWeek( + { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps, +) { + const today = new Date().toISOString().substring(0, 10); + + const hourFormat = new Intl.DateTimeFormat('en-GB', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + const weekDayFormat = new Intl.DateTimeFormat('en-GB', { + weekday: 'short', + day: 'numeric', + month: '2-digit', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + + const days = getDaysForWeek(new Date(startDate)); + + return ( +
+
+ {days.map((day, dayIndex) => { + const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => { + if (!calendarEvent.isAllDay) { + return false; + } + + const startDayDate = new Date(day.date); + const endDayDate = new Date(day.date); + endDayDate.setUTCHours(23); + endDayDate.setUTCMinutes(59); + endDayDate.setUTCSeconds(59); + endDayDate.setUTCMilliseconds(999); + + const eventStartDate = new Date(calendarEvent.startDate); + const eventEndDate = new Date(calendarEvent.endDate); + + // Event starts and ends on this day + if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) { + return true; + } + + // Event starts before and ends after this day + if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) { + return true; + } + + // Event starts on and ends after this day + if ( + eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate + ) { + return true; + } + + // Event starts before and ends on this day + if ( + eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate + ) { + return true; + } + + return false; + }); + + const isFirstDay = dayIndex === 0; + const isLastDay = dayIndex === 6; + const isToday = new Date(day.date).toISOString().substring(0, 10) === today; + + return ( + <> +
+ {weekDayFormat.format(day.date)} +
+
+ + {allDayEvents.length > 0 + ? ( +
    + {allDayEvents.map((calendarEvent) => ( +
  1. + onClickOpenEvent(calendarEvent)} + > +

    + {calendarEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ {day.hours.map((hour, hourIndex) => { + const shortIsoDate = hour.date.toISOString().substring(0, 10); + + const startHourDate = new Date(shortIsoDate); + startHourDate.setUTCHours(hour.date.getUTCHours()); + const endHourDate = new Date(shortIsoDate); + endHourDate.setUTCHours(hour.date.getUTCHours()); + endHourDate.setUTCMinutes(59); + endHourDate.setUTCSeconds(59); + endHourDate.setUTCMilliseconds(999); + + const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0; + const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6; + + const hourEvents = calendarEvents.filter((calendarEvent) => { + if (calendarEvent.isAllDay) { + return false; + } + + const eventStartDate = new Date(calendarEvent.startDate); + const eventEndDate = new Date(calendarEvent.endDate); + eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour + + // Event starts and ends on this hour + if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) { + return true; + } + + // Event starts before and ends after this hour + if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) { + return true; + } + + // Event starts on and ends after this hour + if ( + eventStartDate >= startHourDate && eventStartDate <= endHourDate && + eventEndDate >= endHourDate + ) { + return true; + } + + // Event starts before and ends on this hour + if ( + eventStartDate <= startHourDate && eventEndDate >= startHourDate && + eventEndDate <= endHourDate + ) { + return true; + } + + return false; + }); + + return ( +
+ + {hourEvents.length > 0 + ? ( +
    + {hourEvents.map((hourEvent) => ( +
  1. + onClickOpenEvent(hourEvent)} + > + +

    + {hourEvent.title} +

    +
    +
  2. + ))} +
+ ) + : null} +
+ ); + })} + + ); + })} +
+
+ ); +} diff --git a/components/calendar/ImportEventsModal.tsx b/components/calendar/ImportEventsModal.tsx new file mode 100644 index 0000000..2f5b90e --- /dev/null +++ b/components/calendar/ImportEventsModal.tsx @@ -0,0 +1,86 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Calendar } from '/lib/models/calendar.ts'; + +interface ImportEventsModalProps { + isOpen: boolean; + calendars: Calendar[]; + onClickImport: (calendarId: string) => void; + onClose: () => void; +} + +export default function ImportEventsModal( + { isOpen, calendars, onClickImport, onClose }: ImportEventsModalProps, +) { + const newCalendarId = useSignal(null); + + useEffect(() => { + if (!isOpen) { + newCalendarId.value = null; + } else { + newCalendarId.value = calendars[0]!.uid!; + } + }, [isOpen]); + + return ( + <> +
+
+ +
+

Import Events

+
+
+ +
+ + calendar.uid === newCalendarId.value)?.calendarColor, + }} + title={calendars.find((calendar) => calendar.uid === newCalendarId.value)?.calendarColor} + > + +
+
+
+
+ + +
+
+ + ); +} diff --git a/components/calendar/MainCalendar.tsx b/components/calendar/MainCalendar.tsx new file mode 100644 index 0000000..7bb7daa --- /dev/null +++ b/components/calendar/MainCalendar.tsx @@ -0,0 +1,655 @@ +import { useSignal } from '@preact/signals'; + +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { capitalizeWord } from '/lib/utils/misc.ts'; +import { generateVCalendar } from '/lib/utils/calendar.ts'; +import { + RequestBody as ExportRequestBody, + ResponseBody as ExportResponseBody, +} from '/routes/api/calendar/export-events.tsx'; +import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx'; +import { + RequestBody as DeleteRequestBody, + ResponseBody as DeleteResponseBody, +} from '/routes/api/calendar/delete-event.tsx'; +import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx'; +import CalendarViewDay from './CalendarViewDay.tsx'; +import CalendarViewWeek from './CalendarViewWeek.tsx'; +import CalendarViewMonth from './CalendarViewMonth.tsx'; +import AddEventModal from './AddEventModal.tsx'; +import ViewEventModal from './ViewEventModal.tsx'; +import SearchEvents from './SearchEvents.tsx'; +import ImportEventsModal from './ImportEventsModal.tsx'; + +interface MainCalendarProps { + initialCalendars: Calendar[]; + initialCalendarEvents: CalendarEvent[]; + view: 'day' | 'week' | 'month'; + startDate: string; + baseUrl: string; + timezoneId: string; + timezoneUtcOffset: number; +} + +export default function MainCalendar( + { initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }: + MainCalendarProps, +) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isExporting = useSignal(false); + const isImporting = useSignal(false); + const calendars = useSignal(initialCalendars); + const isViewOptionsDropdownOpen = useSignal(false); + const isImportExportOptionsDropdownOpen = useSignal(false); + const calendarEvents = useSignal(initialCalendarEvents); + const openEventModal = useSignal< + { isOpen: boolean; calendar?: typeof initialCalendars[number]; calendarEvent?: CalendarEvent } + >({ isOpen: false }); + const newEventModal = useSignal<{ isOpen: boolean; initialStartDate?: Date; initiallyAllDay?: boolean }>({ + isOpen: false, + }); + const openImportModal = useSignal< + { isOpen: boolean } + >({ isOpen: false }); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: 'long', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + const today = new Date().toISOString().substring(0, 10); + + const visibleCalendars = calendars.value.filter((calendar) => calendar.isVisible); + + function onClickAddEvent(startDate = new Date(), isAllDay = false) { + if (newEventModal.value.isOpen) { + newEventModal.value = { + isOpen: false, + }; + return; + } + + if (calendars.value.length === 0) { + alert('You need to create a calendar first!'); + return; + } + + newEventModal.value = { + isOpen: true, + initialStartDate: startDate, + initiallyAllDay: isAllDay, + }; + } + + async function onClickSaveNewEvent(newEvent: CalendarEvent) { + if (isAdding.value) { + return; + } + + if (!newEvent) { + return; + } + + isAdding.value = true; + + try { + const requestBody: AddRequestBody = { + calendarIds: visibleCalendars.map((calendar) => calendar.uid!), + calendarView: view, + calendarStartDate: startDate, + calendarId: newEvent.calendarId, + title: newEvent.title, + startDate: new Date(newEvent.startDate).toISOString(), + endDate: new Date(newEvent.endDate).toISOString(), + isAllDay: newEvent.isAllDay, + }; + const response = await fetch(`/api/calendar/add-event`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as AddResponseBody; + + if (!result.success) { + throw new Error('Failed to add event!'); + } + + calendarEvents.value = [...result.newCalendarEvents]; + + newEventModal.value = { + isOpen: false, + }; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function onCloseNewEvent() { + newEventModal.value = { + isOpen: false, + }; + } + + function toggleImportExportOptionsDropdown() { + isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value; + } + + function toggleViewOptionsDropdown() { + isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value; + } + + function onClickOpenEvent(calendarEvent: CalendarEvent) { + if (openEventModal.value.isOpen) { + openEventModal.value = { + isOpen: false, + }; + return; + } + + const calendar = calendars.value.find((calendar) => calendar.uid === calendarEvent.calendarId)!; + + openEventModal.value = { + isOpen: true, + calendar, + calendarEvent, + }; + } + + async function onClickDeleteEvent(calendarEventId: string) { + if (confirm('Are you sure you want to delete this event?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { + calendarIds: visibleCalendars.map((calendar) => calendar.uid!), + calendarView: view, + calendarStartDate: startDate, + calendarEventId, + calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.uid === calendarEventId)!.calendarId, + }; + const response = await fetch(`/api/calendar/delete-event`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete event!'); + } + + calendarEvents.value = [...result.newCalendarEvents]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + + openEventModal.value = { isOpen: false }; + } + } + + function onCloseOpenEvent() { + openEventModal.value = { + isOpen: false, + }; + } + + function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') { + const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString() + .substring(0, 10); + const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString() + .substring(0, 10); + const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString() + .substring(0, 10); + const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString() + .substring(0, 10); + const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString() + .substring(0, 10); + const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString() + .substring(0, 10); + + if (changeTo === 'today') { + if (today === startDate) { + return; + } + + window.location.href = `/calendar?view=${view}&startDate=${today}`; + return; + } + + if (changeTo === 'previous') { + let newStartDate = previousMonth; + + if (view === 'day') { + newStartDate = previousDay; + } else if (view === 'week') { + newStartDate = previousWeek; + } + + if (newStartDate === startDate) { + return; + } + + window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`; + return; + } + + let newStartDate = nextMonth; + + if (view === 'day') { + newStartDate = nextDay; + } else if (view === 'week') { + newStartDate = nextWeek; + } + + if (newStartDate === startDate) { + return; + } + + window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`; + } + + function onClickChangeView(newView: MainCalendarProps['view']) { + if (view === newView) { + isViewOptionsDropdownOpen.value = false; + return; + } + + window.location.href = `/calendar?view=${newView}&startDate=${startDate}`; + } + + function onClickImportICS() { + openImportModal.value = { isOpen: true }; + isImportExportOptionsDropdownOpen.value = false; + } + + function onClickChooseImportCalendar(calendarId: string) { + isImportExportOptionsDropdownOpen.value = false; + + if (isImporting.value) { + return; + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + + fileInput.onchange = (event) => { + const files = (event.target as HTMLInputElement)?.files!; + const file = files[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = async (fileRead) => { + const importFileContents = fileRead.target?.result; + + if (!importFileContents || isImporting.value) { + return; + } + + isImporting.value = true; + + openImportModal.value = { isOpen: false }; + + try { + const icsToImport = importFileContents!.toString(); + + const requestBody: ImportRequestBody = { + icsToImport, + calendarIds: visibleCalendars.map((calendar) => calendar.uid!), + calendarView: view, + calendarStartDate: startDate, + calendarId, + }; + + const response = await fetch(`/api/calendar/import`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ImportResponseBody; + + if (!result.success) { + throw new Error('Failed to import file!'); + } + + calendarEvents.value = [...result.newCalendarEvents]; + } catch (error) { + console.error(error); + } + + isImporting.value = false; + }; + + reader.readAsText(file, 'UTF-8'); + }; + } + + async function onClickExportICS() { + isImportExportOptionsDropdownOpen.value = false; + + if (isExporting.value) { + return; + } + + isExporting.value = true; + + const fileName = ['calendar-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics'] + .join(''); + + try { + const requestBody: ExportRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.uid!) }; + const response = await fetch(`/api/calendar/export-events`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ExportResponseBody; + + if (!result.success) { + throw new Error('Failed to get contact!'); + } + + const exportContents = generateVCalendar([...result.calendarEvents]); + + // Add content-type + const vCardContent = ['data:text/calendar; charset=utf-8,', encodeURIComponent(exportContents)].join(''); + + // Download the file + const data = vCardContent; + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + } catch (error) { + console.error(error); + } + + isExporting.value = false; + } + + return ( + <> +
+
+
+ Manage calendars + +
+
+ +
+

+ +

+
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+ +
+ {view === 'day' + ? ( + + ) + : null} + {view === 'week' + ? ( + + ) + : null} + {view === 'month' + ? ( + + ) + : null} + + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} + +
+ +
+ CalDav URL:{' '} + {baseUrl}/caldav +
+ + + + + + { + openImportModal.value = { isOpen: false }; + }} + /> + + ); +} diff --git a/components/calendar/SearchEvents.tsx b/components/calendar/SearchEvents.tsx new file mode 100644 index 0000000..cbc2f45 --- /dev/null +++ b/components/calendar/SearchEvents.tsx @@ -0,0 +1,152 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx'; +import { getColorAsHex } from '/lib/utils/calendar.ts'; +interface SearchEventsProps { + calendars: Calendar[]; + onClickOpenEvent: (calendarEvent: CalendarEvent) => void; +} + +export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) { + const isSearching = useSignal(false); + const areResultsVisible = useSignal(false); + const calendarEvents = useSignal([]); + const searchTimeout = useSignal>(0); + const closeTimeout = useSignal>(0); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', // Calendar dates are parsed without timezone info, so we need to force to UTC so it's consistent across db, server, and client + }); + + const calendarIds = calendars.map((calendar) => calendar.uid!); + + function searchEvents(searchTerm: string) { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (searchTerm.trim().length < 2) { + return; + } + + areResultsVisible.value = false; + + searchTimeout.value = setTimeout(async () => { + isSearching.value = true; + + try { + const requestBody: RequestBody = { calendarIds, searchTerm }; + const response = await fetch(`/api/calendar/search-events`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to search events!'); + } + + calendarEvents.value = result.matchingCalendarEvents; + + if (calendarEvents.value.length > 0) { + areResultsVisible.value = true; + } + } catch (error) { + console.error(error); + } + + isSearching.value = false; + }, 500); + } + + function onFocus() { + if (calendarEvents.value.length > 0) { + areResultsVisible.value = true; + } + } + + function onBlur() { + if (closeTimeout.value) { + clearTimeout(closeTimeout.value); + } + + closeTimeout.value = setTimeout(() => { + areResultsVisible.value = false; + }, 300); + } + + useEffect(() => { + return () => { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (closeTimeout.value) { + clearTimeout(closeTimeout.value); + } + }; + }, []); + + return ( + <> + searchEvents(event.currentTarget.value)} + onFocus={() => onFocus()} + onBlur={() => onBlur()} + /> + {isSearching.value ? : null} + {areResultsVisible.value + ? ( +
+ +
+ ) + : null} + + ); +} diff --git a/components/calendar/ViewEventModal.tsx b/components/calendar/ViewEventModal.tsx new file mode 100644 index 0000000..b876174 --- /dev/null +++ b/components/calendar/ViewEventModal.tsx @@ -0,0 +1,159 @@ +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { convertRRuleToWords } from '/lib/utils/calendar.ts'; + +interface ViewEventModalProps { + isOpen: boolean; + calendarEvent: CalendarEvent; + calendar: Calendar; + onClickDelete: (eventId: string) => void; + onClose: () => void; + timezoneId: string; +} + +export default function ViewEventModal( + { isOpen, calendarEvent, calendar, onClickDelete, onClose, timezoneId }: ViewEventModalProps, +) { + if (!calendarEvent || !calendar) { + return null; + } + + const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + const hourFormat = new Intl.DateTimeFormat('en-GB', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client + }); + + return ( + <> +
+
+ +
+

{calendarEvent.title}

+
+ + {calendarEvent.startDate ? allDayEventDateFormat.format(new Date(calendarEvent.startDate)) : ''} + + {calendarEvent.isAllDay ? All-day : ( + + {calendarEvent.startDate ? hourFormat.format(new Date(calendarEvent.startDate)) : ''} -{' '} + {calendarEvent.endDate ? hourFormat.format(new Date(calendarEvent.endDate)) : ''} + + )} +
+
+ + {calendar.displayName} + + +
+ {calendarEvent.description + ? ( +
+
+ {calendarEvent.description} +
+
+ ) + : null} + {calendarEvent.eventUrl + ? ( +
+ + {calendarEvent.eventUrl} + +
+ ) + : null} + {calendarEvent.location + ? ( +
+ + {calendarEvent.location} + +
+ ) + : null} + {Array.isArray(calendarEvent.attendees) && calendarEvent.attendees.length > 0 + ? ( +
+ {calendarEvent.attendees.map((attendee) => ( +

+ + {attendee.name || attendee.email} + {' '} + - {attendee.status} +

+ ))} +
+ ) + : null} + {calendarEvent.isRecurring && calendarEvent.recurringRrule + ? ( +
+

+ Repeats {convertRRuleToWords(calendarEvent.recurringRrule, { capitalizeSentence: false })}. +

+
+ ) + : null} + {Array.isArray(calendarEvent.reminders) && calendarEvent.reminders.length > 0 + ? ( +
+ {calendarEvent.reminders.map((reminder) => ( +

+ {reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.startDate))} via{' '} + {reminder.type}. +

+ ))} +
+ ) + : null} +
+ + + Edit + + +
+
+ + ); +} diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index c227913..5f04749 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -48,7 +48,6 @@ interface MainFilesProps { initialPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCalDavEnabled?: boolean; fileShareId?: string; } @@ -59,7 +58,6 @@ export default function MainFiles( initialPath, baseUrl, isFileSharingAllowed, - isCalDavEnabled, fileShareId, }: MainFilesProps, ) { @@ -888,15 +886,6 @@ export default function MainFiles( ) : null} - {!fileShareId && isCalDavEnabled - ? ( -
- CalDav URL:{' '} - {baseUrl}/caldav -
- ) - : null} - {!fileShareId ? ( ; @@ -15,8 +16,10 @@ interface SettingsProps { message: string; }; currency?: SupportedCurrencySymbol; + timezoneId?: string; isExpensesAppEnabled: boolean; isMultiFactorAuthEnabled: boolean; + isCalendarAppEnabled: boolean; helpEmail: string; user: { extra: Pick; @@ -29,7 +32,8 @@ export type Action = | 'change-password' | 'change-dav-password' | 'delete-account' - | 'change-currency'; + | 'change-currency' + | 'change-timezone'; export const actionWords = new Map([ ['change-email', 'change email'], @@ -38,9 +42,10 @@ export const actionWords = new Map([ ['change-dav-password', 'change WebDav password'], ['delete-account', 'delete account'], ['change-currency', 'change currency'], + ['change-timezone', 'change timezone'], ]); -function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) { +function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) { const fields: FormField[] = [ { name: 'action', @@ -122,6 +127,20 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr value: getFormDataField(formData, 'currency') || currency, required: true, }); + } else if (action === 'change-timezone') { + const timezones = getTimeZones(); + + fields.push({ + name: 'timezone', + label: 'Timezone', + type: 'select', + options: timezones.map((timezone) => ({ + value: timezone.id, + label: timezone.label, + })), + value: getFormDataField(formData, 'timezone') || timezoneId, + required: true, + }); } return fields; } @@ -132,8 +151,10 @@ export default function Settings( error, notice, currency, + timezoneId, isExpensesAppEnabled, isMultiFactorAuthEnabled, + isCalendarAppEnabled, helpEmail, user, }: SettingsProps, @@ -201,7 +222,9 @@ export default function Settings(

- {formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))} + {formFields('change-currency', formData, currency, timezoneId).map((field) => + generateFieldHtml(field, formData) + )}
@@ -210,6 +233,26 @@ export default function Settings( ) : null} + {isCalendarAppEnabled + ? ( + <> +

Change your timezone

+

+ This is only used in the calendar app. +

+ + + {formFields('change-timezone', formData, currency, timezoneId).map((field) => + generateFieldHtml(field, formData) + )} +
+ +
+
+ + ) + : null} + {isMultiFactorAuthEnabled ? ( + ); +} diff --git a/islands/calendar/Calendars.tsx b/islands/calendar/Calendars.tsx new file mode 100644 index 0000000..5a5b49e --- /dev/null +++ b/islands/calendar/Calendars.tsx @@ -0,0 +1,314 @@ +import { useSignal } from '@preact/signals'; + +import { Calendar } from '/lib/models/calendar.ts'; +import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/lib/utils/calendar.ts'; +import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx'; +import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx'; +import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx'; + +interface CalendarsProps { + initialCalendars: Calendar[]; +} + +export default function Calendars({ initialCalendars }: CalendarsProps) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isSaving = useSignal(false); + const calendars = useSignal(initialCalendars); + const openCalendar = useSignal(null); + + async function onClickAddCalendar() { + if (isAdding.value) { + return; + } + + const name = (prompt(`What's the **name** for the new calendar?`) || '').trim(); + + if (!name) { + alert('A name is required for a new calendar!'); + return; + } + + isAdding.value = true; + + try { + const requestBody: AddRequestBody = { name }; + const response = await fetch(`/api/calendar/add`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as AddResponseBody; + + if (!result.success) { + throw new Error('Failed to add calendar!'); + } + + calendars.value = [...result.newCalendars]; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + async function onClickDeleteCalendar(calendarId: string) { + if (confirm('Are you sure you want to delete this calendar and all its events?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { calendarId }; + const response = await fetch(`/api/calendar/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete calendar!'); + } + + calendars.value = [...result.newCalendars]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + async function onClickSaveOpenCalendar() { + if (isSaving.value) { + return; + } + + if (!openCalendar.value?.uid) { + alert('A calendar is required to update one!'); + return; + } + + if (!openCalendar.value?.displayName) { + alert('A name is required to update the calendar!'); + return; + } + + if (!openCalendar.value?.calendarColor) { + alert('A color is required to update the calendar!'); + return; + } + + isSaving.value = true; + + try { + const requestBody: UpdateRequestBody = { + id: openCalendar.value.uid!, + name: openCalendar.value.displayName!, + color: openCalendar.value.calendarColor!, + isVisible: openCalendar.value.isVisible!, + }; + const response = await fetch(`/api/calendar/update`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as UpdateResponseBody; + + if (!result.success) { + throw new Error('Failed to update calendar!'); + } + + calendars.value = [...result.newCalendars]; + } catch (error) { + console.error(error); + } + + isSaving.value = false; + openCalendar.value = null; + } + + return ( + <> +
+ View calendar +
+ +
+
+ +
+ + + + + + + + + + + {calendars.value.map((calendar) => ( + + + + + + + ))} + {calendars.value.length === 0 + ? ( + + + + ) + : null} + +
NameColorVisible?
+ {calendar.displayName} + + openCalendar.value = { ...calendar }} + > + + + {calendar.isVisible ? 'Yes' : 'No'} + + +
+
+
No calendars to show
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isSaving.value + ? ( + <> + Saving... + + ) + : null} + {!isDeleting.value && !isSaving.value ? <>  : null} + +
+ +
+
+ +
+

Edit Calendar

+
+
+ + + openCalendar.value = { ...openCalendar.value!, displayName: event.currentTarget.value }} + placeholder='Personal' + /> +
+
+ +
+ + + +
+
+
+ + + openCalendar.value = { ...openCalendar.value!, isVisible: event.currentTarget.checked }} + /> +
+
+
+ + +
+
+ + ); +} diff --git a/islands/calendar/ViewCalendarEvent.tsx b/islands/calendar/ViewCalendarEvent.tsx new file mode 100644 index 0000000..0b817a1 --- /dev/null +++ b/islands/calendar/ViewCalendarEvent.tsx @@ -0,0 +1,263 @@ +import { useSignal } from '@preact/signals'; + +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts'; +import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; +import { + RequestBody as DeleteRequestBody, + ResponseBody as DeleteResponseBody, +} from '/routes/api/calendar/delete-event.tsx'; + +interface ViewCalendarEventProps { + initialCalendarEvent: CalendarEvent; + calendars: Calendar[]; + formData: Record; + error?: string; + notice?: string; +} + +export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[], updateType: 'raw' | 'ui') { + const fields: FormField[] = [ + { + name: 'update-type', + label: 'Update type', + type: 'hidden', + value: updateType, + readOnly: true, + }, + ]; + + if (updateType === 'ui') { + fields.push({ + name: 'title', + label: 'Title', + type: 'text', + placeholder: 'Dentis', + value: calendarEvent.title, + required: true, + }, { + name: 'calendarId', + label: 'Calendar', + type: 'select', + value: calendarEvent.calendarId, + options: calendars.map((calendar) => ({ label: calendar.displayName!, value: calendar.uid! })), + required: true, + description: 'Cannot be changed after the event has been created.', + }, { + name: 'startDate', + label: 'Start date', + type: 'datetime-local', + value: new Date(calendarEvent.startDate).toISOString().substring(0, 16), + required: true, + description: 'Dates are set in the default calendar timezone, controlled by Radicale.', + }, { + name: 'endDate', + label: 'End date', + type: 'datetime-local', + value: new Date(calendarEvent.endDate).toISOString().substring(0, 16), + required: true, + description: 'Dates are set in the default calendar timezone, controlled by Radicale.', + }, { + name: 'isAllDay', + label: 'All-day?', + type: 'checkbox', + placeholder: 'YYYYMMDD', + value: 'true', + required: false, + checked: calendarEvent.isAllDay, + }, { + name: 'status', + label: 'Status', + type: 'select', + value: calendarEvent.status, + options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({ + label: capitalizeWord(status), + value: status, + })), + required: true, + }, { + name: 'description', + label: 'Description', + type: 'textarea', + placeholder: 'Just a regular check-up.', + value: calendarEvent.description, + required: false, + }, { + name: 'eventUrl', + label: 'URL', + type: 'url', + placeholder: 'https://example.com', + value: calendarEvent.eventUrl, + required: false, + }, { + name: 'location', + label: 'Location', + type: 'text', + placeholder: 'Birmingham, UK', + value: calendarEvent.location, + required: false, + }, { + name: 'transparency', + label: 'Transparency', + type: 'select', + value: calendarEvent.transparency, + options: (['opaque', 'transparent'] as CalendarEvent['transparency'][]).map(( + transparency, + ) => ({ + label: capitalizeWord(transparency), + value: transparency, + })), + required: true, + }); + } else if (updateType === 'raw') { + fields.push({ + name: 'ics', + label: 'Raw ICS', + type: 'textarea', + placeholder: 'Raw ICS...', + value: calendarEvent.data, + description: + 'This is the raw ICS for this event. Use this to manually update the event _if_ you know what you are doing.', + rows: '10', + }); + } + + return fields; +} + +export default function ViewCalendarEvent( + { initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps, +) { + const isDeleting = useSignal(false); + const calendarEvent = useSignal(initialCalendarEvent); + + const formData = convertObjectToFormData(formDataObject); + + async function onClickDeleteEvent() { + const message = calendarEvent.peek().isRecurring + ? 'Are you sure you want to delete _all_ instances of this recurring event?' + : 'Are you sure you want to delete this event?'; + if (confirm(message)) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { + calendarIds: calendars.map((calendar) => calendar.uid!), + calendarView: 'day', + calendarStartDate: new Date().toISOString().substring(0, 10), + calendarEventId: calendarEvent.value.uid!, + calendarId: calendarEvent.value.calendarId, + }; + const response = await fetch(`/api/calendar/delete-event`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete event!'); + } + + window.location.href = '/calendar'; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+ View calendar +
+ +
+
+ +
+ {error + ? ( +
+

Failed to update!

+

{error}

+
+ ) + : null} + {notice + ? ( +
+

Success!

+

{notice}

+
+ ) + : null} + +
+ {formFields(calendarEvent.peek(), calendars, 'ui').map((field) => generateFieldHtml(field, formData))} + +
+ {calendarEvent.peek().isRecurring + ? ( +

+ Note that you'll update all instances of this recurring event. +

+ ) + : null} + +
+
+ +
+ +
+ + Edit Raw ICS{' '} + + Expand + + + +
+ {formFields(calendarEvent.peek(), calendars, 'raw').map((field) => generateFieldHtml(field, formData))} + +
+ +
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {!isDeleting.value ? <>  : null} + +
+ + ); +} diff --git a/islands/files/FilesWrapper.tsx b/islands/files/FilesWrapper.tsx index 8024e24..1277ed5 100644 --- a/islands/files/FilesWrapper.tsx +++ b/islands/files/FilesWrapper.tsx @@ -7,7 +7,6 @@ interface FilesWrapperProps { initialPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCalDavEnabled?: boolean; fileShareId?: string; } @@ -19,7 +18,6 @@ export default function FilesWrapper( initialPath, baseUrl, isFileSharingAllowed, - isCalDavEnabled, fileShareId, }: FilesWrapperProps, ) { @@ -30,7 +28,6 @@ export default function FilesWrapper( initialPath={initialPath} baseUrl={baseUrl} isFileSharingAllowed={isFileSharingAllowed} - isCalDavEnabled={isCalDavEnabled} fileShareId={fileShareId} /> ); diff --git a/lib/config.ts b/lib/config.ts index 53da908..1a8e87d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -24,7 +24,7 @@ export class AppConfig { allowPublicSharing: false, }, core: { - enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'], + enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], }, visuals: { title: '', diff --git a/lib/models/calendar.ts b/lib/models/calendar.ts new file mode 100644 index 0000000..1f72f8e --- /dev/null +++ b/lib/models/calendar.ts @@ -0,0 +1,321 @@ +import { createDAVClient } from 'tsdav'; + +import { AppConfig } from '/lib/config.ts'; +import { getColorAsHex, parseVCalendar } from '/lib/utils/calendar.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; +import { UserModel } from '/lib/models/user.ts'; + +interface DAVObject extends Record { + data?: string; + displayName?: string; + ctag?: string; + url: string; + uid?: string; +} + +export interface Calendar extends DAVObject { + calendarColor?: string; + isVisible: boolean; +} + +export interface CalendarEvent extends DAVObject { + calendarId: string; + startDate: Date; + endDate: Date; + title: string; + isAllDay: boolean; + organizerEmail: string; + attendees?: CalendarEventAttendee[]; + reminders?: CalendarEventReminder[]; + transparency: 'opaque' | 'transparent'; + description?: string; + location?: string; + eventUrl?: string; + sequence?: number; + isRecurring?: boolean; + recurringRrule?: string; + recurrenceId?: string; + recurrenceMasterUid?: string; +} + +export interface CalendarEventAttendee { + email: string; + status: 'accepted' | 'rejected' | 'invited'; + name?: string; +} + +export interface CalendarEventReminder { + uid?: string; + startDate: string; + type: 'email' | 'sound' | 'display'; + acknowledgedAt?: string; + description?: string; +} + +const calendarConfig = await AppConfig.getCalendarConfig(); + +async function getClient(userId: string) { + const client = await createDAVClient({ + serverUrl: calendarConfig.calDavUrl, + credentials: {}, + authMethod: 'Custom', + // deno-lint-ignore require-await + authFunction: async () => { + return { + 'X-Remote-User': userId, + }; + }, + fetchOptions: { + timeout: 15_000, + }, + defaultAccountType: 'caldav', + rootUrl: `${calendarConfig.calDavUrl}/`, + principalUrl: `${calendarConfig.calDavUrl}/${userId}/`, + homeUrl: `${calendarConfig.calDavUrl}/${userId}/`, + }); + + return client; +} + +export class CalendarModel { + static async list( + userId: string, + ): Promise { + const client = await getClient(userId); + + const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/`; + + const davCalendars: DAVObject[] = await client.fetchCalendars({ + calendar: { + url: calendarUrl, + }, + }); + + const user = await UserModel.getById(userId); + + const calendars: Calendar[] = davCalendars.map((davCalendar) => { + const uid = davCalendar.url.split('/').filter(Boolean).pop()!; + + return { + ...davCalendar, + displayName: decodeURIComponent(davCalendar.displayName || '(empty)'), + calendarColor: decodeURIComponent(davCalendar.calendarColor || getColorAsHex('bg-gray-700')), + isVisible: !user.extra.hidden_calendar_ids?.includes(uid), + uid, + }; + }); + + return calendars; + } + + static async get( + userId: string, + calendarId: string, + ): Promise { + const calendars = await this.list(userId); + + return calendars.find((calendar) => calendar.uid === calendarId); + } + + static async create( + userId: string, + name: string, + ): Promise { + const calendarId = crypto.randomUUID(); + const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`; + + const client = await getClient(userId); + + await client.makeCalendar({ + url: calendarUrl, + props: { + displayName: name, + }, + }); + } + + static async update( + userId: string, + calendarUrl: string, + displayName: string, + color?: string, + ): Promise { + // Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because tsdav doesn't have PROPPATCH + const xmlBody = ` + + + + ${encodeURIComponent(displayName)} + ${color ? `${encodeURIComponent(color)}` : ''} + + +`; + + await fetch(calendarUrl, { + method: 'PROPPATCH', + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'X-Remote-User': userId, + }, + body: xmlBody, + }); + } + + static async delete( + userId: string, + calendarUrl: string, + ): Promise { + const client = await getClient(userId); + + await client.deleteObject({ + url: calendarUrl, + }); + } +} + +export class CalendarEventModel { + private static async fetchByCalendarId( + userId: string, + calendarId: string, + dateRange?: { start: Date; end: Date }, + ): Promise { + const client = await getClient(userId); + + const fetchOptions: { calendar: { url: string }; timeRange?: { start: string; end: string }; expand?: boolean } = { + calendar: { + url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`, + }, + }; + + if (dateRange) { + fetchOptions.timeRange = { + start: dateRange.start.toISOString(), + end: dateRange.end.toISOString(), + }; + fetchOptions.expand = true; + } + + const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions); + + const calendarEvents: CalendarEvent[] = []; + + for (const davCalendarEvent of davCalendarEvents) { + let uid = davCalendarEvent.url.split('/').filter(Boolean).pop()!; + + const parsedEvents = parseVCalendar(davCalendarEvent.data || ''); + + for (const parsedEvent of parsedEvents) { + if (parsedEvent.uid) { + uid = parsedEvent.uid; + } + + calendarEvents.push({ + ...davCalendarEvent, + ...parsedEvent, + uid, + calendarId, + }); + } + } + + return calendarEvents; + } + + static async list( + userId: string, + calendarIds: string[], + dateRange?: { start: Date; end: Date }, + ): Promise { + const allCalendarEvents: CalendarEvent[] = []; + + await concurrentPromises( + calendarIds.map((calendarId) => async () => { + const calendarEvents = await this.fetchByCalendarId(userId, calendarId, dateRange); + + allCalendarEvents.push(...calendarEvents); + + return calendarEvents; + }), + 5, + ); + + return allCalendarEvents; + } + + static async get( + userId: string, + calendarId: string, + eventId: string, + ): Promise { + const client = await getClient(userId); + + const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects({ + calendar: { + url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`, + }, + objectUrls: [`${calendarConfig.calDavUrl}/${userId}/${calendarId}/${eventId}.ics`], + }); + + if (davCalendarEvents.length === 0) { + return undefined; + } + + const davCalendarEvent = davCalendarEvents[0]; + + const calendarEvent: CalendarEvent = { + ...davCalendarEvent, + ...parseVCalendar(davCalendarEvent.data || '')[0], + uid: eventId, + calendarId, + }; + + return calendarEvent; + } + + static async create( + userId: string, + calendarId: string, + eventId: string, + vCalendar: string, + ): Promise { + const client = await getClient(userId); + + const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`; + + await client.createCalendarObject({ + calendar: { + url: calendarUrl, + }, + iCalString: vCalendar, + filename: `${eventId}.ics`, + }); + } + + static async update( + userId: string, + eventUrl: string, + ics: string, + ): Promise { + const client = await getClient(userId); + + await client.updateCalendarObject({ + calendarObject: { + url: eventUrl, + data: ics, + }, + }); + } + + static async delete( + userId: string, + eventUrl: string, + ): Promise { + const client = await getClient(userId); + + await client.deleteCalendarObject({ + calendarObject: { + url: eventUrl, + }, + }); + } +} diff --git a/lib/types.ts b/lib/types.ts index 10ea659..b260c6c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -14,6 +14,11 @@ export interface User { dav_hashed_password?: string; expenses_currency?: SupportedCurrencySymbol; multi_factor_auth_methods?: MultiFactorAuthMethod[]; + hidden_calendar_ids?: string[]; + timezone?: { + id: string; + utcOffset: number; + }; }; created_at: Date; } @@ -147,7 +152,7 @@ export const currencyMap = new Map([ export type PartialDeep = (T extends (infer U)[] ? PartialDeep[] : { [P in keyof T]?: PartialDeep }) | T; -export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts'; +export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts' | 'calendar'; export interface Config { auth: { diff --git a/lib/utils/calendar.ts b/lib/utils/calendar.ts new file mode 100644 index 0000000..c819326 --- /dev/null +++ b/lib/utils/calendar.ts @@ -0,0 +1,957 @@ +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[] = []; + + let partialCalendarEvent: Partial = {}; + let partialCalendarReminder: Partial = {}; + let vCalendarVersion: VCalendarVersion = '2.0'; + const partialRecurringMasterEvent: Pick = {}; + + // 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([ + ['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> = {}; + + 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; +} diff --git a/lib/utils/calendar_test.ts b/lib/utils/calendar_test.ts new file mode 100644 index 0000000..4d0cb05 --- /dev/null +++ b/lib/utils/calendar_test.ts @@ -0,0 +1,1915 @@ +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { assertMatch } from 'std/assert/assert_match.ts'; + +import { Calendar, CalendarEvent } from '/lib/models/calendar.ts'; +import { + convertRRuleToWords, + generateVCalendar, + generateVEvent, + getCalendarEventStyle, + getColorAsHex, + getDateRangeForCalendarView, + getDaysForWeek, + getIdFromVEvent, + getWeeksForMonth, + parseIcsDate, + parseVCalendar, + splitTextIntoVEvents, + updateIcs, +} from './calendar.ts'; + +Deno.test('that getColorAsHex works', () => { + const tests: { input: string; expected: string | undefined }[] = [ + { input: 'bg-red-700', expected: '#B51E1F' }, + { input: 'bg-green-700', expected: '#148041' }, + { input: 'bg-blue-900', expected: '#1E3A89' }, + { input: 'bg-purple-800', expected: '#6923A9' }, + { input: 'bg-gray-700', expected: '#384354' }, + { input: 'invalid-color', expected: '#384354' }, + ]; + + for (const test of tests) { + const output = getColorAsHex(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that getIdFromVEvent works', () => { + const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [ + { + input: `BEGIN:VEVENT +UID:12345-abcde-67890 +SUMMARY:Test Event +END:VEVENT`, + expected: '12345-abcde-67890', + }, + { + input: `BEGIN:VEVENT +SUMMARY:No UID Event +END:VEVENT`, + shouldBeUUID: true, + }, + { + input: `BEGIN:VEVENT +UID: spaced-uid +SUMMARY:Spaced UID +END:VEVENT`, + expected: 'spaced-uid', + }, + ]; + + for (const test of tests) { + const output = getIdFromVEvent(test.input); + if (test.expected) { + assertEquals(output, test.expected); + } else if (test.shouldBeUUID) { + assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + } + } +}); + +Deno.test('that splitTextIntoVEvents works', () => { + const tests: { input: string; expected: string[] }[] = [ + { + input: `BEGIN:VEVENT +UID:1 +SUMMARY:Event 1 +END:VEVENT +BEGIN:VEVENT +UID:2 +SUMMARY:Event 2 +END:VEVENT`, + expected: [ + `BEGIN:VEVENT +UID:1 +SUMMARY:Event 1 +END:VEVENT`, + `BEGIN:VEVENT +UID:2 +SUMMARY:Event 2 +END:VEVENT`, + ], + }, + { + input: `BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:1 +SUMMARY:Event 1 +END:VEVENT +BEGIN:VEVENT +UID:2 +SUMMARY:Event 2 +END:VEVENT +END:VCALENDAR`, + expected: [ + `BEGIN:VEVENT +UID:1 +SUMMARY:Event 1 +END:VEVENT`, + `BEGIN:VEVENT +UID:2 +SUMMARY:Event 2 +END:VEVENT`, + ], + }, + { + input: `BEGIN:VEVENT +UID:single +SUMMARY:Single Event +END:VEVENT`, + expected: [ + `BEGIN:VEVENT +UID:single +SUMMARY:Single Event +END:VEVENT`, + ], + }, + { + input: '', + expected: [], + }, + { + input: `BEGIN:VEVENT +UID:incomplete +SUMMARY:Incomplete Event`, + expected: [], + }, + ]; + + for (const test of tests) { + const output = splitTextIntoVEvents(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that getDateRangeForCalendarView works', () => { + const baseDate = '2025-01-15'; + + const dayRange = getDateRangeForCalendarView(baseDate, 'day'); + assertEquals(dayRange.start.getDate(), 14); // Previous day + assertEquals(dayRange.end.getDate(), 16); // Next day + + const weekRange = getDateRangeForCalendarView(baseDate, 'week'); + assertEquals(weekRange.start.getDate(), 8); // 7 days before + assertEquals(weekRange.end.getDate(), 22); // 7 days after + + const monthRange = getDateRangeForCalendarView(baseDate, 'month'); + assertEquals(monthRange.start.getDate(), 8); // 7 days before + assertEquals(monthRange.end.getDate(), 15); // 31 days after (wraps to next month) +}); + +Deno.test('that generateVEvent works', () => { + const testEvents: { + input: { + calendarEvent: CalendarEvent; + createdDate: Date; + }; + expected: string; + }[] = [ + { + input: { + calendarEvent: { + calendarId: 'test-calendar', + isAllDay: false, + url: 'test-123.ics', + uid: 'test-123', + title: 'Test Event', + startDate: new Date('2025-01-15T10:00:00Z'), + endDate: new Date('2025-01-15T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + description: 'Test description', + location: 'Test location', + eventUrl: 'https://example.com', + attendees: [ + { email: 'attendee@example.com', status: 'accepted', name: 'Test Attendee' }, + ], + reminders: [ + { type: 'display', startDate: '2025-01-15T09:45:00Z', description: 'Test reminder' }, + ], + }, + createdDate: new Date('2025-01-15T10:00:00Z'), + }, + expected: `BEGIN:VEVENT +DTSTAMP:20250115T100000 +DTSTART:20250115T100000 +DTEND:20250115T110000 +ORGANIZER;CN=:mailto:test@example.com +SUMMARY:Test Event +TRANSP:OPAQUE +UID:test-123 +SEQUENCE:0 +CREATED:20250115T100000 +LAST-MODIFIED:20250115T100000 +DESCRIPTION:Test description +LOCATION:Test location +URL:https://example.com +ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Test reminder +TRIGGER;VALUE=DATE-TIME:20250115T094500 +END:VALARM +END:VEVENT`, + }, + { + input: { + calendarEvent: { + calendarId: 'test-calendar', + isAllDay: true, + url: 'test-123.ics', + uid: 'test-123', + title: 'Test Event', + startDate: new Date('2025-01-15T10:00:00Z'), + endDate: new Date('2025-01-15T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + description: 'Test description', + location: 'Test location', + eventUrl: 'https://example.com', + attendees: [ + { email: 'attendee@example.com', status: 'accepted', name: 'Test Attendee' }, + ], + reminders: [ + { type: 'display', startDate: '2025-01-15T09:45:00Z', description: 'Test reminder' }, + ], + }, + createdDate: new Date('2025-01-15T10:00:00Z'), + }, + expected: `BEGIN:VEVENT +DTSTAMP:20250115T100000 +DTSTART;VALUE=DATE:20250115 +DTEND;VALUE=DATE:20250115 +ORGANIZER;CN=:mailto:test@example.com +SUMMARY:Test Event +TRANSP:OPAQUE +UID:test-123 +SEQUENCE:0 +CREATED:20250115T100000 +LAST-MODIFIED:20250115T100000 +DESCRIPTION:Test description +LOCATION:Test location +URL:https://example.com +ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Test reminder +TRIGGER;VALUE=DATE-TIME:20250115T094500 +END:VALARM +END:VEVENT`, + }, + { + input: { + calendarEvent: { + calendarId: 'test-calendar', + isAllDay: false, + url: 'test-123.ics', + uid: 'test-123', + title: 'Test Event', + startDate: new Date('2025-01-15T10:00:00Z'), + endDate: new Date('2025-01-15T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: true, + recurringRrule: 'FREQ=WEEKLY;BYDAY=MO', + sequence: 1, + }, + createdDate: new Date('2025-01-15T10:00:00Z'), + }, + expected: `BEGIN:VEVENT +DTSTAMP:20250115T100000 +DTSTART:20250115T100000 +DTEND:20250115T110000 +ORGANIZER;CN=:mailto:test@example.com +SUMMARY:Test Event +TRANSP:OPAQUE +UID:test-123 +RRULE:FREQ=WEEKLY;BYDAY=MO +SEQUENCE:1 +CREATED:20250115T100000 +LAST-MODIFIED:20250115T100000 +END:VEVENT`, + }, + ]; + + for (const testEvent of testEvents) { + const output = generateVEvent(testEvent.input.calendarEvent, testEvent.input.createdDate); + assertEquals(output, testEvent.expected); + } +}); + +Deno.test('that generateVCalendar works', () => { + const testEvents: CalendarEvent[] = [ + { + calendarId: 'test-calendar', + isAllDay: false, + url: 'test-123.ics', + uid: 'event-1', + title: 'Event 1', + startDate: new Date('2025-01-15T10:00:00Z'), + endDate: new Date('2025-01-15T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + }, + { + calendarId: 'test-calendar', + isAllDay: true, + url: 'test-123.ics', + uid: 'event-2', + title: 'Event 2', + startDate: new Date('2025-01-16T10:00:00Z'), + endDate: new Date('2025-01-16T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + }, + ]; + + const output = generateVCalendar(testEvents, new Date('2025-01-15T10:00:00Z')); + + const expected = `BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTAMP:20250115T100000 +DTSTART:20250115T100000 +DTEND:20250115T110000 +ORGANIZER;CN=:mailto:test@example.com +SUMMARY:Event 1 +TRANSP:OPAQUE +UID:event-1 +SEQUENCE:0 +CREATED:20250115T100000 +LAST-MODIFIED:20250115T100000 +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20250115T100000 +DTSTART;VALUE=DATE:20250116 +DTEND;VALUE=DATE:20250116 +ORGANIZER;CN=:mailto:test@example.com +SUMMARY:Event 2 +TRANSP:OPAQUE +UID:event-2 +SEQUENCE:0 +CREATED:20250115T100000 +LAST-MODIFIED:20250115T100000 +END:VEVENT +END:VCALENDAR`; + + assertEquals(output, expected); +}); + +Deno.test('that updateIcs works', () => { + const testEvents: { + input: { + originalIcs: string; + updates: CalendarEvent; + }; + expected: string; + }[] = [ + { + input: { + originalIcs: `BEGIN:VEVENT +UID:test-123 +SUMMARY:Original Title +DTSTART:20250115T100000Z +DTEND:20250115T110000Z +END:VEVENT`, + updates: { + calendarId: 'test-calendar', + isAllDay: false, + url: 'test-123.ics', + uid: 'test-123', + title: 'Updated Title', + startDate: new Date('2025-01-16T10:00:00Z'), + endDate: new Date('2025-01-16T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'transparent', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + description: 'New description', + location: 'New location', + eventUrl: 'https://updated.com', + }, + }, + expected: `BEGIN:VEVENT +UID:test-123 +SUMMARY:Updated Title +DTSTART:20250116T100000 +DTEND:20250116T110000 +DESCRIPTION:New description +URL:https://updated.com +LOCATION:New location +END:VEVENT`, + }, + { + input: { + originalIcs: `BEGIN:VEVENT +UID:test-123 +SUMMARY:Original Title +DTSTART:20250115T100000 +DTEND:20250115T110000 +URL:https://example.com +LOCATION:Example location +END:VEVENT`, + updates: { + calendarId: 'test-calendar', + isAllDay: true, + url: 'test-123.ics', + uid: 'test-123', + title: 'Updated Title', + startDate: new Date('2025-01-16T10:00:00Z'), + endDate: new Date('2025-01-16T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'transparent', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + description: 'New description', + eventUrl: 'https://updated.com', + }, + }, + expected: `BEGIN:VEVENT +UID:test-123 +SUMMARY:Updated Title +DTSTART;VALUE=DATE:20250116 +DTEND;VALUE=DATE:20250116 +URL:https://updated.com +LOCATION:Example location +DESCRIPTION:New description +END:VEVENT`, + }, + { + input: { + originalIcs: `BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN +BEGIN:VEVENT +UID:test-123 +SUMMARY:Original Title +DTSTART:20250115T100000 +DTEND:20250115T110000 +URL:https://example.com +LOCATION:Example location +END:VEVENT +END:VCALENDAR`, + updates: { + calendarId: 'test-calendar', + isAllDay: true, + url: 'test-123.ics', + uid: 'test-123', + title: 'Updated Title', + startDate: new Date('2025-01-16T10:00:00Z'), + endDate: new Date('2025-01-16T11:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'transparent', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + description: 'New description', + eventUrl: 'https://updated.com', + }, + }, + expected: `BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +PRODID:-//PYVOBJECT//NONSGML Version 0.9.9//EN +BEGIN:VEVENT +UID:test-123 +SUMMARY:Updated Title +DTSTART;VALUE=DATE:20250116 +DTEND;VALUE=DATE:20250116 +URL:https://updated.com +LOCATION:Example location +DESCRIPTION:New description +END:VEVENT +END:VCALENDAR`, + }, + { + input: { + originalIcs: `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +TZID:Europe/Lisbon +BEGIN:STANDARD +DTSTART:19111231T232315 +RDATE:19111231T232315 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:-003645 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19161101T010000 +RDATE:19161101T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19171015T000000 +RDATE:19171015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181015T000000 +RDATE:19181015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19191015T000000 +RDATE:19191015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19201015T000000 +RDATE:19201015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19211015T000000 +RDATE:19211015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19241005T000000 +RDATE:19241005T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19261003T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19291006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19311004T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19341007T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19391119T000000 +RDATE:19391119T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19401008T000000 +RDATE:19401008T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19411006T000000 +RDATE:19411006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19421025T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19451028T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19461006T000000 +RDATE:19461006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19471005T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19651003T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19661002T030000 +RDATE:19661002T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19760926T010000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19770925T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19781001T020000 +RDATE:19781001T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19790930T020000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19800928T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19810927T010000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19850929T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19860928T020000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19910929T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19920927T020000 +RDATE:19920927T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19930926T030000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19961027T020000 +RDATE:19961027T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19971026T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160617T230000 +RDATE:19160617T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19170301T000000 +RDATE:19170301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19180301T000000 +RDATE:19180301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19190301T000000 +RDATE:19190301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19200301T000000 +RDATE:19200301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19210301T000000 +RDATE:19210301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19240416T230000 +RDATE:19240416T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19260417T230000 +RDATE:19260417T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19270409T230000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19290420T230000 +RDATE:19290420T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19310418T230000 +RDATE:19310418T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19320402T230000 +RDATE:19320402T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19340407T230000 +RDATE:19340407T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19350330T230000 +RDATE:19350330T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19360418T230000 +RDATE:19360418T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19370403T230000 +RDATE:19370403T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19380326T230000 +RDATE:19380326T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19390415T230000 +RDATE:19390415T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19400224T230000 +RDATE:19400224T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19410405T230000 +RDATE:19410405T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420314T230000 +RDATE:19420314T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420425T230000 +RDATE:19420425T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420816T000000 +RDATE:19420816T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430313T230000 +RDATE:19430313T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430417T230000 +RDATE:19430417T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430829T000000 +RDATE:19430829T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440311T230000 +RDATE:19440311T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440422T230000 +RDATE:19440422T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440827T000000 +RDATE:19440827T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450310T230000 +RDATE:19450310T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450421T230000 +RDATE:19450421T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450826T000000 +RDATE:19450826T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19460406T230000 +RDATE:19460406T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19470406T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19660403T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19770327T000000 +RDATE:19770327T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19780402T010000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T010000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19810329T000000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19850331T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19860330T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19920329T010000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19930328T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19950326T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19960331T020000 +RDATE:19960331T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19970330T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +X-TZINFO:Europe/Lisbon[2025b] +END:VTIMEZONE +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +DTSTART;TZID=Europe/Lisbon:20250720T090000 +DTEND;TZID=Europe/Lisbon:20250720T100000 +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR`, + updates: { + calendarId: 'test-calendar', + isAllDay: false, + url: '99e15556-fd88-4cb9-818e-fcbf853bc443.ics', + uid: '99e15556-fd88-4cb9-818e-fcbf853bc443', + title: 'Updated Title', + startDate: new Date('2025-07-20T09:00:00Z'), + endDate: new Date('2025-07-20T10:00:00Z'), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: true, + recurringRrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z', + sequence: 0, + description: 'New description', + }, + }, + expected: `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTIMEZONE +TZID:Europe/Lisbon +BEGIN:STANDARD +DTSTART:19111231T232315 +RDATE:19111231T232315 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:-003645 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19161101T010000 +RDATE:19161101T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19171015T000000 +RDATE:19171015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19181015T000000 +RDATE:19181015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19191015T000000 +RDATE:19191015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19201015T000000 +RDATE:19201015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19211015T000000 +RDATE:19211015T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19241005T000000 +RDATE:19241005T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19261003T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19291006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19311004T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19341007T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19391119T000000 +RDATE:19391119T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19401008T000000 +RDATE:19401008T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19411006T000000 +RDATE:19411006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19421025T000000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19451028T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19461006T000000 +RDATE:19461006T000000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19471005T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19651003T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19661002T030000 +RDATE:19661002T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19760926T010000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19770925T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19781001T020000 +RDATE:19781001T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19790930T020000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19800928T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19810927T010000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19850929T010000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19860928T020000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19910929T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19920927T020000 +RDATE:19920927T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19930926T030000 +RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19961027T020000 +RDATE:19961027T020000 +TZNAME:Europe/Lisbon(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:STANDARD +DTSTART:19971026T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:(STD) +TZOFFSETFROM:+010000 +TZOFFSETTO:+000000 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19160617T230000 +RDATE:19160617T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19170301T000000 +RDATE:19170301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19180301T000000 +RDATE:19180301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19190301T000000 +RDATE:19190301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19200301T000000 +RDATE:19200301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19210301T000000 +RDATE:19210301T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19240416T230000 +RDATE:19240416T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19260417T230000 +RDATE:19260417T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19270409T230000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19290420T230000 +RDATE:19290420T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19310418T230000 +RDATE:19310418T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19320402T230000 +RDATE:19320402T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19340407T230000 +RDATE:19340407T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19350330T230000 +RDATE:19350330T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19360418T230000 +RDATE:19360418T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19370403T230000 +RDATE:19370403T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19380326T230000 +RDATE:19380326T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19390415T230000 +RDATE:19390415T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19400224T230000 +RDATE:19400224T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19410405T230000 +RDATE:19410405T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420314T230000 +RDATE:19420314T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420425T230000 +RDATE:19420425T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19420816T000000 +RDATE:19420816T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430313T230000 +RDATE:19430313T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430417T230000 +RDATE:19430417T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19430829T000000 +RDATE:19430829T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440311T230000 +RDATE:19440311T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440422T230000 +RDATE:19440422T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19440827T000000 +RDATE:19440827T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450310T230000 +RDATE:19450310T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450421T230000 +RDATE:19450421T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19450826T000000 +RDATE:19450826T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+020000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19460406T230000 +RDATE:19460406T230000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19470406T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19660403T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19770327T000000 +RDATE:19770327T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19780402T010000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T010000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19810329T000000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19850331T000000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19860330T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19920329T010000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19930328T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19950326T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19960331T020000 +RDATE:19960331T020000 +TZNAME:Europe/Lisbon(DST) +TZOFFSETFROM:+010000 +TZOFFSETTO:+010000 +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:19970330T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:(DST) +TZOFFSETFROM:+000000 +TZOFFSETTO:+010000 +END:DAYLIGHT +X-TZINFO:Europe/Lisbon[2025b] +END:VTIMEZONE +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +DTSTART:20250720T090000 +DTEND:20250720T100000 +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20250817T080000Z +SUMMARY:Updated Title +TRANSP:OPAQUE +DESCRIPTION:New description +END:VEVENT +END:VCALENDAR`, + }, + ]; + + for (const test of testEvents) { + const output = updateIcs(test.input.originalIcs, test.input.updates); + assertEquals(output, test.expected); + } +}); + +Deno.test('that parseVCalendar works', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:test-123 +SUMMARY:Test Event +DTSTART:20250115T100000Z +DTEND:20250115T110000Z +ORGANIZER;CN=:mailto:test@example.com +TRANSP:OPAQUE +DESCRIPTION:Test description +LOCATION:Test location +URL:https://example.com +ATTENDEE;PARTSTAT=ACCEPTED;CN=Test Attendee:mailto:attendee@example.com +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Test reminder +TRIGGER;VALUE=DATE-TIME:20250115T094500Z +END:VALARM +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 1); + const event = output[0]; + assertEquals(event.uid, 'test-123'); + assertEquals(event.title, 'Test Event'); + assertEquals(event.description, 'Test description'); + assertEquals(event.location, 'Test location'); + assertEquals(event.eventUrl, 'https://example.com'); + assertEquals(event.transparency, 'opaque'); + assertEquals(event.attendees?.length, 1); + assertEquals(event.reminders?.length, 1); +}); + +Deno.test('that parseVCalendar handles multiple events', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event-1 +SUMMARY:Event 1 +DTSTART:20250115T100000Z +DTEND:20250115T110000Z +END:VEVENT +BEGIN:VEVENT +UID:event-2 +SUMMARY:Event 2 +DTSTART:20250116T100000Z +DTEND:20250116T110000Z +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 2); + assertEquals(output[0].uid, 'event-1'); + assertEquals(output[1].uid, 'event-2'); +}); + +Deno.test('that parseVCalendar handles recurring events', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250721T080000Z +DTSTART:20250721T080000Z +DTEND:20250721T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250722T080000Z +DTSTART:20250722T080000Z +DTEND:20250722T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250723T080000Z +DTSTART:20250723T080000Z +DTEND:20250723T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250724T080000Z +DTSTART:20250724T080000Z +DTEND:20250724T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250725T080000Z +DTSTART:20250725T080000Z +DTEND:20250725T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250728T080000Z +DTSTART:20250728T080000Z +DTEND:20250728T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250729T080000Z +DTSTART:20250729T080000Z +DTEND:20250729T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250730T080000Z +DTSTART:20250730T080000Z +DTEND:20250730T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250731T080000Z +DTSTART:20250731T080000Z +DTEND:20250731T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250801T080000Z +DTSTART:20250801T080000Z +DTEND:20250801T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +UID:99e15556-fd88-4cb9-818e-fcbf853bc443 +RECURRENCE-ID:20250804T080000Z +DTSTART:20250804T080000Z +DTEND:20250804T090000Z +CREATED:20250720T075329Z +DTSTAMP:20250720T075414Z +LAST-MODIFIED:20250720T075414Z +SUMMARY:Recurring Standup +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 11); + assertEquals(output[0].uid, '99e15556-fd88-4cb9-818e-fcbf853bc443:20250721T080000Z'); + assertEquals(output[0].isRecurring, true); + assertEquals(output[0].recurrenceMasterUid, '99e15556-fd88-4cb9-818e-fcbf853bc443'); + assertEquals(output[0].recurrenceId, '20250721T080000Z'); + assertEquals(output[0].title, 'Recurring Standup'); + + assertEquals(output[1].uid, '99e15556-fd88-4cb9-818e-fcbf853bc443:20250722T080000Z'); + assertEquals(output[1].isRecurring, true); + assertEquals(output[1].recurrenceMasterUid, '99e15556-fd88-4cb9-818e-fcbf853bc443'); + assertEquals(output[1].recurrenceId, '20250722T080000Z'); + assertEquals(output[1].title, 'Recurring Standup'); +}); + +Deno.test('that getWeeksForMonth works', () => { + const testDate = new Date('2025-01-15'); + const weeks = getWeeksForMonth(testDate); + + // January 2025 starts on Wednesday, so it should have 5 weeks + assertEquals(weeks.length, 5); + + // First week should start with December 30, 2024 (Monday) + assertEquals(weeks[0][0].date.getDate(), 30); + assertEquals(weeks[0][0].date.getMonth(), 11); + + // Last week should end with February 2, 2025 (Sunday) + assertEquals(weeks[4][6].date.getDate(), 2); + assertEquals(weeks[4][6].date.getMonth(), 1); +}); + +Deno.test('that getDaysForWeek works', () => { + const testDate = new Date('2025-01-15'); + const days = getDaysForWeek(testDate); + + assertEquals(days.length, 7); + + // Should start with Monday (January 13, 2025) + assertEquals(days[0].date.getDate(), 13); + assertEquals(days[0].date.getDay(), 1); + + // Should end with Sunday (January 19, 2025) + assertEquals(days[6].date.getDate(), 19); + assertEquals(days[6].date.getDay(), 0); + + // Each day should have 24 hours + assertEquals(days[0].hours.length, 24); +}); + +Deno.test('that getCalendarEventStyle works', () => { + const calendars: Calendar[] = [ + { url: 'cal-1', isVisible: true, uid: 'cal-1', name: 'Calendar 1', calendarColor: '#B51E1F' }, + { url: 'cal-2', isVisible: true, uid: 'cal-2', name: 'Calendar 2', calendarColor: '#1E3A89' }, + ]; + + const opaqueEvent: CalendarEvent = { + calendarId: 'cal-1', + isAllDay: false, + url: 'event-1.ics', + uid: 'event-1', + title: 'Opaque Event', + startDate: new Date(), + endDate: new Date(), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + }; + + const transparentEvent: CalendarEvent = { + calendarId: 'cal-2', + isAllDay: false, + url: 'event-2.ics', + uid: 'event-2', + title: 'Transparent Event', + startDate: new Date(), + endDate: new Date(), + organizerEmail: 'test@example.com', + transparency: 'transparent', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + }; + + assertEquals(getCalendarEventStyle(opaqueEvent, calendars), { backgroundColor: '#B51E1F' }); + assertEquals(getCalendarEventStyle(transparentEvent, calendars), { border: '1px solid #1E3A89' }); +}); + +Deno.test('that getCalendarEventStyle returns default color for unknown calendar', () => { + const calendars: Calendar[] = []; + const event: CalendarEvent = { + calendarId: 'unknown-cal', + isAllDay: false, + url: 'event-1.ics', + uid: 'event-1', + title: 'Unknown Calendar Event', + startDate: new Date(), + endDate: new Date(), + organizerEmail: 'test@example.com', + transparency: 'opaque', + isRecurring: false, + recurringRrule: undefined, + sequence: 0, + }; + + assertEquals(getCalendarEventStyle(event, calendars), { backgroundColor: '#384354' }); +}); + +Deno.test('that parseIcsDate works', () => { + const tests: { input: string; expected: string }[] = [ + { input: '20250101T000000Z', expected: '2025-01-01T00:00:00.000Z' }, + { input: '20250201T000300', expected: '2025-02-01T00:03:00.000Z' }, + { input: '20250103T050000', expected: '2025-01-03T05:00:00.000Z' }, + ]; + + for (const test of tests) { + const output = parseIcsDate(test.input); + assertEquals(output.toISOString(), test.expected); + } +}); + +Deno.test('that convertRRuleToWords works', () => { + const tests: { input: string; expected: string }[] = [ + { input: 'RRULE:FREQ=DAILY', expected: 'Every day' }, + { input: 'RRULE:FREQ=WEEKLY;BYDAY=MO', expected: 'Every week on Monday' }, + { input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=15', expected: 'Every month on the 15th' }, + { input: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR', expected: 'Every week on Monday, Wednesday, Friday' }, + { input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1;INTERVAL=2', expected: 'Every 2 months on the 1st' }, + { input: 'RRULE:FREQ=DAILY;COUNT=5', expected: 'Every day for 5 times' }, + { + input: 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20250101T000000Z', + expected: 'Every week on Monday, Wednesday, Friday until 2025-01-01', + }, + { + input: 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1;INTERVAL=2;UNTIL=20250101T000000Z', + expected: 'Every 2 months on the 1st until 2025-01-01', + }, + ]; + + for (const test of tests) { + const output = convertRRuleToWords(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that convertRRuleToWords handles invalid rules', () => { + const output = convertRRuleToWords('INVALID:RULE'); + assertEquals(output, ''); +}); + +Deno.test('that parseVCalendar handles vCalendar 1.0', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:1.0 +BEGIN:VEVENT +UID:test-123 +SUMMARY:Test Event +DTSTART:20250115T100000Z +DTEND:20250115T110000Z +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 1); + assertEquals(output[0].uid, 'test-123'); +}); + +Deno.test('that parseVCalendar handles multiline fields', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:test-123 +SUMMARY:Test Event with + long title +DESCRIPTION:Test description with very + long text.\\n\\nAnd a new line. +DTSTART:20250115T100000Z +DTEND:20250115T110000Z +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 1); + assertEquals(output[0].title, 'Test Event with long title'); + assertEquals(output[0].description, 'Test description with very long text.\n\nAnd a new line.'); +}); + +Deno.test('that parseVCalendar handles empty fields gracefully', () => { + const testIcs = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:test-123 +SUMMARY: +DESCRIPTION: +LOCATION: +END:VEVENT +END:VCALENDAR`; + + const output = parseVCalendar(testIcs); + + assertEquals(output.length, 1); + assertEquals(output[0].uid, 'test-123'); + assertEquals(output[0].title, undefined); + assertEquals(output[0].location, undefined); + assertEquals(output[0].description, undefined); +}); diff --git a/routes/api/calendar/add-event.tsx b/routes/api/calendar/add-event.tsx new file mode 100644 index 0000000..790a036 --- /dev/null +++ b/routes/api/calendar/add-event.tsx @@ -0,0 +1,74 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts'; +import { generateVCalendar, getDateRangeForCalendarView } from '/lib/utils/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; + calendarView: 'day' | 'week' | 'month'; + calendarStartDate: string; + calendarId: string; + title: string; + startDate: string; + endDate: string; + isAllDay?: boolean; +} + +export interface ResponseBody { + success: boolean; + newCalendarEvents: CalendarEvent[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.calendarId || !requestBody.calendarIds || !requestBody.title || !requestBody.startDate || + !requestBody.endDate || !requestBody.calendarView || !requestBody.calendarStartDate + ) { + return new Response('Bad Request', { status: 400 }); + } + + const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId); + + if (!calendar) { + return new Response('Not Found', { status: 404 }); + } + + const userId = context.state.user.id; + + const eventId = crypto.randomUUID(); + + const newEvent: CalendarEvent = { + calendarId: requestBody.calendarId, + title: requestBody.title, + startDate: new Date(requestBody.startDate), + endDate: new Date(requestBody.endDate), + isAllDay: Boolean(requestBody.isAllDay), + organizerEmail: context.state.user.email, + transparency: 'opaque', + url: `${calendar.url}/${eventId}.ics`, + uid: eventId, + }; + + const vCalendar = generateVCalendar([newEvent]); + + await CalendarEventModel.create(userId, requestBody.calendarId, eventId, vCalendar); + + const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView); + + const newCalendarEvents = await CalendarEventModel.list(userId, requestBody.calendarIds, dateRange); + + const responseBody: ResponseBody = { success: true, newCalendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/add.tsx b/routes/api/calendar/add.tsx new file mode 100644 index 0000000..af5ddea --- /dev/null +++ b/routes/api/calendar/add.tsx @@ -0,0 +1,35 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarModel } from '/lib/models/calendar.ts'; + +interface Data {} + +export interface RequestBody { + name: string; +} + +export interface ResponseBody { + success: boolean; + newCalendars: Calendar[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.name) { + await CalendarModel.create(context.state.user.id, requestBody.name); + } + + const newCalendars = await CalendarModel.list(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newCalendars }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/delete-event.tsx b/routes/api/calendar/delete-event.tsx new file mode 100644 index 0000000..1859bdc --- /dev/null +++ b/routes/api/calendar/delete-event.tsx @@ -0,0 +1,64 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts'; +import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; + calendarView: 'day' | 'week' | 'month'; + calendarStartDate: string; + calendarId: string; + calendarEventId: string; +} + +export interface ResponseBody { + success: boolean; + newCalendarEvents: CalendarEvent[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.calendarId || !requestBody.calendarIds || !requestBody.calendarEventId || + !requestBody.calendarEventId || + !requestBody.calendarView || !requestBody.calendarStartDate + ) { + return new Response('Bad Request', { status: 400 }); + } + + const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId); + + if (!calendar) { + return new Response('Not Found', { status: 404 }); + } + + const calendarEvent = await CalendarEventModel.get( + context.state.user.id, + calendar.uid!, + requestBody.calendarEventId, + ); + + if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendarId) { + return new Response('Not Found', { status: 404 }); + } + + await CalendarEventModel.delete(context.state.user.id, calendarEvent.url); + + const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView); + + const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange); + + const responseBody: ResponseBody = { success: true, newCalendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/delete.tsx b/routes/api/calendar/delete.tsx new file mode 100644 index 0000000..ae64db5 --- /dev/null +++ b/routes/api/calendar/delete.tsx @@ -0,0 +1,41 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarModel } from '/lib/models/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarId: string; +} + +export interface ResponseBody { + success: boolean; + newCalendars: Calendar[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.calendarId) { + const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId); + + if (!calendar) { + return new Response('Not found', { status: 404 }); + } + + await CalendarModel.delete(context.state.user.id, requestBody.calendarId); + } + + const newCalendars = await CalendarModel.list(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newCalendars }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/export-events.tsx b/routes/api/calendar/export-events.tsx new file mode 100644 index 0000000..39927f4 --- /dev/null +++ b/routes/api/calendar/export-events.tsx @@ -0,0 +1,38 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; +} + +export interface ResponseBody { + success: boolean; + calendarEvents: CalendarEvent[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.calendarIds) { + return new Response('Bad Request', { status: 400 }); + } + + const calendarEvents = await CalendarEventModel.list( + context.state.user.id, + requestBody.calendarIds, + ); + + const responseBody: ResponseBody = { success: true, calendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/import.tsx b/routes/api/calendar/import.tsx new file mode 100644 index 0000000..df60736 --- /dev/null +++ b/routes/api/calendar/import.tsx @@ -0,0 +1,67 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; +import { getDateRangeForCalendarView, getIdFromVEvent, splitTextIntoVEvents } from '/lib/utils/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; + calendarView: 'day' | 'week' | 'month'; + calendarStartDate: string; + icsToImport: string; + calendarId: string; +} + +export interface ResponseBody { + success: boolean; + newCalendarEvents: CalendarEvent[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.calendarId || !requestBody.calendarIds || !requestBody.icsToImport || + !requestBody.calendarView || !requestBody.calendarStartDate + ) { + return new Response('Bad Request', { status: 400 }); + } + + const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId); + + if (!calendar) { + return new Response('Not Found', { status: 404 }); + } + + const vEvents = splitTextIntoVEvents(requestBody.icsToImport); + + if (vEvents.length === 0) { + return new Response('Not found', { status: 404 }); + } + + await concurrentPromises( + vEvents.map((vEvent) => async () => { + const eventId = getIdFromVEvent(vEvent); + + await CalendarEventModel.create(context.state.user!.id, calendar.uid!, eventId, vEvent); + }), + 5, + ); + + const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView); + + const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange); + + const responseBody: ResponseBody = { success: true, newCalendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/search-events.tsx b/routes/api/calendar/search-events.tsx new file mode 100644 index 0000000..31bf660 --- /dev/null +++ b/routes/api/calendar/search-events.tsx @@ -0,0 +1,53 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts'; + +interface Data {} + +export interface RequestBody { + calendarIds: string[]; + searchTerm: string; +} + +export interface ResponseBody { + success: boolean; + matchingCalendarEvents: CalendarEvent[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.calendarIds || !requestBody.searchTerm + ) { + return new Response('Bad Request', { status: 400 }); + } + + const allCalendarEvents = await CalendarEventModel.list( + context.state.user.id, + requestBody.calendarIds, + ); + + const lowerSearchTerm = requestBody.searchTerm.toLowerCase(); + + const matchingCalendarEvents = allCalendarEvents.filter((calendarEvent) => + calendarEvent.title.toLowerCase().includes(lowerSearchTerm) || + calendarEvent.description?.toLowerCase().includes(lowerSearchTerm) || + calendarEvent.location?.toLowerCase().includes(lowerSearchTerm) || + calendarEvent.eventUrl?.toLowerCase().includes(lowerSearchTerm) || + calendarEvent.organizerEmail?.toLowerCase().includes(lowerSearchTerm) || + calendarEvent.attendees?.some((attendee) => attendee.email.toLowerCase().includes(lowerSearchTerm)) || + calendarEvent.reminders?.some((reminder) => reminder.description?.toLowerCase().includes(lowerSearchTerm)) + ).sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); + + const responseBody: ResponseBody = { success: true, matchingCalendarEvents }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/calendar/update.tsx b/routes/api/calendar/update.tsx new file mode 100644 index 0000000..522f25f --- /dev/null +++ b/routes/api/calendar/update.tsx @@ -0,0 +1,65 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarModel } from '/lib/models/calendar.ts'; +import { UserModel } from '/lib/models/user.ts'; +import { getColorAsHex } from '/lib/utils/calendar.ts'; + +interface Data {} + +export interface RequestBody { + id: string; + name: string; + color: string; + isVisible: boolean; +} + +export interface ResponseBody { + success: boolean; + newCalendars: Calendar[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.id) { + const calendar = await CalendarModel.get(context.state.user.id, requestBody.id); + + if (!calendar) { + return new Response('Not found', { status: 404 }); + } + + calendar.displayName = requestBody.name; + calendar.calendarColor = requestBody.color?.startsWith('#') + ? requestBody.color + : getColorAsHex(requestBody.color || 'bg-gray-700'); + + await CalendarModel.update(context.state.user.id, calendar.url, calendar.displayName, calendar.calendarColor); + + if (requestBody.isVisible !== calendar.isVisible) { + const user = await UserModel.getById(context.state.user.id); + + if (requestBody.isVisible) { + user.extra.hidden_calendar_ids = user.extra.hidden_calendar_ids?.filter((id) => id !== calendar.uid!); + } else if (Array.isArray(user.extra.hidden_calendar_ids)) { + user.extra.hidden_calendar_ids.push(calendar.uid!); + } else { + user.extra.hidden_calendar_ids = [calendar.uid!]; + } + + await UserModel.update(user); + } + } + + const newCalendars = await CalendarModel.list(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newCalendars }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/calendar.tsx b/routes/calendar.tsx new file mode 100644 index 0000000..7c23f5a --- /dev/null +++ b/routes/calendar.tsx @@ -0,0 +1,82 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts'; +import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx'; +import { AppConfig } from '/lib/config.ts'; +import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts'; + +interface Data { + userCalendars: Calendar[]; + userCalendarEvents: CalendarEvent[]; + baseUrl: string; + view: 'day' | 'week' | 'month'; + startDate: string; + timezoneId: string; + timezoneUtcOffset: number; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const baseUrl = (await AppConfig.getConfig()).auth.baseUrl; + const calendarConfig = await AppConfig.getCalendarConfig(); + + if (!calendarConfig.enableCalDavServer) { + throw new Error('CalDAV server is not enabled'); + } + + const userId = context.state.user.id; + const timezoneId = context.state.user.extra.timezone?.id || 'UTC'; + const timezoneUtcOffset = context.state.user.extra.timezone?.utcOffset || 0; + + const searchParams = new URL(request.url).searchParams; + + const view = (searchParams.get('view') as Data['view']) || 'week'; + const startDate = searchParams.get('startDate') || new Date().toISOString().substring(0, 10); + + let userCalendars = await CalendarModel.list(userId); + + // Create default calendar if none exists + if (userCalendars.length === 0) { + await CalendarModel.create(userId, 'Calendar'); + + userCalendars = await CalendarModel.list(userId); + } + + const visibleCalendarIds = userCalendars.filter((calendar) => calendar.isVisible).map((calendar) => calendar.uid!); + + const dateRange = getDateRangeForCalendarView(startDate, view); + + const userCalendarEvents = await CalendarEventModel.list(userId, visibleCalendarIds, dateRange); + + return await context.render({ + userCalendars, + userCalendarEvents, + baseUrl, + view, + startDate, + timezoneId, + timezoneUtcOffset, + }); + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/calendar/[calendarEventId].tsx b/routes/calendar/[calendarEventId].tsx new file mode 100644 index 0000000..239eaa6 --- /dev/null +++ b/routes/calendar/[calendarEventId].tsx @@ -0,0 +1,160 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts'; +import { convertFormDataToObject } from '/lib/utils/misc.ts'; +import { updateIcs } from '/lib/utils/calendar.ts'; +import { getFormDataField } from '/lib/form-utils.tsx'; +import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx'; +import { AppConfig } from '/lib/config.ts'; + +interface Data { + calendarEvent: CalendarEvent; + calendars: Calendar[]; + error?: string; + notice?: string; + formData: Record; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const calendarConfig = await AppConfig.getCalendarConfig(); + + if (!calendarConfig.enableCalDavServer) { + throw new Error('CalDAV server is not enabled'); + } + + let { calendarEventId } = context.params; + + const searchParams = new URL(request.url).searchParams; + const calendarId = searchParams.get('calendarId') || undefined; + + if (!calendarId) { + return new Response('Bad request', { status: 400 }); + } + + // When editing a recurring event, we only allow the master + if (calendarEventId.includes(':')) { + calendarEventId = calendarEventId.split(':')[0]; + } + + const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId); + + if (!calendarEvent) { + return new Response('Not found', { status: 404 }); + } + + const calendars = await CalendarModel.list(context.state.user.id); + + return await context.render({ calendarEvent, calendars, formData: {} }); + }, + async POST(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const calendarConfig = await AppConfig.getCalendarConfig(); + + if (!calendarConfig.enableCalDavServer) { + throw new Error('CalDAV server is not enabled'); + } + + const { calendarEventId } = context.params; + + const searchParams = new URL(request.url).searchParams; + const calendarId = searchParams.get('calendarId') || undefined; + + if (!calendarId) { + return new Response('Bad request', { status: 400 }); + } + + const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId); + + if (!calendarEvent) { + return new Response('Not found', { status: 404 }); + } + + const calendars = await CalendarModel.list(context.state.user.id); + + const formData = await request.formData(); + + const updateType = getFormDataField(formData, 'update-type') as 'raw' | 'ui'; + + calendarEvent.title = getFormDataField(formData, 'title'); + calendarEvent.startDate = new Date(`${getFormDataField(formData, 'startDate')}:00.000Z`); + calendarEvent.endDate = new Date(`${getFormDataField(formData, 'endDate')}:00.000Z`); + calendarEvent.isAllDay = getFormDataField(formData, 'isAllDay') === 'true'; + calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status']; + + calendarEvent.description = getFormDataField(formData, 'description') || undefined; + calendarEvent.eventUrl = getFormDataField(formData, 'eventUrl') || undefined; + calendarEvent.location = getFormDataField(formData, 'location') || undefined; + calendarEvent.transparency = getFormDataField(formData, 'transparency') as CalendarEvent['transparency'] || + 'opaque'; + const rawIcs = getFormDataField(formData, 'ics'); + + try { + if (!calendarEvent.title) { + throw new Error(`Title is required.`); + } + + formFields(calendarEvent, calendars, updateType).forEach((field) => { + if (field.required) { + const value = formData.get(field.name); + + if (!value) { + throw new Error(`${field.label} is required`); + } + } + }); + + let updatedIcs = ''; + + if (updateType === 'raw') { + updatedIcs = rawIcs; + } else if (updateType === 'ui') { + if (!calendarEvent.title || !calendarEvent.startDate || !calendarEvent.endDate) { + throw new Error(`Title, start date, and end date are required.`); + } + + updatedIcs = updateIcs(calendarEvent.data || '', calendarEvent); + } + + await CalendarEventModel.update(context.state.user.id, calendarEvent.url!, updatedIcs); + + return await context.render({ + calendarEvent, + calendars, + notice: 'Event updated successfully!', + formData: convertFormDataToObject(formData), + }); + } catch (error) { + console.error(error); + + return await context.render({ + calendarEvent, + calendars, + error: (error as Error).toString(), + formData: convertFormDataToObject(formData), + }); + } + }, +}; + +export default function CalendarEventPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/calendars.tsx b/routes/calendars.tsx new file mode 100644 index 0000000..cf545ae --- /dev/null +++ b/routes/calendars.tsx @@ -0,0 +1,36 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { Calendar, CalendarModel } from '/lib/models/calendar.ts'; +import Calendars from '/islands/calendar/Calendars.tsx'; +import { AppConfig } from '/lib/config.ts'; + +interface Data { + userCalendars: Calendar[]; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const calendarConfig = await AppConfig.getCalendarConfig(); + + if (!calendarConfig.enableCalDavServer) { + throw new Error('CalDAV server is not enabled'); + } + + const userCalendars = await CalendarModel.list(context.state.user.id); + + return await context.render({ userCalendars }); + }, +}; + +export default function CalendarsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/files.tsx b/routes/files.tsx index fb6bdfa..dd37e77 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -11,7 +11,6 @@ interface Data { currentPath: string; baseUrl: string; isFileSharingAllowed: boolean; - isCalDavEnabled: boolean; } export const handler: Handlers = { @@ -41,9 +40,6 @@ export const handler: Handlers = { const userFiles = await FileModel.list(context.state.user.id, currentPath); const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed(); - const calendarConfig = await AppConfig.getCalendarConfig(); - - const isCalDavEnabled = calendarConfig.enableCalDavServer; return await context.render({ userDirectories, @@ -51,7 +47,6 @@ export const handler: Handlers = { currentPath, baseUrl, isFileSharingAllowed: isPublicFileSharingAllowed, - isCalDavEnabled, }); }, }; @@ -65,7 +60,6 @@ export default function FilesPage({ data }: PageProps) initialPath={data.currentPath} baseUrl={data.baseUrl} isFileSharingAllowed={data.isFileSharingAllowed} - isCalDavEnabled={data.isCalDavEnabled} /> ); diff --git a/routes/settings.tsx b/routes/settings.tsx index b862bae..d821745 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -8,6 +8,7 @@ import { getFormDataField } from '/lib/form-utils.tsx'; import { EmailModel } from '/lib/models/email.ts'; import { AppConfig } from '/lib/config.ts'; import Settings, { Action, actionWords } from '/islands/Settings.tsx'; +import { getTimeZones } from '/lib/utils/calendar.ts'; interface Data { error?: { @@ -20,8 +21,10 @@ interface Data { }; formData: Record; currency?: SupportedCurrencySymbol; + timezoneId?: string; isExpensesAppEnabled: boolean; isMultiFactorAuthEnabled: boolean; + isCalendarAppEnabled: boolean; helpEmail: string; user: { extra: Pick; @@ -37,13 +40,16 @@ export const handler: Handlers = { const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar'); return await context.render({ formData: {}, currency: context.state.user.extra.expenses_currency, + timezoneId: context.state.user.extra.timezone?.id || 'UTC', isExpensesAppEnabled, helpEmail, isMultiFactorAuthEnabled, + isCalendarAppEnabled, user: context.state.user, }); }, @@ -55,6 +61,7 @@ export const handler: Handlers = { const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses'); const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail; const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled(); + const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar'); let action: Action = 'change-email'; let errorTitle = ''; @@ -183,6 +190,24 @@ export const handler: Handlers = { successTitle = 'Currency changed!'; successMessage = 'Currency changed successfully.'; + } else if (action === 'change-timezone') { + const timezones = getTimeZones(); + const newTimezoneId = getFormDataField(formData, 'timezone'); + const matchingTimezone = timezones.find((timezone) => timezone.id === newTimezoneId); + + if (!matchingTimezone) { + throw new Error(`Invalid timezone.`); + } + + user.extra.timezone = { + id: newTimezoneId, + utcOffset: matchingTimezone.utcOffset, + }; + + await UserModel.update(user); + + successTitle = 'Timezone changed!'; + successMessage = 'Timezone changed successfully.'; } const notice = successTitle @@ -196,9 +221,11 @@ export const handler: Handlers = { notice, formData: convertFormDataToObject(formData), currency: user.extra.expenses_currency, + timezoneId: user.extra.timezone?.id || 'UTC', isExpensesAppEnabled, helpEmail, isMultiFactorAuthEnabled, + isCalendarAppEnabled, user: user, }); } catch (error) { @@ -210,9 +237,11 @@ export const handler: Handlers = { error: { title: errorTitle, message: errorMessage }, formData: convertFormDataToObject(formData), currency: user.extra.expenses_currency, + timezoneId: user.extra.timezone?.id || 'UTC', isExpensesAppEnabled, helpEmail, isMultiFactorAuthEnabled, + isCalendarAppEnabled, user: user, }); } @@ -227,8 +256,10 @@ export default function SettingsPage({ data }: PageProps diff --git a/static/styles.css b/static/styles.css index 517f6c1..d9229d0 100644 --- a/static/styles.css +++ b/static/styles.css @@ -90,3 +90,9 @@ img.blue { details summary::-webkit-details-marker { display: none; } + +.calendar-week-view-days { + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + grid-template-rows: + 0.5fr 1fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; +}