From 15dcc8803db002d6ee321b50292e9b6181e549dc Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sat, 6 Sep 2025 12:46:13 +0100 Subject: [PATCH] Basic CalDav UI (Calendar) This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description. You can also import and export ICS (VCALENDAR + VEVENT) files. It allows editing the ICS directly, for power users. Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar". You can also change the display timezone for the calendar from the settings. Finally, there's some minor documentation fixes and some other minor tweaks. Closes #56 Closes #89 --- README.md | 4 +- bewcloud.config.sample.ts | 2 +- components/Header.tsx | 10 + components/calendar/AddEventModal.tsx | 163 ++ components/calendar/CalendarViewDay.tsx | 229 +++ components/calendar/CalendarViewMonth.tsx | 166 ++ components/calendar/CalendarViewWeek.tsx | 225 +++ components/calendar/ImportEventsModal.tsx | 86 + components/calendar/MainCalendar.tsx | 655 +++++++ components/calendar/SearchEvents.tsx | 152 ++ components/calendar/ViewEventModal.tsx | 159 ++ components/files/MainFiles.tsx | 11 - docker-compose.dev.yml | 2 +- docker-compose.yml | 2 +- fresh.gen.ts | 28 + islands/Settings.tsx | 49 +- islands/calendar/CalendarWrapper.tsx | 30 + islands/calendar/Calendars.tsx | 314 ++++ islands/calendar/ViewCalendarEvent.tsx | 263 +++ islands/files/FilesWrapper.tsx | 3 - lib/config.ts | 2 +- lib/models/calendar.ts | 321 ++++ lib/types.ts | 7 +- lib/utils/calendar.ts | 957 ++++++++++ lib/utils/calendar_test.ts | 1915 +++++++++++++++++++++ routes/api/calendar/add-event.tsx | 74 + routes/api/calendar/add.tsx | 35 + routes/api/calendar/delete-event.tsx | 64 + routes/api/calendar/delete.tsx | 41 + routes/api/calendar/export-events.tsx | 38 + routes/api/calendar/import.tsx | 67 + routes/api/calendar/search-events.tsx | 53 + routes/api/calendar/update.tsx | 65 + routes/calendar.tsx | 82 + routes/calendar/[calendarEventId].tsx | 160 ++ routes/calendars.tsx | 36 + routes/files.tsx | 6 - routes/settings.tsx | 31 + static/styles.css | 6 + 39 files changed, 6483 insertions(+), 30 deletions(-) create mode 100644 components/calendar/AddEventModal.tsx create mode 100644 components/calendar/CalendarViewDay.tsx create mode 100644 components/calendar/CalendarViewMonth.tsx create mode 100644 components/calendar/CalendarViewWeek.tsx create mode 100644 components/calendar/ImportEventsModal.tsx create mode 100644 components/calendar/MainCalendar.tsx create mode 100644 components/calendar/SearchEvents.tsx create mode 100644 components/calendar/ViewEventModal.tsx create mode 100644 islands/calendar/CalendarWrapper.tsx create mode 100644 islands/calendar/Calendars.tsx create mode 100644 islands/calendar/ViewCalendarEvent.tsx create mode 100644 lib/models/calendar.ts create mode 100644 lib/utils/calendar.ts create mode 100644 lib/utils/calendar_test.ts create mode 100644 routes/api/calendar/add-event.tsx create mode 100644 routes/api/calendar/add.tsx create mode 100644 routes/api/calendar/delete-event.tsx create mode 100644 routes/api/calendar/delete.tsx create mode 100644 routes/api/calendar/export-events.tsx create mode 100644 routes/api/calendar/import.tsx create mode 100644 routes/api/calendar/search-events.tsx create mode 100644 routes/api/calendar/update.tsx create mode 100644 routes/calendar.tsx create mode 100644 routes/calendar/[calendarEventId].tsx create mode 100644 routes/calendars.tsx 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; +}