From 4e5fdd569ac9a566ad9699d58d5adc91fc5a4c01 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Wed, 3 Apr 2024 14:02:04 +0100 Subject: [PATCH] Files CRUD. Remove Contacts and Calendar + CardDav and CalDav. --- .env.sample | 3 +- .gitignore | 3 + README.md | 29 +- components/Header.tsx | 16 - components/calendar/AddEventModal.tsx | 156 ---- components/calendar/CalendarViewDay.tsx | 224 ------ components/calendar/CalendarViewMonth.tsx | 161 ---- components/calendar/CalendarViewWeek.tsx | 216 ------ components/calendar/ImportEventsModal.tsx | 81 -- components/calendar/MainCalendar.tsx | 634 ---------------- components/calendar/SearchEvents.tsx | 149 ---- components/calendar/ViewEventModal.tsx | 143 ---- components/files/CreateDirectoryModal.tsx | 60 ++ components/files/FilesBreadcrumb.tsx | 44 ++ components/files/ListFiles.tsx | 209 +++++ components/files/MainFiles.tsx | 522 +++++++++++++ components/files/MoveDirectoryOrFileModal.tsx | 145 ++++ .../files/RenameDirectoryOrFileModal.tsx | 67 ++ components/files/SearchFiles.tsx | 165 ++++ crons/news.ts | 7 - db-migrations/003-contacts-app.pgsql | 52 -- db-migrations/004-calendar-app.pgsql | 107 --- fresh.gen.ts | 92 +-- import_map.json | 2 - islands/calendar/CalendarWrapper.tsx | 23 - islands/calendar/Calendars.tsx | 299 -------- islands/calendar/ViewCalendarEvent.tsx | 219 ------ islands/contacts/Contacts.tsx | 408 ---------- islands/contacts/ViewContact.tsx | 471 ------------ islands/files/FilesWrapper.tsx | 21 + lib/config.ts | 8 + lib/data/calendar.ts | 465 ------------ lib/data/contacts.ts | 138 ---- lib/data/files.ts | 261 +++++++ lib/data/news.ts | 167 ++-- lib/data/user.ts | 11 - lib/types.ts | 124 +-- lib/utils/calendar.ts | 716 ------------------ lib/utils/contacts.ts | 317 -------- lib/utils/files.ts | 57 ++ lib/utils/files_test.ts | 36 + lib/utils/misc.ts | 8 +- routes/.well-known/caldav.tsx | 7 - routes/.well-known/carddav.tsx | 7 - routes/api/calendar/add-event.tsx | 77 -- routes/api/calendar/add.tsx | 39 - routes/api/calendar/delete-event.tsx | 73 -- routes/api/calendar/delete.tsx | 41 - routes/api/calendar/get-events.tsx | 38 - routes/api/calendar/import.tsx | 97 --- routes/api/calendar/search-events.tsx | 42 - routes/api/calendar/update.tsx | 48 -- routes/api/contacts/add.tsx | 41 - routes/api/contacts/delete.tsx | 42 - routes/api/contacts/get.tsx | 27 - routes/api/contacts/import.tsx | 60 -- routes/api/files/create-directory.tsx | 47 ++ routes/api/files/delete-directory.tsx | 47 ++ routes/api/files/delete.tsx | 47 ++ routes/api/files/get-directories.tsx | 47 ++ routes/api/files/move-directory.tsx | 52 ++ routes/api/files/move.tsx | 52 ++ routes/api/files/rename-directory.tsx | 51 ++ routes/api/files/rename.tsx | 51 ++ routes/api/files/upload.tsx | 42 + routes/calendar.tsx | 58 -- routes/calendar/[calendarEventId].tsx | 120 --- routes/calendars.tsx | 29 - routes/contacts.tsx | 48 -- routes/contacts/[contactId].tsx | 204 ----- routes/dav/addressbooks.tsx | 124 --- routes/dav/addressbooks/contacts.tsx | 243 ------ .../addressbooks/contacts/[contactId].vcf.tsx | 221 ------ routes/dav/calendars.tsx | 196 ----- routes/dav/calendars/[calendarId].tsx | 234 ------ .../[calendarId]/[calendarEventId].ics.tsx | 253 ------- routes/dav/files.tsx | 91 --- routes/dav/index.tsx | 118 --- routes/dav/principals.tsx | 137 ---- routes/files.tsx | 51 ++ routes/files/open/[fileName].tsx | 47 ++ routes/remote.php/[davRoute].tsx | 7 - static/images/directory.svg | 1 + static/images/file.svg | 1 + static/images/move.svg | 1 + static/images/rename.svg | 1 + static/images/share.svg | 1 + static/images/trash.svg | 1 + static/styles.css | 5 - 89 files changed, 2302 insertions(+), 8001 deletions(-) delete mode 100644 components/calendar/AddEventModal.tsx delete mode 100644 components/calendar/CalendarViewDay.tsx delete mode 100644 components/calendar/CalendarViewMonth.tsx delete mode 100644 components/calendar/CalendarViewWeek.tsx delete mode 100644 components/calendar/ImportEventsModal.tsx delete mode 100644 components/calendar/MainCalendar.tsx delete mode 100644 components/calendar/SearchEvents.tsx delete mode 100644 components/calendar/ViewEventModal.tsx create mode 100644 components/files/CreateDirectoryModal.tsx create mode 100644 components/files/FilesBreadcrumb.tsx create mode 100644 components/files/ListFiles.tsx create mode 100644 components/files/MainFiles.tsx create mode 100644 components/files/MoveDirectoryOrFileModal.tsx create mode 100644 components/files/RenameDirectoryOrFileModal.tsx create mode 100644 components/files/SearchFiles.tsx delete mode 100644 db-migrations/003-contacts-app.pgsql delete mode 100644 db-migrations/004-calendar-app.pgsql delete mode 100644 islands/calendar/CalendarWrapper.tsx delete mode 100644 islands/calendar/Calendars.tsx delete mode 100644 islands/calendar/ViewCalendarEvent.tsx delete mode 100644 islands/contacts/Contacts.tsx delete mode 100644 islands/contacts/ViewContact.tsx create mode 100644 islands/files/FilesWrapper.tsx delete mode 100644 lib/data/calendar.ts delete mode 100644 lib/data/contacts.ts create mode 100644 lib/data/files.ts delete mode 100644 lib/utils/calendar.ts delete mode 100644 lib/utils/contacts.ts create mode 100644 lib/utils/files.ts create mode 100644 lib/utils/files_test.ts delete mode 100644 routes/.well-known/caldav.tsx delete mode 100644 routes/.well-known/carddav.tsx delete mode 100644 routes/api/calendar/add-event.tsx delete mode 100644 routes/api/calendar/add.tsx delete mode 100644 routes/api/calendar/delete-event.tsx delete mode 100644 routes/api/calendar/delete.tsx delete mode 100644 routes/api/calendar/get-events.tsx delete mode 100644 routes/api/calendar/import.tsx delete mode 100644 routes/api/calendar/search-events.tsx delete mode 100644 routes/api/calendar/update.tsx delete mode 100644 routes/api/contacts/add.tsx delete mode 100644 routes/api/contacts/delete.tsx delete mode 100644 routes/api/contacts/get.tsx delete mode 100644 routes/api/contacts/import.tsx create mode 100644 routes/api/files/create-directory.tsx create mode 100644 routes/api/files/delete-directory.tsx create mode 100644 routes/api/files/delete.tsx create mode 100644 routes/api/files/get-directories.tsx create mode 100644 routes/api/files/move-directory.tsx create mode 100644 routes/api/files/move.tsx create mode 100644 routes/api/files/rename-directory.tsx create mode 100644 routes/api/files/rename.tsx create mode 100644 routes/api/files/upload.tsx delete mode 100644 routes/calendar.tsx delete mode 100644 routes/calendar/[calendarEventId].tsx delete mode 100644 routes/calendars.tsx delete mode 100644 routes/contacts.tsx delete mode 100644 routes/contacts/[contactId].tsx delete mode 100644 routes/dav/addressbooks.tsx delete mode 100644 routes/dav/addressbooks/contacts.tsx delete mode 100644 routes/dav/addressbooks/contacts/[contactId].vcf.tsx delete mode 100644 routes/dav/calendars.tsx delete mode 100644 routes/dav/calendars/[calendarId].tsx delete mode 100644 routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx delete mode 100644 routes/dav/files.tsx delete mode 100644 routes/dav/index.tsx delete mode 100644 routes/dav/principals.tsx create mode 100644 routes/files.tsx create mode 100644 routes/files/open/[fileName].tsx delete mode 100644 routes/remote.php/[davRoute].tsx create mode 100644 static/images/directory.svg create mode 100644 static/images/file.svg create mode 100644 static/images/move.svg create mode 100644 static/images/rename.svg create mode 100644 static/images/share.svg create mode 100644 static/images/trash.svg diff --git a/.env.sample b/.env.sample index 5465589..14d3456 100644 --- a/.env.sample +++ b/.env.sample @@ -14,4 +14,5 @@ PASSWORD_SALT="fake" BREVO_API_KEY="fake" CONFIG_ALLOW_SIGNUPS="false" -CONFIG_ENABLED_APPS="dashboard,news,contacts,calendar,tasks,files,notes,photos" +CONFIG_ENABLED_APPS="dashboard,news,files,notes,photos" +CONFIG_FILES_ROOT_PATH="data-files" diff --git a/.gitignore b/.gitignore index 5e25746..5482ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ db/ # Env var .env + +# Files +data-files/ diff --git a/README.md b/README.md index 55dab4c..9c6dcf1 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,18 @@ This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/). +> [!CAUTION] +> This is actively being built and should be considered pre-alpha. Bugs will exist. Code and models _can_ change without a good upgrade path (though I'll try to avoid that). **Don't use it as your only source of data!** + ## Self-host it! Check the [Development section below](#development). -> **NOTE:** You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. +> [!NOTE] +> You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. -> **NOTE 2:** Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin. +> [!IMPORTANT] +> Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin. ## Requirements @@ -55,14 +60,20 @@ Just push to the `main` branch. - [x] Dashboard with URLs and Notes - [x] News -- [x] Contacts / CardDav -- [ ] Calendar / CalDav -- [ ] Tasks / CalDav -- [ ] Files / WebDav -- [ ] Notes / WebDav -- [ ] Photos / WebDav -- [ ] Desktop app for selective file sync (or potentially just `rclone`) +- [ ] Files UI +- [ ] Notes UI +- [ ] Photos UI +- [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`) - [ ] Mobile app for offline file sync - [ ] Add notes support for mobile app - [ ] Add photos/sync support for mobile client - [ ] Address `TODO:`s in code +- [ ] Basic Contacts UI via CardDav? +- [ ] Basic Calendar UI via CalDav? +- [ ] Basic Tasks UI via CalDav? + +## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement? + +[Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point. + +My focus is still to get me to replace Nextcloud for me and my family ASAP, but turns out it's not easy to do it all in a single, installable _thing_, so I'm focusing on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient). diff --git a/components/Header.tsx b/components/Header.tsx index e53dc0a..201e28f 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -31,18 +31,6 @@ export default function Header({ route, user }: Data) { url: '/news', label: 'News', }, - { - url: '/contacts', - label: 'Contacts', - }, - { - url: '/calendar', - label: 'Calendar', - }, - { - url: '/tasks', - label: 'Tasks', - }, { url: '/files', label: 'Files', @@ -70,10 +58,6 @@ export default function Header({ route, user }: Data) { pageLabel = 'Settings'; } - if (route.startsWith('/calendars')) { - pageLabel = 'Calendars'; - } - return ( <> diff --git a/components/calendar/AddEventModal.tsx b/components/calendar/AddEventModal.tsx deleted file mode 100644 index 2f93796..0000000 --- a/components/calendar/AddEventModal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useSignal } from '@preact/signals'; -import { useEffect } from 'preact/hooks'; - -import { Calendar, CalendarEvent } from '/lib/types.ts'; - -export type NewCalendarEvent = Pick< - CalendarEvent, - 'id' | 'calendar_id' | 'title' | 'start_date' | 'end_date' | 'is_all_day' ->; - -interface AddEventModalProps { - isOpen: boolean; - initialStartDate?: Date; - initiallyAllDay?: boolean; - calendars: Pick[]; - onClickSave: (newEvent: NewCalendarEvent) => 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.setMinutes(0); - startDate.setSeconds(0); - startDate.setMilliseconds(0); - - const endDate = new Date(startDate); - endDate.setHours(startDate.getHours() + 1); - - if (initiallyAllDay) { - startDate.setHours(9); - endDate.setHours(18); - } - - newEvent.value = { - id: 'new', - title: '', - calendar_id: calendars[0]!.id, - start_date: startDate, - end_date: endDate, - is_all_day: initiallyAllDay || false, - }; - } - }, [isOpen]); - - return ( - <> -
-
- -
-

New Event

-
-
- - newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }} - placeholder='Dentist' - /> -
-
- -
- - calendar.id === newEvent.value?.calendar_id)?.color - } rounded-full`} - title={calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color} - > - -
-
-
- - - newEvent.value = { ...newEvent.value!, start_date: new Date(event.currentTarget.value) }} - /> -
-
- - - newEvent.value = { ...newEvent.value!, end_date: new Date(event.currentTarget.value) }} - /> -
-
- - newEvent.value = { ...newEvent.value!, is_all_day: event.currentTarget.checked }} - /> -
-
-
- - -
-
- - ); -} diff --git a/components/calendar/CalendarViewDay.tsx b/components/calendar/CalendarViewDay.tsx deleted file mode 100644 index ea9a336..0000000 --- a/components/calendar/CalendarViewDay.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { getCalendarEventColor } from '/lib/utils/calendar.ts'; - -interface CalendarViewDayProps { - startDate: Date; - visibleCalendars: Pick[]; - calendarEvents: CalendarEvent[]; - onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; - onClickOpenEvent: (calendarEvent: CalendarEvent) => void; -} - -export default function CalendarViewDay( - { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewDayProps, -) { - const today = new Date().toISOString().substring(0, 10); - - const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); - const dayFormat = new Intl.DateTimeFormat('en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric', - }); - - const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => { - if (!calendarEvent.is_all_day) { - return false; - } - - const startDayDate = new Date(startDate); - const endDayDate = new Date(startDate); - endDayDate.setHours(23); - endDayDate.setMinutes(59); - endDayDate.setSeconds(59); - endDayDate.setMilliseconds(999); - - const eventStartDate = new Date(calendarEvent.start_date); - const eventEndDate = new Date(calendarEvent.end_date); - - // 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.setHours(hourNumber); - - const shortIsoDate = date.toISOString().substring(0, 10); - - const isCurrentHour = shortIsoDate === today && new Date().getHours() === 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.setHours(hour.date.getHours()); - const endHourDate = new Date(shortIsoDate); - endHourDate.setHours(hour.date.getHours()); - endHourDate.setMinutes(59); - endHourDate.setSeconds(59); - endHourDate.setMilliseconds(999); - - const isLastHour = hourIndex === 23; - - const hourEvents = calendarEvents.filter((calendarEvent) => { - if (calendarEvent.is_all_day) { - return false; - } - - const eventStartDate = new Date(calendarEvent.start_date); - const eventEndDate = new Date(calendarEvent.end_date); - eventEndDate.setSeconds(eventEndDate.getSeconds() - 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 deleted file mode 100644 index 63004b0..0000000 --- a/components/calendar/CalendarViewMonth.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { getCalendarEventColor, getWeeksForMonth } from '/lib/utils/calendar.ts'; - -interface CalendarViewWeekProps { - startDate: Date; - visibleCalendars: Pick[]; - calendarEvents: CalendarEvent[]; - onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; - onClickOpenEvent: (calendarEvent: CalendarEvent) => void; -} - -export default function CalendarViewWeek( - { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps, -) { - const today = new Date().toISOString().substring(0, 10); - - const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); - - 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.setHours(23); - endDayDate.setMinutes(59); - endDayDate.setSeconds(59); - endDayDate.setMilliseconds(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.start_date); - const eventEndDate = new Date(calendarEvent.end_date); - - // 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 deleted file mode 100644 index 7f13326..0000000 --- a/components/calendar/CalendarViewWeek.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { getCalendarEventColor, getDaysForWeek } from '/lib/utils/calendar.ts'; - -interface CalendarViewWeekProps { - startDate: Date; - visibleCalendars: Pick[]; - calendarEvents: CalendarEvent[]; - onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void; - onClickOpenEvent: (calendarEvent: CalendarEvent) => void; -} - -export default function CalendarViewWeek( - { startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps, -) { - const today = new Date().toISOString().substring(0, 10); - - const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); - const weekDayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: '2-digit' }); - - const days = getDaysForWeek(new Date(startDate)); - - return ( -
-
- {days.map((day, dayIndex) => { - const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => { - if (!calendarEvent.is_all_day) { - return false; - } - - const startDayDate = new Date(day.date); - const endDayDate = new Date(day.date); - endDayDate.setHours(23); - endDayDate.setMinutes(59); - endDayDate.setSeconds(59); - endDayDate.setMilliseconds(999); - - const eventStartDate = new Date(calendarEvent.start_date); - const eventEndDate = new Date(calendarEvent.end_date); - - // 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.setHours(hour.date.getHours()); - const endHourDate = new Date(shortIsoDate); - endHourDate.setHours(hour.date.getHours()); - endHourDate.setMinutes(59); - endHourDate.setSeconds(59); - endHourDate.setMilliseconds(999); - - const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0; - const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6; - - const hourEvents = calendarEvents.filter((calendarEvent) => { - if (calendarEvent.is_all_day) { - return false; - } - - const eventStartDate = new Date(calendarEvent.start_date); - const eventEndDate = new Date(calendarEvent.end_date); - eventEndDate.setSeconds(eventEndDate.getSeconds() - 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 deleted file mode 100644 index 14d6c90..0000000 --- a/components/calendar/ImportEventsModal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useSignal } from '@preact/signals'; -import { useEffect } from 'preact/hooks'; - -import { Calendar } from '/lib/types.ts'; - -interface ImportEventsModalProps { - isOpen: boolean; - calendars: Pick[]; - 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]!.id; - } - }, [isOpen]); - - return ( - <> -
-
- -
-

Import Events

-
-
- -
- - calendar.id === newCalendarId.value)?.color - } rounded-full`} - title={calendars.find((calendar) => calendar.id === newCalendarId.value)?.color} - > - -
-
-
-
- - -
-
- - ); -} diff --git a/components/calendar/MainCalendar.tsx b/components/calendar/MainCalendar.tsx deleted file mode 100644 index d7384b1..0000000 --- a/components/calendar/MainCalendar.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import { useSignal } from '@preact/signals'; - -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { baseUrl, capitalizeWord } from '/lib/utils/misc.ts'; -import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; -import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/calendar/get-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, { NewCalendarEvent } from './AddEventModal.tsx'; -import ViewEventModal from './ViewEventModal.tsx'; -import SearchEvents from './SearchEvents.tsx'; -import ImportEventsModal from './ImportEventsModal.tsx'; - -interface MainCalendarProps { - initialCalendars: Pick[]; - initialCalendarEvents: CalendarEvent[]; - view: 'day' | 'week' | 'month'; - startDate: string; -} - -export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: 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' }); - const today = new Date().toISOString().substring(0, 10); - - const visibleCalendars = calendars.value.filter((calendar) => calendar.is_visible); - - 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: NewCalendarEvent) { - if (isAdding.value) { - return; - } - - if (!newEvent) { - return; - } - - isAdding.value = true; - - try { - const requestBody: AddRequestBody = { - calendarIds: visibleCalendars.map((calendar) => calendar.id), - calendarView: view, - calendarStartDate: startDate, - calendarId: newEvent.calendar_id, - title: newEvent.title, - startDate: new Date(newEvent.start_date).toISOString(), - endDate: new Date(newEvent.end_date).toISOString(), - isAllDay: newEvent.is_all_day, - }; - 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.id === calendarEvent.calendar_id)!; - - 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.id), - calendarView: view, - calendarStartDate: startDate, - calendarEventId, - calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.id === calendarEventId)!.calendar_id, - }; - 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 partialCalendarEvents = parseVCalendarFromTextContents(importFileContents!.toString()); - - const requestBody: ImportRequestBody = { - partialCalendarEvents, - calendarIds: visibleCalendars.map((calendar) => calendar.id), - 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: GetRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.id) }; - const response = await fetch(`/api/calendar/get-events`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as GetResponseBody; - - if (!result.success) { - throw new Error('Failed to get contact!'); - } - - const exportContents = formatCalendarEventsToVCalendar([...result.calendarEvents], calendars.value); - - // 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 URLs:{' '} - {baseUrl}/dav/principals/{' '} - {baseUrl}/dav/calendars/ -
- - - - - - { - openImportModal.value = { isOpen: false }; - }} - /> - - ); -} diff --git a/components/calendar/SearchEvents.tsx b/components/calendar/SearchEvents.tsx deleted file mode 100644 index 5dc1e11..0000000 --- a/components/calendar/SearchEvents.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useSignal } from '@preact/signals'; -import { useEffect } from 'preact/hooks'; - -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx'; -interface SearchEventsProps { - calendars: Pick[]; - 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', - }); - - const calendarIds = calendars.map((calendar) => calendar.id); - - 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 deleted file mode 100644 index ee56602..0000000 --- a/components/calendar/ViewEventModal.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import { convertRRuleToWords } from '/lib/utils/calendar.ts'; - -interface ViewEventModalProps { - isOpen: boolean; - calendarEvent: CalendarEvent; - calendar: Pick; - onClickDelete: (eventId: string) => void; - onClose: () => void; -} - -export default function ViewEventModal( - { isOpen, calendarEvent, calendar, onClickDelete, onClose }: ViewEventModalProps, -) { - if (!calendarEvent || !calendar) { - return null; - } - - const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }); - const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' }); - - return ( - <> -
-
- -
-

{calendarEvent.title}

-
- - {calendarEvent.start_date ? allDayEventDateFormat.format(new Date(calendarEvent.start_date)) : ''} - - {calendarEvent.is_all_day ? All-day : ( - - {calendarEvent.start_date ? hourFormat.format(new Date(calendarEvent.start_date)) : ''} -{' '} - {calendarEvent.end_date ? hourFormat.format(new Date(calendarEvent.end_date)) : ''} - - )} -
-
- - {calendar.name} - - -
- {calendarEvent.extra.description - ? ( -
-
- {calendarEvent.extra.description} -
-
- ) - : null} - {calendarEvent.extra.url - ? ( -
- - {calendarEvent.extra.url} - -
- ) - : null} - {calendarEvent.extra.location - ? ( -
- - {calendarEvent.extra.location} - -
- ) - : null} - {Array.isArray(calendarEvent.extra.attendees) && calendarEvent.extra.attendees.length > 0 - ? ( -
- {calendarEvent.extra.attendees.map((attendee) => ( -

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

- ))} -
- ) - : null} - {calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule - ? ( -
-

Repeats {convertRRuleToWords(calendarEvent.extra.recurring_rrule).toLowerCase()}.

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

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

- ))} -
- ) - : null} -
- - - Edit - - -
-
- - ); -} diff --git a/components/files/CreateDirectoryModal.tsx b/components/files/CreateDirectoryModal.tsx new file mode 100644 index 0000000..8fc36ce --- /dev/null +++ b/components/files/CreateDirectoryModal.tsx @@ -0,0 +1,60 @@ +import { useSignal } from '@preact/signals'; + +interface CreateDirectoryModalProps { + isOpen: boolean; + onClickSave: (newDirectoryName: string) => Promise; + onClose: () => void; +} + +export default function CreateDirectoryModal( + { isOpen, onClickSave, onClose }: CreateDirectoryModalProps, +) { + const newDirectoryName = useSignal(''); + + return ( + <> +
+
+ +
+

Create New Directory

+
+
+ + { + newDirectoryName.value = event.currentTarget.value; + }} + placeholder='Amazing' + /> +
+
+
+ + +
+
+ + ); +} diff --git a/components/files/FilesBreadcrumb.tsx b/components/files/FilesBreadcrumb.tsx new file mode 100644 index 0000000..43a3084 --- /dev/null +++ b/components/files/FilesBreadcrumb.tsx @@ -0,0 +1,44 @@ +interface FilesBreadcrumbProps { + path: string; +} + +export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) { + if (path === '/') { + return ( +

+ All files +

+ ); + } + + const pathParts = path.slice(1, -1).split('/'); + + return ( +

+ All files + {pathParts.map((part, index) => { + if (index === pathParts.length - 1) { + return ( + <> + / + {part} + + ); + } + + const fullPathForPart: string[] = []; + + for (let pathPartIndex = 0; pathPartIndex <= index; ++pathPartIndex) { + fullPathForPart.push(pathParts[pathPartIndex]); + } + + return ( + <> + / + {part} + + ); + })} +

+ ); +} diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx new file mode 100644 index 0000000..be9bcf0 --- /dev/null +++ b/components/files/ListFiles.tsx @@ -0,0 +1,209 @@ +import { Directory, DirectoryFile } from '/lib/types.ts'; +import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts'; + +interface ListFilesProps { + directories: Directory[]; + files: DirectoryFile[]; + onClickDeleteDirectory: (parentPath: string, name: string) => Promise; + onClickDeleteFile: (parentPath: string, name: string) => Promise; + onClickOpenRenameDirectory: (parentPath: string, name: string) => void; + onClickOpenRenameFile: (parentPath: string, name: string) => void; + onClickOpenMoveDirectory: (parentPath: string, name: string) => void; + onClickOpenMoveFile: (parentPath: string, name: string) => void; +} + +export default function ListFiles( + { + directories, + files, + onClickDeleteDirectory, + onClickDeleteFile, + onClickOpenRenameDirectory, + onClickOpenRenameFile, + onClickOpenMoveDirectory, + onClickOpenMoveFile, + }: ListFilesProps, +) { + const dateFormat = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour12: false, + hour: '2-digit', + minute: '2-digit', + }); + + return ( +
+ + + + + + + + + + + {directories.map((directory) => { + const fullPath = `${directory.parent_path}${directory.directory_name}/`; + + return ( + + + + + + + ); + })} + {files.map((file) => ( + + + + + + + ))} + {directories.length === 0 && files.length === 0 + ? ( + + + + ) + : null} + +
NameLast updateSize
+ + Directory + {directory.directory_name} + + + {dateFormat.format(new Date(directory.updated_at))} + + - + + {fullPath === TRASH_PATH ? null : ( +
+ + + +
+ )} +
+ + File + {file.file_name} + + + {dateFormat.format(new Date(file.updated_at))} + + {humanFileSize(file.size_in_bytes)} + +
+ + + +
+
+
+
No files to show
+
+
+
+ ); +} diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx new file mode 100644 index 0000000..06598f9 --- /dev/null +++ b/components/files/MainFiles.tsx @@ -0,0 +1,522 @@ +import { useSignal } from '@preact/signals'; + +import { Directory, DirectoryFile } from '/lib/types.ts'; +import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx'; +import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx'; +import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx'; +import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx'; +import { + RequestBody as CreateDirectoryRequestBody, + ResponseBody as CreateDirectoryResponseBody, +} from '/routes/api/files/create-directory.tsx'; +import { + RequestBody as RenameDirectoryRequestBody, + ResponseBody as RenameDirectoryResponseBody, +} from '/routes/api/files/rename-directory.tsx'; +import { + RequestBody as MoveDirectoryRequestBody, + ResponseBody as MoveDirectoryResponseBody, +} from '/routes/api/files/move-directory.tsx'; +import { + RequestBody as DeleteDirectoryRequestBody, + ResponseBody as DeleteDirectoryResponseBody, +} from '/routes/api/files/delete-directory.tsx'; +import SearchFiles from './SearchFiles.tsx'; +import ListFiles from './ListFiles.tsx'; +import FilesBreadcrumb from './FilesBreadcrumb.tsx'; +import CreateDirectoryModal from './CreateDirectoryModal.tsx'; +import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx'; +import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx'; + +interface MainFilesProps { + initialDirectories: Directory[]; + initialFiles: DirectoryFile[]; + initialPath: string; +} + +export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) { + const isAdding = useSignal(false); + const isUploading = useSignal(false); + const isDeleting = useSignal(false); + const isUpdating = useSignal(false); + const directories = useSignal(initialDirectories); + const files = useSignal(initialFiles); + const path = useSignal(initialPath); + const areNewOptionsOption = useSignal(false); + const isNewDirectoryModalOpen = useSignal(false); + const renameDirectoryOrFileModal = useSignal< + { isOpen: boolean; isDirectory: boolean; parentPath: string; name: string } | null + >(null); + const moveDirectoryOrFileModal = useSignal< + { isOpen: boolean; isDirectory: boolean; path: string; name: string } | null + >(null); + + function onClickUploadFile() { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + + fileInput.onchange = async (event) => { + const chosenFiles = (event.target as HTMLInputElement)?.files!; + const chosenFile = chosenFiles[0]; + + if (!chosenFile) { + return; + } + + isUploading.value = true; + + areNewOptionsOption.value = false; + + const requestBody = new FormData(); + requestBody.set('parent_path', path.value); + requestBody.set('name', chosenFile.name); + requestBody.set('contents', chosenFile); + + try { + const response = await fetch(`/api/files/upload`, { + method: 'POST', + body: requestBody, + }); + const result = await response.json() as UploadResponseBody; + + if (!result.success) { + throw new Error('Failed to upload file!'); + } + + files.value = [...result.newFiles]; + } catch (error) { + console.error(error); + } + + isUploading.value = false; + }; + } + + function onClickCreateDirectory() { + if (isNewDirectoryModalOpen.value) { + isNewDirectoryModalOpen.value = false; + return; + } + + isNewDirectoryModalOpen.value = true; + } + + async function onClickSaveDirectory(newDirectoryName: string) { + if (isAdding.value) { + return; + } + + if (!newDirectoryName) { + return; + } + + areNewOptionsOption.value = false; + isAdding.value = true; + + try { + const requestBody: CreateDirectoryRequestBody = { + parentPath: path.value, + name: newDirectoryName, + }; + const response = await fetch(`/api/files/create-directory`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as CreateDirectoryResponseBody; + + if (!result.success) { + throw new Error('Failed to create directory!'); + } + + directories.value = [...result.newDirectories]; + + isNewDirectoryModalOpen.value = false; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function onCloseCreateDirectory() { + isNewDirectoryModalOpen.value = false; + } + + function toggleNewOptionsDropdown() { + areNewOptionsOption.value = !areNewOptionsOption.value; + } + + function onClickOpenRenameDirectory(parentPath: string, name: string) { + renameDirectoryOrFileModal.value = { + isOpen: true, + isDirectory: true, + parentPath, + name, + }; + } + + function onClickOpenRenameFile(parentPath: string, name: string) { + renameDirectoryOrFileModal.value = { + isOpen: true, + isDirectory: false, + parentPath, + name, + }; + } + + function onClickCloseRename() { + renameDirectoryOrFileModal.value = null; + } + + async function onClickSaveRenameDirectory(newName: string) { + if ( + isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || !renameDirectoryOrFileModal.value?.isDirectory + ) { + return; + } + + isUpdating.value = true; + + try { + const requestBody: RenameDirectoryRequestBody = { + parentPath: renameDirectoryOrFileModal.value.parentPath, + oldName: renameDirectoryOrFileModal.value.name, + newName, + }; + const response = await fetch(`/api/files/rename-directory`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as RenameDirectoryResponseBody; + + if (!result.success) { + throw new Error('Failed to rename directory!'); + } + + directories.value = [...result.newDirectories]; + } catch (error) { + console.error(error); + } + + isUpdating.value = false; + renameDirectoryOrFileModal.value = null; + } + + async function onClickSaveRenameFile(newName: string) { + if ( + isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || renameDirectoryOrFileModal.value?.isDirectory + ) { + return; + } + + isUpdating.value = true; + + try { + const requestBody: RenameRequestBody = { + parentPath: renameDirectoryOrFileModal.value.parentPath, + oldName: renameDirectoryOrFileModal.value.name, + newName, + }; + const response = await fetch(`/api/files/rename`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as RenameResponseBody; + + if (!result.success) { + throw new Error('Failed to rename file!'); + } + + files.value = [...result.newFiles]; + } catch (error) { + console.error(error); + } + + isUpdating.value = false; + renameDirectoryOrFileModal.value = null; + } + + function onClickOpenMoveDirectory(parentPath: string, name: string) { + moveDirectoryOrFileModal.value = { + isOpen: true, + isDirectory: true, + path: parentPath, + name, + }; + } + + function onClickOpenMoveFile(parentPath: string, name: string) { + moveDirectoryOrFileModal.value = { + isOpen: true, + isDirectory: false, + path: parentPath, + name, + }; + } + + function onClickCloseMove() { + moveDirectoryOrFileModal.value = null; + } + + async function onClickSaveMoveDirectory(newPath: string) { + if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || !moveDirectoryOrFileModal.value?.isDirectory) { + return; + } + + isUpdating.value = true; + + try { + const requestBody: MoveDirectoryRequestBody = { + oldParentPath: moveDirectoryOrFileModal.value.path, + newParentPath: newPath, + name: moveDirectoryOrFileModal.value.name, + }; + const response = await fetch(`/api/files/move-directory`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as MoveDirectoryResponseBody; + + if (!result.success) { + throw new Error('Failed to move directory!'); + } + + directories.value = [...result.newDirectories]; + } catch (error) { + console.error(error); + } + + isUpdating.value = false; + moveDirectoryOrFileModal.value = null; + } + + async function onClickSaveMoveFile(newPath: string) { + if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || moveDirectoryOrFileModal.value?.isDirectory) { + return; + } + + isUpdating.value = true; + + try { + const requestBody: MoveRequestBody = { + oldParentPath: moveDirectoryOrFileModal.value.path, + newParentPath: newPath, + name: moveDirectoryOrFileModal.value.name, + }; + const response = await fetch(`/api/files/move`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as MoveResponseBody; + + if (!result.success) { + throw new Error('Failed to move file!'); + } + + files.value = [...result.newFiles]; + } catch (error) { + console.error(error); + } + + isUpdating.value = false; + moveDirectoryOrFileModal.value = null; + } + + async function onClickDeleteDirectory(parentPath: string, name: string) { + if (confirm('Are you sure you want to delete this directory?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteDirectoryRequestBody = { + parentPath, + name, + }; + const response = await fetch(`/api/files/delete-directory`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteDirectoryResponseBody; + + if (!result.success) { + throw new Error('Failed to delete directory!'); + } + + directories.value = [...result.newDirectories]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + async function onClickDeleteFile(parentPath: string, name: string) { + if (confirm('Are you sure you want to delete this file?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { + parentPath, + name, + }; + const response = await fetch(`/api/files/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete file!'); + } + + files.value = [...result.newFiles]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+
+
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isAdding.value + ? ( + <> + Creating... + + ) + : null} + {isUploading.value + ? ( + <> + Uploading... + + ) + : null} + {isUpdating.value + ? ( + <> + Updating... + + ) + : null} + {!isDeleting.value && !isAdding.value && !isUploading.value && !isUpdating.value ? <>  : null} + +
+ + + + + + + + ); +} diff --git a/components/files/MoveDirectoryOrFileModal.tsx b/components/files/MoveDirectoryOrFileModal.tsx new file mode 100644 index 0000000..4987e76 --- /dev/null +++ b/components/files/MoveDirectoryOrFileModal.tsx @@ -0,0 +1,145 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx'; +import { Directory } from '/lib/types.ts'; + +interface MoveDirectoryOrFileModalProps { + isOpen: boolean; + initialPath: string; + isDirectory: boolean; + name: string; + onClickSave: (newPath: string) => Promise; + onClose: () => void; +} + +export default function MoveDirectoryOrFileModal( + { isOpen, initialPath, isDirectory, name, onClickSave, onClose }: MoveDirectoryOrFileModalProps, +) { + const newPath = useSignal(initialPath); + const isLoading = useSignal(false); + const directories = useSignal([]); + + useEffect(() => { + newPath.value = initialPath; + + fetchDirectories(); + }, [initialPath]); + + async function fetchDirectories() { + if (!initialPath) { + return; + } + + isLoading.value = true; + + try { + const requestBody: RequestBody = { + parentPath: newPath.value, + directoryPathToExclude: isDirectory ? `${initialPath}${name}` : '', + }; + const response = await fetch(`/api/files/get-directories`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to get directories!'); + } + + directories.value = [...result.directories]; + + isLoading.value = false; + } catch (error) { + console.error(error); + } + } + + async function onChooseNewDirectory(chosenPath: string) { + newPath.value = chosenPath; + + await fetchDirectories(); + } + + const parentPath = newPath.value === '/' + ? null + : `/${newPath.peek().split('/').filter(Boolean).slice(0, -1).join('/')}`; + + if (!name) { + return null; + } + + return ( + <> +
+
+ +
+

Move "{name}" into "{newPath.value}"

+
+
    + {parentPath + ? ( +
  1. + onChooseNewDirectory(parentPath === '/' ? parentPath : `${parentPath}/`)} + > +

    + .. +

    +
    +
  2. + ) + : null} + {directories.value.map((directory) => ( +
  3. + onChooseNewDirectory(`${directory.parent_path}${directory.directory_name}/`)} + > +

    + {directory.directory_name} +

    +
    +
  4. + ))} +
+ + {isLoading.value + ? ( + <> + Loading... + + ) + : null} + {!isLoading.value ? <>  : null} + +
+
+ + +
+
+ + ); +} diff --git a/components/files/RenameDirectoryOrFileModal.tsx b/components/files/RenameDirectoryOrFileModal.tsx new file mode 100644 index 0000000..6a65096 --- /dev/null +++ b/components/files/RenameDirectoryOrFileModal.tsx @@ -0,0 +1,67 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +interface RenameDirectoryOrFileModalProps { + isOpen: boolean; + initialName: string; + isDirectory: boolean; + onClickSave: (newName: string) => Promise; + onClose: () => void; +} + +export default function RenameDirectoryOrFileModal( + { isOpen, initialName, isDirectory, onClickSave, onClose }: RenameDirectoryOrFileModalProps, +) { + const newName = useSignal(initialName); + + useEffect(() => { + newName.value = initialName; + }, [initialName]); + + return ( + <> +
+
+ +
+

Rename {isDirectory ? 'Directory' : 'File'}

+
+
+ + { + newName.value = event.currentTarget.value; + }} + placeholder='Amazing' + /> +
+
+
+ + +
+
+ + ); +} diff --git a/components/files/SearchFiles.tsx b/components/files/SearchFiles.tsx new file mode 100644 index 0000000..78fa0b5 --- /dev/null +++ b/components/files/SearchFiles.tsx @@ -0,0 +1,165 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Directory, DirectoryFile } from '/lib/types.ts'; +// import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx'; +interface SearchFilesProps {} + +export default function SearchFiles({}: SearchFilesProps) { + const isSearching = useSignal(false); + const areResultsVisible = useSignal(false); + const matchingDirectories = useSignal([]); + const matchingFiles = 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', + }); + + function searchFiles(searchTerm: string) { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (searchTerm.trim().length < 2) { + return; + } + + areResultsVisible.value = false; + + searchTimeout.value = setTimeout(() => { + isSearching.value = true; + + // TODO: Build this + // try { + // const requestBody: RequestBody = { searchTerm }; + // const response = await fetch(`/api/files/search`, { + // method: 'POST', + // body: JSON.stringify(requestBody), + // }); + // const result = await response.json() as ResponseBody; + + // if (!result.success) { + // throw new Error('Failed to search files!'); + // } + + // matchingDirectories.value = result.matchingDirectories; + // matchingFiles.value = result.matchingFiles; + + // if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) { + // areResultsVisible.value = true; + // } + // } catch (error) { + // console.error(error); + // } + + isSearching.value = false; + }, 500); + } + + function onFocus() { + if (matchingDirectories.value.length > 0 || matchingFiles.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 ( + <> + searchFiles(event.currentTarget.value)} + onFocus={() => onFocus()} + onBlur={() => onBlur()} + /> + {isSearching.value ? : null} + {areResultsVisible.value + ? ( +
+ +
+ ) + : null} + + ); +} diff --git a/crons/news.ts b/crons/news.ts index 3cb080b..3be18b4 100644 --- a/crons/news.ts +++ b/crons/news.ts @@ -1,5 +1,4 @@ import Database, { sql } from '/lib/interfaces/database.ts'; -import Locker from '/lib/interfaces/locker.ts'; import { NewsFeed } from '/lib/types.ts'; import { concurrentPromises } from '/lib/utils/misc.ts'; import { crawlNewsFeed } from '/lib/data/news.ts'; @@ -9,10 +8,6 @@ const db = new Database(); export async function fetchNewArticles(forceFetch = false) { const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4)); - const lock = new Locker(`feeds`); - - await lock.acquire(); - try { const feedsToCrawl = await db.query( sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`, @@ -27,6 +22,4 @@ export async function fetchNewArticles(forceFetch = false) { } catch (error) { console.log(error); } - - lock.release(); } diff --git a/db-migrations/003-contacts-app.pgsql b/db-migrations/003-contacts-app.pgsql deleted file mode 100644 index fc08abb..0000000 --- a/db-migrations/003-contacts-app.pgsql +++ /dev/null @@ -1,52 +0,0 @@ -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - - --- --- Name: bewcloud_contacts; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.bewcloud_contacts ( - id uuid DEFAULT gen_random_uuid(), - user_id uuid DEFAULT gen_random_uuid(), - revision text NOT NULL, - first_name text NOT NULL, - last_name text NOT NULL, - extra jsonb NOT NULL, - updated_at timestamp with time zone DEFAULT now(), - created_at timestamp with time zone DEFAULT now() -); - - -ALTER TABLE public.bewcloud_contacts OWNER TO postgres; - - --- --- Name: bewcloud_contacts bewcloud_contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_contacts - ADD CONSTRAINT bewcloud_contacts_pkey PRIMARY KEY (id); - - --- --- Name: bewcloud_contacts bewcloud_contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_contacts - ADD CONSTRAINT bewcloud_contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); - - --- --- Name: TABLE bewcloud_contacts; Type: ACL; Schema: public; Owner: postgres --- - -GRANT ALL ON TABLE public.bewcloud_contacts TO postgres; diff --git a/db-migrations/004-calendar-app.pgsql b/db-migrations/004-calendar-app.pgsql deleted file mode 100644 index d998775..0000000 --- a/db-migrations/004-calendar-app.pgsql +++ /dev/null @@ -1,107 +0,0 @@ -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - - --- --- Name: bewcloud_calendars; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.bewcloud_calendars ( - id uuid DEFAULT gen_random_uuid(), - user_id uuid DEFAULT gen_random_uuid(), - revision text NOT NULL, - name text NOT NULL, - color text NOT NULL, - is_visible boolean NOT NULL, - extra jsonb NOT NULL, - updated_at timestamp with time zone DEFAULT now(), - created_at timestamp with time zone DEFAULT now() -); - - -ALTER TABLE public.bewcloud_calendars OWNER TO postgres; - - --- --- Name: bewcloud_calendars bewcloud_calendars_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_calendars - ADD CONSTRAINT bewcloud_calendars_pkey PRIMARY KEY (id); - - --- --- Name: bewcloud_calendars bewcloud_calendars_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_calendars - ADD CONSTRAINT bewcloud_calendars_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); - - --- --- Name: TABLE bewcloud_calendars; Type: ACL; Schema: public; Owner: postgres --- - -GRANT ALL ON TABLE public.bewcloud_calendars TO postgres; - - --- --- Name: bewcloud_calendar_events; Type: TABLE; Schema: public; Owner: postgres --- - -CREATE TABLE public.bewcloud_calendar_events ( - id uuid DEFAULT gen_random_uuid(), - user_id uuid DEFAULT gen_random_uuid(), - calendar_id uuid DEFAULT gen_random_uuid(), - revision text NOT NULL, - title text NOT NULL, - start_date timestamp with time zone NOT NULL, - end_date timestamp with time zone NOT NULL, - is_all_day boolean NOT NULL, - status VARCHAR NOT NULL, - extra jsonb NOT NULL, - updated_at timestamp with time zone DEFAULT now(), - created_at timestamp with time zone DEFAULT now() -); - - -ALTER TABLE public.bewcloud_calendar_events OWNER TO postgres; - - --- --- Name: bewcloud_calendar_events bewcloud_calendar_events_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_calendar_events - ADD CONSTRAINT bewcloud_calendar_events_pkey PRIMARY KEY (id); - - --- --- Name: bewcloud_calendar_events bewcloud_calendar_events_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_calendar_events - ADD CONSTRAINT bewcloud_calendar_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); - - --- --- Name: bewcloud_calendar_events bewcloud_calendar_events_calendar_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE ONLY public.bewcloud_calendar_events - ADD CONSTRAINT bewcloud_calendar_events_calendar_id_fkey FOREIGN KEY (calendar_id) REFERENCES public.bewcloud_calendars(id); - - --- --- Name: TABLE bewcloud_calendar_events; Type: ACL; Schema: public; Owner: postgres --- - -GRANT ALL ON TABLE public.bewcloud_calendar_events TO postgres; diff --git a/fresh.gen.ts b/fresh.gen.ts index 77ec498..61e4c4b 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -2,124 +2,80 @@ // This file SHOULD be checked into source version control. // This file is automatically updated during development when running `dev.ts`. -import * as $_well_known_caldav from './routes/.well-known/caldav.tsx'; -import * as $_well_known_carddav from './routes/.well-known/carddav.tsx'; import * as $_404 from './routes/_404.tsx'; import * as $_app from './routes/_app.tsx'; import * as $_middleware from './routes/_middleware.tsx'; -import * as $api_calendar_add_event from './routes/api/calendar/add-event.tsx'; -import * as $api_calendar_add from './routes/api/calendar/add.tsx'; -import * as $api_calendar_delete_event from './routes/api/calendar/delete-event.tsx'; -import * as $api_calendar_delete from './routes/api/calendar/delete.tsx'; -import * as $api_calendar_get_events from './routes/api/calendar/get-events.tsx'; -import * as $api_calendar_import from './routes/api/calendar/import.tsx'; -import * as $api_calendar_search_events from './routes/api/calendar/search-events.tsx'; -import * as $api_calendar_update from './routes/api/calendar/update.tsx'; -import * as $api_contacts_add from './routes/api/contacts/add.tsx'; -import * as $api_contacts_delete from './routes/api/contacts/delete.tsx'; -import * as $api_contacts_get from './routes/api/contacts/get.tsx'; -import * as $api_contacts_import from './routes/api/contacts/import.tsx'; import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx'; import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx'; +import * as $api_files_create_directory from './routes/api/files/create-directory.tsx'; +import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx'; +import * as $api_files_delete from './routes/api/files/delete.tsx'; +import * as $api_files_get_directories from './routes/api/files/get-directories.tsx'; +import * as $api_files_move_directory from './routes/api/files/move-directory.tsx'; +import * as $api_files_move from './routes/api/files/move.tsx'; +import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx'; +import * as $api_files_rename from './routes/api/files/rename.tsx'; +import * as $api_files_upload from './routes/api/files/upload.tsx'; import * as $api_news_add_feed from './routes/api/news/add-feed.tsx'; import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx'; import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx'; import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; -import * as $calendar from './routes/calendar.tsx'; -import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx'; -import * as $calendars from './routes/calendars.tsx'; -import * as $contacts from './routes/contacts.tsx'; -import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx'; import * as $dashboard from './routes/dashboard.tsx'; -import * as $dav_addressbooks from './routes/dav/addressbooks.tsx'; -import * as $dav_addressbooks_contacts from './routes/dav/addressbooks/contacts.tsx'; -import * as $dav_addressbooks_contacts_contactId_vcf from './routes/dav/addressbooks/contacts/[contactId].vcf.tsx'; -import * as $dav_calendars from './routes/dav/calendars.tsx'; -import * as $dav_calendars_calendarId_ from './routes/dav/calendars/[calendarId].tsx'; -import * as $dav_calendars_calendarId_calendarEventId_ics from './routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx'; -import * as $dav_files from './routes/dav/files.tsx'; -import * as $dav_index from './routes/dav/index.tsx'; -import * as $dav_principals from './routes/dav/principals.tsx'; +import * as $files from './routes/files.tsx'; +import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx'; import * as $index from './routes/index.tsx'; import * as $login from './routes/login.tsx'; import * as $logout from './routes/logout.tsx'; import * as $news from './routes/news.tsx'; import * as $news_feeds from './routes/news/feeds.tsx'; -import * as $remote_php_davRoute_ from './routes/remote.php/[davRoute].tsx'; import * as $settings from './routes/settings.tsx'; import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; -import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx'; -import * as $calendar_Calendars from './islands/calendar/Calendars.tsx'; -import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx'; -import * as $contacts_Contacts from './islands/contacts/Contacts.tsx'; -import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; +import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx'; import * as $news_Articles from './islands/news/Articles.tsx'; import * as $news_Feeds from './islands/news/Feeds.tsx'; import { type Manifest } from '$fresh/server.ts'; const manifest = { routes: { - './routes/.well-known/caldav.tsx': $_well_known_caldav, - './routes/.well-known/carddav.tsx': $_well_known_carddav, './routes/_404.tsx': $_404, './routes/_app.tsx': $_app, './routes/_middleware.tsx': $_middleware, - './routes/api/calendar/add-event.tsx': $api_calendar_add_event, - './routes/api/calendar/add.tsx': $api_calendar_add, - './routes/api/calendar/delete-event.tsx': $api_calendar_delete_event, - './routes/api/calendar/delete.tsx': $api_calendar_delete, - './routes/api/calendar/get-events.tsx': $api_calendar_get_events, - './routes/api/calendar/import.tsx': $api_calendar_import, - './routes/api/calendar/search-events.tsx': $api_calendar_search_events, - './routes/api/calendar/update.tsx': $api_calendar_update, - './routes/api/contacts/add.tsx': $api_contacts_add, - './routes/api/contacts/delete.tsx': $api_contacts_delete, - './routes/api/contacts/get.tsx': $api_contacts_get, - './routes/api/contacts/import.tsx': $api_contacts_import, './routes/api/dashboard/save-links.tsx': $api_dashboard_save_links, './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, + './routes/api/files/create-directory.tsx': $api_files_create_directory, + './routes/api/files/delete-directory.tsx': $api_files_delete_directory, + './routes/api/files/delete.tsx': $api_files_delete, + './routes/api/files/get-directories.tsx': $api_files_get_directories, + './routes/api/files/move-directory.tsx': $api_files_move_directory, + './routes/api/files/move.tsx': $api_files_move, + './routes/api/files/rename-directory.tsx': $api_files_rename_directory, + './routes/api/files/rename.tsx': $api_files_rename, + './routes/api/files/upload.tsx': $api_files_upload, './routes/api/news/add-feed.tsx': $api_news_add_feed, './routes/api/news/delete-feed.tsx': $api_news_delete_feed, './routes/api/news/import-feeds.tsx': $api_news_import_feeds, './routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, - './routes/calendar.tsx': $calendar, - './routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_, - './routes/calendars.tsx': $calendars, - './routes/contacts.tsx': $contacts, - './routes/contacts/[contactId].tsx': $contacts_contactId_, './routes/dashboard.tsx': $dashboard, - './routes/dav/addressbooks.tsx': $dav_addressbooks, - './routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts, - './routes/dav/addressbooks/contacts/[contactId].vcf.tsx': $dav_addressbooks_contacts_contactId_vcf, - './routes/dav/calendars.tsx': $dav_calendars, - './routes/dav/calendars/[calendarId].tsx': $dav_calendars_calendarId_, - './routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx': $dav_calendars_calendarId_calendarEventId_ics, - './routes/dav/files.tsx': $dav_files, - './routes/dav/index.tsx': $dav_index, - './routes/dav/principals.tsx': $dav_principals, + './routes/files.tsx': $files, + './routes/files/open/[fileName].tsx': $files_open_fileName_, './routes/index.tsx': $index, './routes/login.tsx': $login, './routes/logout.tsx': $logout, './routes/news.tsx': $news, './routes/news/feeds.tsx': $news_feeds, - './routes/remote.php/[davRoute].tsx': $remote_php_davRoute_, './routes/settings.tsx': $settings, './routes/signup.tsx': $signup, }, islands: { './islands/Settings.tsx': $Settings, - './islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper, - './islands/calendar/Calendars.tsx': $calendar_Calendars, - './islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent, - './islands/contacts/Contacts.tsx': $contacts_Contacts, - './islands/contacts/ViewContact.tsx': $contacts_ViewContact, './islands/dashboard/Links.tsx': $dashboard_Links, './islands/dashboard/Notes.tsx': $dashboard_Notes, + './islands/files/FilesWrapper.tsx': $files_FilesWrapper, './islands/news/Articles.tsx': $news_Articles, './islands/news/Feeds.tsx': $news_Feeds, }, diff --git a/import_map.json b/import_map.json index b6659a9..1604acd 100644 --- a/import_map.json +++ b/import_map.json @@ -2,8 +2,6 @@ "imports": { "/": "./", "./": "./", - "xml/": "https://deno.land/x/xml@2.1.3/", - "rrule-rust": "npm:rrule-rust@1.2.0", "fresh/": "https://deno.land/x/fresh@1.6.8/", "$fresh/": "https://deno.land/x/fresh@1.6.8/", diff --git a/islands/calendar/CalendarWrapper.tsx b/islands/calendar/CalendarWrapper.tsx deleted file mode 100644 index 3c4de91..0000000 --- a/islands/calendar/CalendarWrapper.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Calendar, CalendarEvent } from '/lib/types.ts'; -import MainCalendar from '/components/calendar/MainCalendar.tsx'; - -interface CalendarWrapperProps { - initialCalendars: Pick[]; - initialCalendarEvents: CalendarEvent[]; - view: 'day' | 'week' | 'month'; - startDate: string; -} - -// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself -export default function CalendarWrapper( - { initialCalendars, initialCalendarEvents, view, startDate }: CalendarWrapperProps, -) { - return ( - - ); -} diff --git a/islands/calendar/Calendars.tsx b/islands/calendar/Calendars.tsx deleted file mode 100644 index 8b2c97f..0000000 --- a/islands/calendar/Calendars.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { useSignal } from '@preact/signals'; - -import { Calendar } from '/lib/types.ts'; -import { CALENDAR_COLOR_OPTIONS } 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?.id) { - alert('A calendar is required to update one!'); - return; - } - - if (!openCalendar.value?.name) { - alert('A name is required to update the calendar!'); - return; - } - - if (!openCalendar.value?.color) { - alert('A color is required to update the calendar!'); - return; - } - - isSaving.value = true; - - try { - const requestBody: UpdateRequestBody = { ...openCalendar.value }; - 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.name} - - openCalendar.value = { ...calendar }} - > - - - {calendar.is_visible ? '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!, name: event.currentTarget.value }} - placeholder='Personal' - /> -
-
- -
- - - -
-
-
- - - openCalendar.value = { ...openCalendar.value!, is_visible: event.currentTarget.checked }} - /> -
-
-
- - -
-
- - ); -} diff --git a/islands/calendar/ViewCalendarEvent.tsx b/islands/calendar/ViewCalendarEvent.tsx deleted file mode 100644 index 482d91a..0000000 --- a/islands/calendar/ViewCalendarEvent.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useSignal } from '@preact/signals'; - -import { Calendar, CalendarEvent } from '/lib/types.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[]) { - const fields: FormField[] = [ - { - name: 'title', - label: 'Title', - type: 'text', - placeholder: 'Dentis', - value: calendarEvent.title, - required: true, - }, - { - name: 'calendar_id', - label: 'Calendar', - type: 'select', - value: calendarEvent.calendar_id, - options: calendars.map((calendar) => ({ label: calendar.name, value: calendar.id })), - required: true, - }, - { - name: 'start_date', - label: 'Start date', - type: 'datetime-local', - value: new Date(calendarEvent.start_date).toISOString().substring(0, 16), - required: true, - }, - { - name: 'end_date', - label: 'End date', - type: 'datetime-local', - value: new Date(calendarEvent.end_date).toISOString().substring(0, 16), - required: true, - }, - { - name: 'is_all_day', - label: 'All-day?', - type: 'checkbox', - placeholder: 'YYYYMMDD', - value: 'true', - required: false, - checked: calendarEvent.is_all_day, - }, - { - 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.extra.description, - required: false, - }, - { - name: 'url', - label: 'URL', - type: 'url', - placeholder: 'https://example.com', - value: calendarEvent.extra.url, - required: false, - }, - { - name: 'location', - label: 'Location', - type: 'text', - placeholder: 'Birmingham, UK', - value: calendarEvent.extra.location, - required: false, - }, - { - name: 'transparency', - label: 'Transparency', - type: 'select', - value: calendarEvent.extra.transparency, - options: (['default', 'opaque', 'transparent'] as CalendarEvent['extra']['transparency'][]).map(( - transparency, - ) => ({ - label: capitalizeWord(transparency), - value: transparency, - })), - required: true, - }, - ]; - - 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() { - if (confirm('Are you sure you want to delete this event?')) { - if (isDeleting.value) { - return; - } - - isDeleting.value = true; - - try { - const requestBody: DeleteRequestBody = { - calendarIds: calendars.map((calendar) => calendar.id), - calendarView: 'day', - calendarStartDate: new Date().toISOString().substring(0, 10), - calendarEventId: calendarEvent.value.id, - calendarId: calendarEvent.value.calendar_id, - }; - 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).map((field) => generateFieldHtml(field, formData))} - -
- -
-
- - - {isDeleting.value - ? ( - <> - Deleting... - - ) - : null} - {!isDeleting.value ? <>  : null} - -
- - ); -} diff --git a/islands/contacts/Contacts.tsx b/islands/contacts/Contacts.tsx deleted file mode 100644 index bc25d67..0000000 --- a/islands/contacts/Contacts.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import { useSignal } from '@preact/signals'; - -import { Contact } from '/lib/types.ts'; -import { baseUrl } from '/lib/utils/misc.ts'; -import { CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts'; -import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; -import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; -import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; -import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; - -interface ContactsProps { - initialContacts: Pick[]; - page: number; - contactsCount: number; - search?: string; -} - -export default function Contacts({ initialContacts, page, contactsCount, search }: ContactsProps) { - const isAdding = useSignal(false); - const isDeleting = useSignal(false); - const isExporting = useSignal(false); - const isImporting = useSignal(false); - const contacts = useSignal[]>(initialContacts); - const isOptionsDropdownOpen = useSignal(false); - - async function onClickAddContact() { - if (isAdding.value) { - return; - } - - const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim(); - - if (!firstName) { - alert('A first name is required for a new contact!'); - return; - } - - const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim(); - - isAdding.value = true; - - try { - const requestBody: AddRequestBody = { firstName, lastName, page }; - const response = await fetch(`/api/contacts/add`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as AddResponseBody; - - if (!result.success) { - throw new Error('Failed to add contact!'); - } - - contacts.value = [...result.contacts]; - } catch (error) { - console.error(error); - } - - isAdding.value = false; - } - - function toggleOptionsDropdown() { - isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value; - } - - async function onClickDeleteContact(contactId: string) { - if (confirm('Are you sure you want to delete this contact?')) { - if (isDeleting.value) { - return; - } - - isDeleting.value = true; - - try { - const requestBody: DeleteRequestBody = { contactId, page }; - const response = await fetch(`/api/contacts/delete`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as DeleteResponseBody; - - if (!result.success) { - throw new Error('Failed to delete contact!'); - } - - contacts.value = [...result.contacts]; - } catch (error) { - console.error(error); - } - - isDeleting.value = false; - } - } - - function onClickImportVCard() { - isOptionsDropdownOpen.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; - - try { - const partialContacts = parseVCardFromTextContents(importFileContents!.toString()); - - const requestBody: ImportRequestBody = { partialContacts, page }; - const response = await fetch(`/api/contacts/import`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as ImportResponseBody; - - if (!result.success) { - throw new Error('Failed to import contact!'); - } - - contacts.value = [...result.contacts]; - } catch (error) { - console.error(error); - } - - isImporting.value = false; - }; - - reader.readAsText(file, 'UTF-8'); - }; - } - - async function onClickExportVCard() { - isOptionsDropdownOpen.value = false; - - if (isExporting.value) { - return; - } - - isExporting.value = true; - - const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf'] - .join(''); - - try { - const requestBody: GetRequestBody = {}; - const response = await fetch(`/api/contacts/get`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as GetResponseBody; - - if (!result.success) { - throw new Error('Failed to get contact!'); - } - - const exportContents = formatContactToVCard([...result.contacts]); - - // Add content-type - const vCardContent = ['data:text/vcard; 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; - } - - const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT); - const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1); - - return ( - <> -
-
-
- -
-
-
-
-
- -
- - -
- -
-
- -
- - - - - - - - - - {contacts.value.map((contact) => ( - - - - - - ))} - {contacts.value.length === 0 - ? ( - - - - ) - : null} - -
First NameLast Name
- {contact.first_name} - - {contact.last_name} - - -
-
-
No contacts to show
-
-
- - - {isDeleting.value - ? ( - <> - Deleting... - - ) - : null} - {isExporting.value - ? ( - <> - Exporting... - - ) - : null} - {isImporting.value - ? ( - <> - Importing... - - ) - : null} - {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} - -
- - {pagesCount > 0 - ? ( -
- -
- ) - : null} - -
- CardDAV URLs:{' '} - {baseUrl}/dav/principals/{' '} - {baseUrl}/dav/addressbooks/ -
- - ); -} diff --git a/islands/contacts/ViewContact.tsx b/islands/contacts/ViewContact.tsx deleted file mode 100644 index 0bdd624..0000000 --- a/islands/contacts/ViewContact.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import { useSignal } from '@preact/signals'; - -import { Contact } from '/lib/types.ts'; -import { convertObjectToFormData } from '/lib/utils/misc.ts'; -import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; -import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; - -interface ViewContactProps { - initialContact: Contact; - formData: Record; - error?: string; - notice?: string; -} - -export function formFields(contact: Contact) { - const fields: FormField[] = [ - { - name: 'name_title', - label: 'Honorary title/prefix', - type: 'text', - placeholder: 'Dr.', - value: contact.extra.name_title, - required: false, - }, - { - name: 'first_name', - label: 'First name', - type: 'text', - placeholder: 'John', - value: contact.first_name, - required: true, - }, - { - name: 'middle_names', - label: 'Middle name(s)', - type: 'text', - placeholder: '', - value: contact.extra.middle_names?.map((name) => (name || '').trim()).filter(Boolean).join(' '), - required: false, - }, - { - name: 'last_name', - label: 'Last name', - type: 'text', - placeholder: 'Doe', - value: contact.last_name, - required: false, - }, - { - name: 'birthday', - label: 'Birthday', - type: 'text', - placeholder: 'YYYYMMDD', - value: contact.extra.birthday, - required: false, - }, - { - name: 'nickname', - label: 'Nickname', - type: 'text', - placeholder: 'Johnny', - value: contact.extra.nickname, - required: false, - }, - { - name: 'organization', - label: 'Company/Organization', - type: 'text', - placeholder: 'Acme Corporation', - value: contact.extra.organization, - required: false, - }, - { - name: 'role', - label: 'Job/Role', - type: 'text', - placeholder: '(Super) Genius', - value: contact.extra.role, - required: false, - }, - { - name: 'photo_url', - label: 'Photo URL', - type: 'url', - placeholder: 'https://example.com/image.jpg', - value: contact.extra.photo_url, - required: false, - }, - ]; - - // Phones - const phones = contact.extra.fields?.filter((field) => field.type === 'phone') || []; - for (const [index, phone] of phones.entries()) { - fields.push({ - name: 'phone_numbers', - label: `Phone number #${index + 1}`, - type: 'tel', - placeholder: '+44 0000 111 2222', - value: phone.value, - required: false, - }); - - fields.push({ - name: 'phone_labels', - label: `Phone label #${index + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: phone.name, - required: false, - }); - } - - fields.push({ - name: 'phone_numbers', - label: `Phone number #${phones.length + 1}`, - type: 'tel', - placeholder: '+44 0000 111 2222', - value: '', - required: false, - }, { - name: 'phone_labels', - label: `Phone label #${phones.length + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: '', - required: false, - }); - - // Emails - const emails = contact.extra.fields?.filter((field) => field.type === 'email') || []; - for (const [index, email] of emails.entries()) { - fields.push({ - name: 'email_addresses', - label: `Email #${index + 1}`, - type: 'email', - placeholder: 'user@example.com', - value: email.value, - required: false, - }); - - fields.push({ - name: 'email_labels', - label: `Email label #${index + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: email.name, - required: false, - }); - } - - fields.push({ - name: 'email_addresses', - label: `Email #${emails.length + 1}`, - type: 'email', - placeholder: 'user@example.com', - value: '', - required: false, - }, { - name: 'email_labels', - label: `Email label #${emails.length + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: '', - required: false, - }); - - // URLs - const urls = contact.extra.fields?.filter((field) => field.type === 'url') || []; - for (const [index, url] of urls.entries()) { - fields.push({ - name: 'url_addresses', - label: `URL #${index + 1}`, - type: 'url', - placeholder: 'https://example.com', - value: url.value, - required: false, - }); - - fields.push({ - name: 'url_labels', - label: `URL label #${index + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: url.name, - required: false, - }); - } - - fields.push({ - name: 'url_addresses', - label: `URL #${urls.length + 1}`, - type: 'url', - placeholder: 'https://example.com', - value: '', - required: false, - }, { - name: 'url_labels', - label: `URL label #${urls.length + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: '', - required: false, - }); - - // Others - const others = contact.extra.fields?.filter((field) => field.type === 'other') || []; - for (const [index, other] of others.entries()) { - fields.push({ - name: 'other_values', - label: `Other contact #${index + 1}`, - type: 'text', - placeholder: '@acme', - value: other.value, - required: false, - }); - - fields.push({ - name: 'other_labels', - label: `Other label #${index + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: other.name, - required: false, - }); - } - - fields.push({ - name: 'other_values', - label: `Other contact #${others.length + 1}`, - type: 'text', - placeholder: '@acme', - value: '', - required: false, - }, { - name: 'other_labels', - label: `Other label #${others.length + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: '', - required: false, - }); - - // Addresses - const addresses = contact.extra.addresses || []; - for (const [index, address] of addresses.entries()) { - fields.push({ - name: 'address_line_1s', - label: `Address line 1 #${index + 1}`, - type: 'text', - placeholder: '992 Tyburn Rd', - value: address.line_1, - required: false, - }); - - fields.push({ - name: 'address_line_2s', - label: `Address line 2 #${index + 1}`, - type: 'text', - placeholder: 'Apt 2', - value: address.line_2, - required: false, - }); - - fields.push({ - name: 'address_cities', - label: `Address city #${index + 1}`, - type: 'text', - placeholder: 'Birmingham', - value: address.city, - required: false, - }); - - fields.push({ - name: 'address_postal_codes', - label: `Address postal code #${index + 1}`, - type: 'text', - placeholder: 'B24 0TL', - value: address.postal_code, - required: false, - }); - - fields.push({ - name: 'address_states', - label: `Address state #${index + 1}`, - type: 'text', - placeholder: 'West Midlands', - value: address.state, - required: false, - }); - - fields.push({ - name: 'address_countries', - label: `Address country #${index + 1}`, - type: 'text', - placeholder: 'United Kingdom', - value: address.country, - required: false, - }); - - fields.push({ - name: 'address_labels', - label: `Address label #${index + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: address.label, - required: false, - }); - } - - fields.push({ - name: 'address_line_1s', - label: `Address line 1 #${addresses.length + 1}`, - type: 'text', - placeholder: '992 Tyburn Rd', - value: '', - required: false, - }, { - name: 'address_line_2s', - label: `Address line 2 #${addresses.length + 1}`, - type: 'text', - placeholder: 'Apt 2', - value: '', - required: false, - }, { - name: 'address_cities', - label: `Address city #${addresses.length + 1}`, - type: 'text', - placeholder: 'Birmingham', - value: '', - required: false, - }, { - name: 'address_postal_codes', - label: `Address postal code #${addresses.length + 1}`, - type: 'text', - placeholder: 'B24 0TL', - value: '', - required: false, - }, { - name: 'address_states', - label: `Address state #${addresses.length + 1}`, - type: 'text', - placeholder: 'West Midlands', - value: '', - required: false, - }, { - name: 'address_countries', - label: `Address country #${addresses.length + 1}`, - type: 'text', - placeholder: 'United Kingdom', - value: '', - required: false, - }, { - name: 'address_labels', - label: `Address label #${addresses.length + 1}`, - type: 'text', - placeholder: 'Home, Work, etc.', - value: '', - required: false, - }); - - fields.push({ - name: 'notes', - label: 'Notes', - type: 'textarea', - placeholder: 'Some notes...', - value: contact.extra.notes, - required: false, - }); - - return fields; -} - -export default function ViewContact({ initialContact, formData: formDataObject, error, notice }: ViewContactProps) { - const isDeleting = useSignal(false); - const contact = useSignal(initialContact); - - const formData = convertObjectToFormData(formDataObject); - - async function onClickDeleteContact() { - if (confirm('Are you sure you want to delete this contact?')) { - if (isDeleting.value) { - return; - } - - isDeleting.value = true; - - try { - const requestBody: DeleteRequestBody = { contactId: contact.value.id, page: 1 }; - const response = await fetch(`/api/contacts/delete`, { - method: 'POST', - body: JSON.stringify(requestBody), - }); - const result = await response.json() as DeleteResponseBody; - - if (!result.success) { - throw new Error('Failed to delete contact!'); - } - - window.location.href = '/contacts'; - } catch (error) { - console.error(error); - } - - isDeleting.value = false; - } - } - - return ( - <> -
- View contacts -
- -
-
- -
- {error - ? ( -
-

Failed to update!

-

{error}

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

Success!

-

{notice}

-
- ) - : null} - -
- {formFields(contact.peek()).map((field) => generateFieldHtml(field, formData))} - -
- -
-
- - - {isDeleting.value - ? ( - <> - Deleting... - - ) - : null} - {!isDeleting.value ? <>  : null} - -
- - ); -} diff --git a/islands/files/FilesWrapper.tsx b/islands/files/FilesWrapper.tsx new file mode 100644 index 0000000..1b4b800 --- /dev/null +++ b/islands/files/FilesWrapper.tsx @@ -0,0 +1,21 @@ +import { Directory, DirectoryFile } from '/lib/types.ts'; +import MainFiles from '/components/files/MainFiles.tsx'; + +interface FilesWrapperProps { + initialDirectories: Directory[]; + initialFiles: DirectoryFile[]; + initialPath: string; +} + +// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself +export default function FilesWrapper( + { initialDirectories, initialFiles, initialPath }: FilesWrapperProps, +) { + return ( + + ); +} diff --git a/lib/config.ts b/lib/config.ts index fba98ff..4ea45d7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -13,3 +13,11 @@ export async function isSignupAllowed() { return false; } + +export function getFilesRootPath() { + const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH'); + + const filesRootPath = `${Deno.cwd()}/${configRootPath}`; + + return filesRootPath; +} diff --git a/lib/data/calendar.ts b/lib/data/calendar.ts deleted file mode 100644 index a6605a4..0000000 --- a/lib/data/calendar.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { RRuleSet } from 'rrule-rust'; - -import Database, { sql } from '/lib/interfaces/database.ts'; -import Locker from '/lib/interfaces/locker.ts'; -import { Calendar, CalendarEvent, CalendarEventReminder } from '/lib/types.ts'; -import { getRandomItem } from '/lib/utils/misc.ts'; -import { CALENDAR_COLOR_OPTIONS, getVCalendarDate } from '/lib/utils/calendar.ts'; -import { getUserById } from './user.ts'; - -const db = new Database(); - -export async function getCalendars(userId: string): Promise { - const calendars = await db.query( - sql`SELECT * FROM "bewcloud_calendars" WHERE "user_id" = $1 ORDER BY "created_at" ASC`, - [ - userId, - ], - ); - - return calendars; -} - -export async function getCalendarEvents( - userId: string, - calendarIds: string[], - dateRange?: { start: Date; end: Date }, -): Promise { - if (!dateRange) { - const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`, - [ - userId, - calendarIds, - ], - ); - - return calendarEvents; - } else { - // Fetch initial recurring events and calculate any necessary to create/show for the date range, if it's not in the past - if (dateRange.end >= new Date()) { - const lock = new Locker(`events-${userId}`); - - await lock.acquire(); - - const initialRecurringCalendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" - WHERE "user_id" = $1 - AND "calendar_id" = ANY($2) - AND "start_date" <= $3 - AND ("extra" ->> 'is_recurring')::boolean IS TRUE - AND ("extra" ->> 'recurring_id')::uuid = "id" - ORDER BY "start_date" ASC`, - [ - userId, - calendarIds, - dateRange.end, - ], - ); - - // For each initial recurring event, check instance dates, check if those exist in calendarEvents. If not, create them. - for (const initialRecurringCalendarEvent of initialRecurringCalendarEvents) { - try { - const oneMonthAgo = new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 1)); - - let recurringInstanceStartDate = initialRecurringCalendarEvent.start_date; - let lastSequence = initialRecurringCalendarEvent.extra.recurring_sequence!; - - if (recurringInstanceStartDate <= oneMonthAgo) { - // Fetch the latest recurring sample, so we don't have to calculate as many recurring dates, but still preserve the original date's properties for generating the recurring instances - const latestRecurringInstance = (await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" - WHERE "user_id" = $1 - AND "calendar_id" = ANY($2) - AND "start_date" <= $3 - AND ("extra" ->> 'is_recurring')::boolean IS TRUE - AND ("extra" ->> 'recurring_id')::uuid = $4 - ORDER BY ("extra" ->> 'recurring_sequence')::number DESC - LIMIT 1`, - [ - userId, - calendarIds, - dateRange.end, - initialRecurringCalendarEvent.extra.recurring_id!, - ], - ))[0]; - - if (latestRecurringInstance) { - recurringInstanceStartDate = latestRecurringInstance.start_date; - lastSequence = latestRecurringInstance.extra.recurring_sequence!; - } - } - - const rRuleSet = RRuleSet.parse( - `DTSTART:${ - getVCalendarDate(recurringInstanceStartDate) - }\n${initialRecurringCalendarEvent.extra.recurring_rrule}`, - ); - - const maxRecurringDatesToGenerate = 30; - - const timestamps = rRuleSet.all(maxRecurringDatesToGenerate); - - const validDates = timestamps.map((timestamp) => new Date(timestamp)).filter((date) => date <= dateRange.end); - - // For each date, check if an instance already exists. If not, create it and add it. - for (const instanceDate of validDates) { - instanceDate.setHours(recurringInstanceStartDate.getHours()); // NOTE: Something is making the hour shift when it shouldn't - - const matchingRecurringInstance = (await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" - WHERE "user_id" = $1 - AND "calendar_id" = ANY($2) - AND "start_date" = $3 - AND ("extra" ->> 'is_recurring')::boolean IS TRUE - AND ("extra" ->> 'recurring_id')::uuid = $4 - ORDER BY "start_date" ASC - LIMIT 1`, - [ - userId, - calendarIds, - instanceDate, - initialRecurringCalendarEvent.extra.recurring_id!, - ], - ))[0]; - - if (!matchingRecurringInstance) { - const oneHourLater = new Date(new Date(instanceDate).setHours(instanceDate.getHours() + 1)); - const newCalendarEvent = await createCalendarEvent( - userId, - initialRecurringCalendarEvent.calendar_id, - initialRecurringCalendarEvent.title, - instanceDate, - oneHourLater, - initialRecurringCalendarEvent.is_all_day, - ); - - newCalendarEvent.extra = { ...newCalendarEvent.extra, ...initialRecurringCalendarEvent.extra }; - - newCalendarEvent.extra.recurring_sequence = ++lastSequence; - - await updateCalendarEvent(newCalendarEvent); - } - } - } catch (error) { - console.error(`Error generating recurring instances: ${error}`); - console.error(error); - } - } - - lock.release(); - } - - const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" - WHERE "user_id" = $1 - AND "calendar_id" = ANY($2) - AND ( - ("start_date" >= $3 OR "end_date" <= $4) - OR ("start_date" < $3 AND "end_date" > $4) - ) - ORDER BY "start_date" ASC`, - [ - userId, - calendarIds, - dateRange.start, - dateRange.end, - ], - ); - - return calendarEvents; - } -} - -export async function getCalendarEvent(id: string, userId: string): Promise { - const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, - [ - id, - userId, - ], - ); - - return calendarEvents[0]; -} - -export async function getCalendar(id: string, userId: string) { - const calendars = await db.query( - sql`SELECT * FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, - [ - id, - userId, - ], - ); - - return calendars[0]; -} - -export async function createCalendar(userId: string, name: string, color?: string) { - const extra: Calendar['extra'] = { - default_transparency: 'opaque', - }; - - const revision = crypto.randomUUID(); - - const newColor = color || getRandomItem(CALENDAR_COLOR_OPTIONS); - - const newCalendar = (await db.query( - sql`INSERT INTO "bewcloud_calendars" ( - "user_id", - "revision", - "name", - "color", - "is_visible", - "extra" - ) VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [ - userId, - revision, - name, - newColor, - true, - JSON.stringify(extra), - ], - ))[0]; - - return newCalendar; -} - -export async function updateCalendar(calendar: Calendar) { - const revision = crypto.randomUUID(); - - await db.query( - sql`UPDATE "bewcloud_calendars" SET - "revision" = $3, - "name" = $4, - "color" = $5, - "is_visible" = $6, - "extra" = $7, - "updated_at" = now() - WHERE "id" = $1 AND "revision" = $2`, - [ - calendar.id, - calendar.revision, - revision, - calendar.name, - calendar.color, - calendar.is_visible, - JSON.stringify(calendar.extra), - ], - ); -} - -export async function deleteCalendar(id: string, userId: string) { - await db.query( - sql`DELETE FROM "bewcloud_calendar_events" WHERE "calendar_id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); -} - -async function updateCalendarRevision(calendar: Calendar) { - const revision = crypto.randomUUID(); - - await db.query( - sql`UPDATE "bewcloud_calendars" SET - "revision" = $3, - "updated_at" = now() - WHERE "id" = $1 AND "revision" = $2`, - [ - calendar.id, - calendar.revision, - revision, - ], - ); -} - -export async function createCalendarEvent( - userId: string, - calendarId: string, - title: string, - startDate: Date, - endDate: Date, - isAllDay = false, -) { - const user = await getUserById(userId); - - if (!user) { - throw new Error('User not found'); - } - - const calendar = await getCalendar(calendarId, userId); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - const oneHourEarlier = new Date(new Date(startDate).setHours(new Date(startDate).getHours() - 1)); - const sameDayAtNine = new Date(new Date(startDate).setHours(9)); - - const newReminder: CalendarEventReminder = { - start_date: isAllDay ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(), - type: 'display', - }; - - const extra: CalendarEvent['extra'] = { - organizer_email: user.email, - transparency: 'default', - reminders: [newReminder], - }; - - const revision = crypto.randomUUID(); - - const status: CalendarEvent['status'] = 'scheduled'; - - const newCalendarEvent = (await db.query( - sql`INSERT INTO "bewcloud_calendar_events" ( - "user_id", - "calendar_id", - "revision", - "title", - "start_date", - "end_date", - "is_all_day", - "status", - "extra" - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *`, - [ - userId, - calendarId, - revision, - title, - startDate, - endDate, - isAllDay, - status, - JSON.stringify(extra), - ], - ))[0]; - - await updateCalendarRevision(calendar); - - return newCalendarEvent; -} - -export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalendarId?: string) { - const revision = crypto.randomUUID(); - - const user = await getUserById(calendarEvent.user_id); - - if (!user) { - throw new Error('User not found'); - } - - const calendar = await getCalendar(calendarEvent.calendar_id, user.id); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - const oldCalendar = oldCalendarId ? await getCalendar(oldCalendarId, user.id) : null; - - const oldCalendarEvent = await getCalendarEvent(calendarEvent.id, user.id); - - if (oldCalendarEvent.start_date !== calendarEvent.start_date) { - const oneHourEarlier = new Date( - new Date(calendarEvent.start_date).setHours(new Date(calendarEvent.start_date).getHours() - 1), - ); - const sameDayAtNine = new Date(new Date(calendarEvent.start_date).setHours(9)); - - const newReminder: CalendarEventReminder = { - start_date: calendarEvent.is_all_day ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(), - type: 'display', - }; - - if (!Array.isArray(calendarEvent.extra.reminders)) { - calendarEvent.extra.reminders = [newReminder]; - } else { - if (calendarEvent.extra.reminders.length === 0) { - calendarEvent.extra.reminders.push(newReminder); - } else { - calendarEvent.extra.reminders[0] = { ...calendarEvent.extra.reminders[0], start_date: newReminder.start_date }; - } - } - } - - await db.query( - sql`UPDATE "bewcloud_calendar_events" SET - "revision" = $3, - "calendar_id" = $4, - "title" = $5, - "start_date" = $6, - "end_date" = $7, - "is_all_day" = $8, - "status" = $9, - "extra" = $10, - "updated_at" = now() - WHERE "id" = $1 AND "revision" = $2`, - [ - calendarEvent.id, - calendarEvent.revision, - revision, - calendarEvent.calendar_id, - calendarEvent.title, - calendarEvent.start_date, - calendarEvent.end_date, - calendarEvent.is_all_day, - calendarEvent.status, - JSON.stringify(calendarEvent.extra), - ], - ); - - await updateCalendarRevision(calendar); - - if (oldCalendar) { - await updateCalendarRevision(oldCalendar); - } -} - -export async function deleteCalendarEvent(id: string, calendarId: string, userId: string) { - const calendar = await getCalendar(calendarId, userId); - - if (!calendar) { - throw new Error('Calendar not found'); - } - - await db.query( - sql`DELETE FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3`, - [ - id, - calendarId, - userId, - ], - ); - - await updateCalendarRevision(calendar); -} - -export async function searchCalendarEvents( - searchTerm: string, - userId: string, - calendarIds: string[], -): Promise { - const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) AND ("title" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "start_date" ASC`, - [ - userId, - calendarIds, - `%${searchTerm.split(' ').join('%')}%`, - ], - ); - - return calendarEvents; -} diff --git a/lib/data/contacts.ts b/lib/data/contacts.ts deleted file mode 100644 index 5fe9024..0000000 --- a/lib/data/contacts.ts +++ /dev/null @@ -1,138 +0,0 @@ -import Database, { sql } from '/lib/interfaces/database.ts'; -import { Contact } from '/lib/types.ts'; -import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils/contacts.ts'; -import { updateUserContactRevision } from './user.ts'; - -const db = new Database(); - -export async function getContacts(userId: string, pageIndex: number) { - const contacts = await db.query>( - sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`, - [ - userId, - pageIndex * CONTACTS_PER_PAGE_COUNT, - ], - ); - - return contacts; -} - -export async function getContactsCount(userId: string) { - const results = await db.query<{ count: number }>( - sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - return Number(results[0]?.count || 0); -} - -export async function searchContacts(searchTerm: string, userId: string, pageIndex: number) { - const contacts = await db.query>( - sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`, - [ - userId, - pageIndex * CONTACTS_PER_PAGE_COUNT, - `%${searchTerm.split(' ').join('%')}%`, - ], - ); - - return contacts; -} - -export async function searchContactsCount(search: string, userId: string) { - const results = await db.query<{ count: number }>( - sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`, - [ - userId, - `%${search}%`, - ], - ); - - return Number(results[0]?.count || 0); -} - -export async function getAllContacts(userId: string) { - const contacts = await db.query(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [ - userId, - ]); - - return contacts; -} - -export async function getContact(id: string, userId: string) { - const contacts = await db.query( - sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, - [ - id, - userId, - ], - ); - - return contacts[0]; -} - -export async function createContact(userId: string, firstName: string, lastName: string) { - const extra: Contact['extra'] = {}; - - const revision = crypto.randomUUID(); - - const newContact = (await db.query( - sql`INSERT INTO "bewcloud_contacts" ( - "user_id", - "revision", - "first_name", - "last_name", - "extra" - ) VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [ - userId, - revision, - firstName, - lastName, - JSON.stringify(extra), - ], - ))[0]; - - await updateUserContactRevision(userId); - - return newContact; -} - -export async function updateContact(contact: Contact) { - const revision = crypto.randomUUID(); - - await db.query( - sql`UPDATE "bewcloud_contacts" SET - "revision" = $3, - "first_name" = $4, - "last_name" = $5, - "extra" = $6, - "updated_at" = now() - WHERE "id" = $1 AND "revision" = $2`, - [ - contact.id, - contact.revision, - revision, - contact.first_name, - contact.last_name, - JSON.stringify(contact.extra), - ], - ); - - await updateUserContactRevision(contact.user_id); -} - -export async function deleteContact(id: string, userId: string) { - await db.query( - sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); - - await updateUserContactRevision(userId); -} diff --git a/lib/data/files.ts b/lib/data/files.ts new file mode 100644 index 0000000..f173d6c --- /dev/null +++ b/lib/data/files.ts @@ -0,0 +1,261 @@ +import { join } from 'std/path/join.ts'; + +// import Database, { sql } from '/lib/interfaces/database.ts'; +import { getFilesRootPath } from '/lib/config.ts'; +import { Directory, DirectoryFile, FileShare } from '/lib/types.ts'; +import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; + +// const db = new Database(); + +export async function getDirectories(userId: string, path: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + // const directoryShares = await db.query(sql`SELECT * FROM "bewcloud_file_shares" + // WHERE "parent_path" = $2 + // AND "type" = 'directory' + // AND ( + // "owner_user_id" = $1 + // OR ANY("user_ids_with_read_access") = $1 + // OR ANY("user_ids_with_write_access") = $1 + // )`, [ + // userId, + // path, + // ]); + + const directoryShares: FileShare[] = []; + + // TODO: Remove this mock test + if (path === '/') { + directoryShares.push({ + id: 'test-ing-123', + owner_user_id: userId, + parent_path: '/', + name: 'Testing', + type: 'directory', + user_ids_with_read_access: [], + user_ids_with_write_access: [], + extra: { + read_share_links: [], + write_share_links: [], + }, + updated_at: new Date('2024-04-01'), + created_at: new Date('2024-03-31'), + }); + } + + const directories: Directory[] = []; + + const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory); + + for (const entry of directoryEntries) { + const stat = await Deno.stat(join(rootPath, entry.name)); + + const directory: Directory = { + owner_user_id: userId, + parent_path: path, + directory_name: entry.name, + has_write_access: true, + file_share: directoryShares.find((share) => + share.owner_user_id === userId && share.parent_path === path && share.name === entry.name + ), + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + directories.push(directory); + } + + // TODO: Add directoryShares that aren't owned by this user + + directories.sort(sortDirectoriesByName); + + return directories; +} + +export async function getFiles(userId: string, path: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + // const fileShares = await db.query(sql`SELECT * FROM "bewcloud_file_shares" + // WHERE "parent_path" = $2 + // AND "type" = 'file' + // AND ( + // "owner_user_id" = $1 + // OR ANY("user_ids_with_read_access") = $1 + // OR ANY("user_ids_with_write_access") = $1 + // )`, [ + // userId, + // path, + // ]); + + const fileShares: FileShare[] = []; + + const files: DirectoryFile[] = []; + + const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile); + + for (const entry of fileEntries) { + const stat = await Deno.stat(join(rootPath, entry.name)); + + const file: DirectoryFile = { + owner_user_id: userId, + parent_path: path, + file_name: entry.name, + has_write_access: true, + file_share: fileShares.find((share) => + share.owner_user_id === userId && share.parent_path === path && share.name === entry.name + ), + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + files.push(file); + } + + // TODO: Add fileShares that aren't owned by this user + + files.sort(sortFilesByName); + + return files; +} + +async function getPathEntries(userId: string, path: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + // Ensure the user directory exists + if (path === '/') { + try { + await Deno.stat(rootPath); + } catch (error) { + if (error.toString().includes('NotFound')) { + await Deno.mkdir(rootPath, { recursive: true }); + } + } + } + + const entries: Deno.DirEntry[] = []; + + for await (const dirEntry of Deno.readDir(rootPath)) { + entries.push(dirEntry); + } + + entries.sort(sortEntriesByName); + + return entries; +} + +export async function createDirectory(userId: string, path: string, name: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + try { + await Deno.mkdir(join(rootPath, name), { recursive: true }); + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +export async function renameDirectoryOrFile( + userId: string, + oldPath: string, + newPath: string, + oldName: string, + newName: string, +): Promise { + const oldRootPath = join(getFilesRootPath(), userId, oldPath); + const newRootPath = join(getFilesRootPath(), userId, newPath); + + try { + await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); + + // TODO: Update any matching file shares + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + try { + if (path.startsWith(TRASH_PATH)) { + await Deno.remove(join(rootPath, name), { recursive: true }); + } else { + const trashPath = join(getFilesRootPath(), userId, TRASH_PATH); + await Deno.rename(join(rootPath, name), join(trashPath, name)); + + // TODO: Delete any matching file shares + } + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +export async function createFile( + userId: string, + path: string, + name: string, + contents: string | ArrayBuffer, +): Promise { + const rootPath = `${getFilesRootPath()}/${userId}${path}`; + + try { + if (typeof contents === 'string') { + await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true }); + } else { + await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true }); + } + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +export async function getFile( + userId: string, + path: string, + name: string, +): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string }> { + const rootPath = `${getFilesRootPath()}/${userId}${path}`; + + try { + const contents = await Deno.readFile(join(rootPath, name)); + + let contentType = 'application/octet-stream'; + + // NOTE: Detecting based on extension is not accurate, but installing a dependency like `npm:file-types` just for this seems unnecessary + const extension = name.split('.').slice(-1).join('').toLowerCase(); + + if (extension === 'jpg' || extension === 'jpeg') { + contentType = 'image/jpeg'; + } else if (extension === 'png') { + contentType = 'image/png'; + } else if (extension === 'pdf') { + contentType = 'application/pdf'; + } else if (extension === 'txt' || extension === 'md') { + contentType = 'text/plain'; + } + + return { + success: true, + contents, + contentType, + }; + } catch (error) { + console.error(error); + return { + success: false, + }; + } +} diff --git a/lib/data/news.ts b/lib/data/news.ts index 90a0303..3844f48 100644 --- a/lib/data/news.ts +++ b/lib/data/news.ts @@ -1,6 +1,7 @@ import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; import Database, { sql } from '/lib/interfaces/database.ts'; +import Locker from '/lib/interfaces/locker.ts'; import { NewsFeed, NewsFeedArticle } from '/lib/types.ts'; import { findFeedInUrl, @@ -211,90 +212,98 @@ type JsonFeedArticle = JsonFeed['items'][number]; const MAX_ARTICLES_CRAWLED_PER_RUN = 10; export async function crawlNewsFeed(newsFeed: NewsFeed) { - // TODO: Lock this per feedId, so no two processes run this at the same time + const lock = new Locker(`feeds:${newsFeed.id}`); - if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { - const feedUrl = await findFeedInUrl(newsFeed.feed_url); + await lock.acquire(); - if (!feedUrl) { - throw new Error( - `Invalid URL for feed: "${feedUrl}"`, + try { + if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { + const feedUrl = await findFeedInUrl(newsFeed.feed_url); + + if (!feedUrl) { + throw new Error( + `Invalid URL for feed: "${feedUrl}"`, + ); + } + + if (feedUrl !== newsFeed.feed_url) { + newsFeed.feed_url = feedUrl; + } + + const feedInfo = await getFeedInfo(newsFeed.feed_url); + + newsFeed.extra.title = feedInfo.title; + newsFeed.extra.feed_type = feedInfo.feed_type; + newsFeed.extra.crawl_type = feedInfo.crawl_type; + } + + const feedArticles = await fetchNewsArticles(newsFeed); + + const articles: Omit[] = []; + + for (const feedArticle of feedArticles) { + // Don't add too many articles per run + if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) { + continue; + } + + const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) || + feedArticle.id; + + const articleIsoDate = (feedArticle as JsonFeedArticle).date_published || + (feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified || + (feedArticle as FeedArticle).updated?.toISOString(); + + const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date(); + + const summary = await parseTextFromHtml( + (feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value || + (feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html || + (feedArticle as JsonFeedArticle).summary || '', ); - } - if (feedUrl !== newsFeed.feed_url) { - newsFeed.feed_url = feedUrl; - } - - const feedInfo = await getFeedInfo(newsFeed.feed_url); - - newsFeed.extra.title = feedInfo.title; - newsFeed.extra.feed_type = feedInfo.feed_type; - newsFeed.extra.crawl_type = feedInfo.crawl_type; - } - - const feedArticles = await fetchNewsArticles(newsFeed); - - const articles: Omit[] = []; - - for (const feedArticle of feedArticles) { - // Don't add too many articles per run - if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) { - continue; - } - - const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) || - feedArticle.id; - - const articleIsoDate = (feedArticle as JsonFeedArticle).date_published || - (feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified || - (feedArticle as FeedArticle).updated?.toISOString(); - - const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date(); - - const summary = await parseTextFromHtml( - (feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value || - (feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html || - (feedArticle as JsonFeedArticle).summary || '', - ); - - if (url) { - articles.push({ - article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title || - url.replace('http://', '').replace('https://', ''), - article_url: url, - article_summary: summary, - article_date: articleDate, - }); - } - } - - const existingArticles = await getNewsArticlesByFeedId(newsFeed.id); - const existingArticleUrls = new Set(existingArticles.map((article) => article.article_url)); - const previousLatestArticleUrl = existingArticles[0]?.article_url; - let seenPreviousLatestArticleUrl = false; - let addedArticlesCount = 0; - - for (const article of articles) { - // Stop looking after seeing the previous latest article - if (article.article_url === previousLatestArticleUrl) { - seenPreviousLatestArticleUrl = true; - } - - if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) { - try { - await createsNewsArticle(newsFeed.user_id, newsFeed.id, article); - ++addedArticlesCount; - } catch (error) { - console.error(error); - console.error(`Failed to add new article: "${article.article_url}"`); + if (url) { + articles.push({ + article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title || + url.replace('http://', '').replace('https://', ''), + article_url: url, + article_summary: summary, + article_date: articleDate, + }); } } + + const existingArticles = await getNewsArticlesByFeedId(newsFeed.id); + const existingArticleUrls = new Set(existingArticles.map((article) => article.article_url)); + const previousLatestArticleUrl = existingArticles[0]?.article_url; + let seenPreviousLatestArticleUrl = false; + let addedArticlesCount = 0; + + for (const article of articles) { + // Stop looking after seeing the previous latest article + if (article.article_url === previousLatestArticleUrl) { + seenPreviousLatestArticleUrl = true; + } + + if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) { + try { + await createsNewsArticle(newsFeed.user_id, newsFeed.id, article); + ++addedArticlesCount; + } catch (error) { + console.error(error); + console.error(`Failed to add new article: "${article.article_url}"`); + } + } + } + + console.log('Added', addedArticlesCount, 'new articles'); + + newsFeed.last_crawled_at = new Date(); + + await updateNewsFeed(newsFeed); + } catch (error) { + lock.release(); + + throw error; } - - console.log('Added', addedArticlesCount, 'new articles'); - - newsFeed.last_crawled_at = new Date(); - - await updateNewsFeed(newsFeed); } diff --git a/lib/data/user.ts b/lib/data/user.ts index b2bceb3..99925f7 100644 --- a/lib/data/user.ts +++ b/lib/data/user.ts @@ -283,14 +283,3 @@ export async function validateVerificationCode( throw new Error('Not Found'); } } - -export async function updateUserContactRevision(id: string) { - const user = await getUserById(id); - - const revision = crypto.randomUUID(); - - user.extra.contacts_revision = revision; - user.extra.contacts_updated_at = new Date().toISOString(); - - await updateUser(user); -} diff --git a/lib/types.ts b/lib/types.ts index 9e91c18..96a2d81 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -12,8 +12,6 @@ export interface User { is_email_verified: boolean; is_admin?: boolean; dav_hashed_password?: string; - contacts_revision?: string; - contacts_updated_at?: string; }; created_at: Date; } @@ -87,109 +85,45 @@ export interface NewsFeedArticle { created_at: Date; } -// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity -export interface Contact { +export interface DirectoryOrFileShareLink { + url: string; + hashed_password: string; +} + +export interface FileShare { id: string; - user_id: string; - revision: string; - first_name: string; - last_name: string; - extra: { - name_title?: string; - middle_names?: string[]; - organization?: string; - role?: string; - photo_url?: string; - photo_mediatype?: string; - addresses?: ContactAddress[]; - fields?: ContactField[]; - notes?: string; - uid?: string; - nickname?: string; - birthday?: string; - }; - updated_at: Date; - created_at: Date; -} - -export interface ContactAddress { - label?: string; - line_1?: string; - line_2?: string; - city?: string; - state?: string; - postal_code?: string; - country?: string; -} - -export type ContactFieldType = 'email' | 'phone' | 'url' | 'other'; - -export interface ContactField { + owner_user_id: string; + parent_path: string; name: string; - value: string; - type: ContactFieldType; -} - -export interface Calendar { - id: string; - user_id: string; - revision: string; - name: string; - color: string; - is_visible: boolean; + type: 'directory' | 'file'; + user_ids_with_read_access: string[]; + user_ids_with_write_access: string[]; extra: { - shared_read_user_ids?: string[]; - shared_write_user_ids?: string[]; - default_transparency: 'opaque' | 'transparent'; - calendar_timezone?: string; + read_share_links: DirectoryOrFileShareLink[]; + write_share_links: DirectoryOrFileShareLink[]; }; updated_at: Date; created_at: Date; } -export interface CalendarEvent { - id: string; - user_id: string; - calendar_id: string; - revision: string; - title: string; - start_date: Date; - end_date: Date; - is_all_day: boolean; - status: 'scheduled' | 'pending' | 'canceled'; - extra: { - organizer_email: string; - description?: string; - location?: string; - url?: string; - attendees?: CalendarEventAttendee[]; - transparency: 'default' | Calendar['extra']['default_transparency']; - is_recurring?: boolean; - recurring_id?: string; - recurring_sequence?: number; - recurring_rrule?: string; - recurring_rdate?: string; - recurring_exdate?: string; - is_task?: boolean; - task_due_date?: string; - task_completed_at?: string; - uid?: string; - reminders?: CalendarEventReminder[]; - }; +export interface Directory { + owner_user_id: string; + parent_path: string; + directory_name: string; + has_write_access: boolean; + file_share?: FileShare; + size_in_bytes: number; updated_at: Date; created_at: Date; } -export interface CalendarEventAttendee { - email: string; - status: 'accepted' | 'rejected' | 'invited'; - name?: string; -} - -export interface CalendarEventReminder { - uid?: string; - start_date: string; - type: 'email' | 'sound' | 'display'; - acknowledged_at?: string; - description?: string; +export interface DirectoryFile { + owner_user_id: string; + parent_path: string; + file_name: string; + has_write_access: boolean; + file_share?: FileShare; + size_in_bytes: number; + updated_at: Date; + created_at: Date; } diff --git a/lib/utils/calendar.ts b/lib/utils/calendar.ts deleted file mode 100644 index c4888b8..0000000 --- a/lib/utils/calendar.ts +++ /dev/null @@ -1,716 +0,0 @@ -import { Calendar, CalendarEvent, CalendarEventAttendee, CalendarEventReminder } from '/lib/types.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; - -// NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file -export const CALENDAR_BORDER_COLOR_OPTIONS = [ - 'border-red-700', - 'border-red-950', - 'border-orange-700', - 'border-orange-950', - 'border-amber-700', - 'border-yellow-800', - 'border-lime-700', - 'border-lime-950', - 'border-green-700', - 'border-emerald-800', - 'border-teal-700', - 'border-cyan-700', - 'border-sky-800', - 'border-blue-900', - 'border-indigo-700', - 'border-violet-700', - 'border-purple-800', - 'border-fuchsia-700', - 'border-pink-800', - 'border-rose-700', -] as const; - -export function getColorAsHex(calendarColor: string) { - const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor); - - return CALENDAR_COLOR_OPTIONS_HEX[colorIndex]; -} - -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 formatCalendarEventsToVCalendar( - calendarEvents: CalendarEvent[], - calendars: Pick[], -): string { - const vCalendarText = calendarEvents.map((calendarEvent) => - `BEGIN:VEVENT -DTSTAMP:${getVCalendarDate(calendarEvent.created_at)} -DTSTART:${getVCalendarDate(calendarEvent.start_date)} -DTEND:${getVCalendarDate(calendarEvent.end_date)} -ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email} -SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)} -TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()} -${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''} -${ - calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule - ? `RRULE:${calendarEvent.extra.recurring_rrule}` - : '' - } -SEQUENCE:${calendarEvent.extra.recurring_sequence || 0} -CREATED:${getVCalendarDate(calendarEvent.created_at)} -LAST-MODIFIED:${getVCalendarDate(calendarEvent.updated_at)} -${ - calendarEvent.extra.attendees?.map((attendee) => - `ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${ - getSafelyEscapedTextForVCalendar(attendee.name || '') - }:mailto:${attendee.email}` - ).join('\n') || '' - } -${ - calendarEvent.extra.reminders?.map((reminder) => - `BEGIN:VALARM -ACTION:${reminder.type.toUpperCase()} -${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''} -TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)} -${reminder.uid ? `UID:${reminder.uid}` : ''} -${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''} -END:VALARM` - ).join('\n') || '' - } -END:VEVENT` - ).join('\n'); - - return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) => - line.trim() - ).filter( - Boolean, - ).join('\n'); -} - -type VCalendarVersion = '1.0' | '2.0'; - -export function parseVCalendarFromTextContents(text: string): Partial[] { - // 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'; - - // 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.extra = { - ...(partialCalendarEvent.extra! || {}), - reminders: [...(partialCalendarEvent.extra?.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.extra = { - ...(partialCalendarEvent.extra! || {}), - uid, - }; - - 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.extra = { - ...(partialCalendarEvent.extra! || {}), - description, - }; - - continue; - } - - if (line.startsWith('SUMMARY:')) { - const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim()); - - partialCalendarEvent.title = title; - - continue; - } - - if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) { - const startDateInfo = line.split(':')[1] || ''; - const [dateInfo, hourInfo] = startDateInfo.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); - - const startDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`); - - partialCalendarEvent.start_date = startDate; - - continue; - } - - if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) { - const endDateInfo = line.split(':')[1] || ''; - const [dateInfo, hourInfo] = endDateInfo.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); - - const endDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`); - - partialCalendarEvent.end_date = endDate; - - continue; - } - - if (line.startsWith('ORGANIZER;')) { - const organizerInfo = line.split(':'); - const organizerEmail = organizerInfo.slice(-1)[0] || ''; - - if (!organizerEmail) { - continue; - } - - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - organizer_email: organizerEmail, - }; - } - - if (line.startsWith('TRANSP:')) { - const transparency = (line.split('TRANSP:')[1] || 'default') - .toLowerCase() as CalendarEvent['extra']['transparency']; - - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - 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.extra = { - ...(partialCalendarEvent.extra! || {}), - attendees: [...(partialCalendarEvent.extra?.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.start_date || new Date()); - - if (line.includes('DATE-TIME')) { - const [dateInfo, hourInfo] = triggerInfo.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); - - triggerDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`); - } 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.setHours(triggerDate.getHours() - triggerHours); - } else { - triggerDate.setHours(triggerHours); - } - } - - if (triggerMinutesMatch && triggerMinutesMatch.length > 0) { - const triggerMinutes = parseInt(triggerMinutesMatch[0], 10); - - if (isNegative) { - triggerDate.setMinutes(triggerDate.getMinutes() - triggerMinutes); - } else { - triggerDate.setMinutes(triggerMinutes); - } - } - - if (triggerSecondsMatch && triggerSecondsMatch.length > 0) { - const triggerSeconds = parseInt(triggerSecondsMatch[0], 10); - - if (isNegative) { - triggerDate.setSeconds(triggerDate.getSeconds() - triggerSeconds); - } else { - triggerDate.setSeconds(triggerSeconds); - } - } - } - - partialCalendarReminder.start_date = triggerDate.toISOString(); - - continue; - } - - if (line.startsWith('RRULE:')) { - const rRule = line.replace('RRULE:', '').trim(); - - if (!rRule) { - continue; - } - - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - is_recurring: true, - recurring_rrule: rRule, - recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0, - }; - - continue; - } - - if (line.startsWith('SEQUENCE:')) { - const sequence = line.replace('SEQUENCE:', '').trim(); - - if (!sequence || sequence === '0') { - continue; - } - - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - recurring_sequence: parseInt(sequence, 10), - }; - - continue; - } - } - - return partialCalendarEvents; -} - -// NOTE: Considers weeks starting Monday, not Sunday -export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] { - const year = date.getFullYear(); - const month = date.getMonth(); - - const firstOfMonth = new Date(year, month, 1); - const lastOfMonth = new Date(year, month + 1, 0); - - const daysToShow = firstOfMonth.getDay() + (firstOfMonth.getDay() === 0 ? 6 : -1) + lastOfMonth.getDate(); - - const weekCount = Math.ceil(daysToShow / 7); - - const weeks: { date: Date; isSameMonth: boolean }[][] = []; - - const startingDate = new Date(firstOfMonth); - startingDate.setDate( - startingDate.getDate() - Math.abs(firstOfMonth.getDay() === 0 ? 6 : (firstOfMonth.getDay() - 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.setDate(weekDayDate.getDate() + (dayIndex + weekIndex * 7)); - - const isSameMonth = weekDayDate.getMonth() === 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().getHours(); - - const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = []; - - const startingDate = new Date(date); - startingDate.setDate( - startingDate.getDate() - Math.abs(startingDate.getDay() === 0 ? 6 : (startingDate.getDay() - 1)), - ); - - for (let dayIndex = 0; days.length < 7; ++dayIndex) { - const dayDate = new Date(startingDate); - dayDate.setDate(dayDate.getDate() + 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.setHours(hourIndex); - - const isCurrentHour = isSameDay && hourIndex === currentHour; - - days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour }); - } - } - - return days; -} - -function getCalendarEventTransparency( - calendarEvent: CalendarEvent, - calendars: Pick[], -) { - const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id); - - const transparency = calendarEvent.extra.transparency === 'default' - ? (matchingCalendar?.extra.default_transparency || 'opaque') - : calendarEvent.extra.transparency; - - return transparency; -} - -export function getCalendarEventColor( - calendarEvent: CalendarEvent, - calendars: Pick[], -) { - const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id); - const opaqueColor = matchingCalendar?.color || 'bg-gray-700'; - const transparentColor = opaqueColor.replace('bg-', 'border border-'); - - const transparency = getCalendarEventTransparency(calendarEvent, calendars); - - return transparency === 'opaque' ? opaqueColor : transparentColor; -} - -type RRuleFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY'; -type RRuleWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'; -type RRuleType = 'FREQ' | 'BYDAY' | 'BYMONTHDAY' | 'BYHOUR' | 'BYMINUTE' | 'COUNT' | 'INTERVAL'; - -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): 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 words: string[] = []; - - if (frequency === 'DAILY') { - if (byHour) { - if (byMinute) { - words.push(`Every day at ${byHour}:${byMinute}`); - } else { - words.push(`Every day at ${byHour}:00`); - } - } else { - words.push(`Every day`); - } - - if (count) { - if (count === '1') { - words.push(`for 1 time`); - } else { - words.push(`for ${count} times`); - } - } - - return words.join(' '); - } - - if (frequency === 'WEEKLY') { - if (byDay) { - if (interval && parseInt(interval, 10) > 1) { - words.push( - `Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on ${convertRRuleDaysToWords(byDay)}`, - ); - } else { - words.push(`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`); - } - } - - return words.join(' '); - } - - // monthly - if (frequency === 'MONTHLY' && byMonthDay) { - if (interval && parseInt(interval, 10) > 1) { - words.push( - `Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on the ${ - getOrdinalSuffix(parseInt(byMonthDay, 10)) - }`, - ); - } else { - words.push( - `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`); - } - } - return words.join(' '); - } - - return words.join(' '); -} diff --git a/lib/utils/contacts.ts b/lib/utils/contacts.ts deleted file mode 100644 index 0a1efdf..0000000 --- a/lib/utils/contacts.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { Contact, ContactAddress, ContactField } from '../types.ts'; - -export const CONTACTS_PER_PAGE_COUNT = 20; - -function getSafelyEscapedTextForVCard(text: string) { - return text.replaceAll('\n', '\\n').replaceAll(',', '\\,'); -} - -function getSafelyUnescapedTextFromVCard(text: string) { - return text.replaceAll('\\n', '\n').replaceAll('\\,', ','); -} - -export function formatContactToVCard(contacts: Contact[]): string { - const vCardText = contacts.map((contact) => - `BEGIN:VCARD -VERSION:4.0 -N:${contact.last_name};${contact.first_name};${ - contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : '' - };${contact.extra.name_title || ''}; -FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name} -${contact.extra.organization ? `ORG:${getSafelyEscapedTextForVCard(contact.extra.organization)}` : ''} -${contact.extra.role ? `TITLE:${getSafelyEscapedTextForVCard(contact.extra.role)}` : ''} -${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''} -${contact.extra.nickname ? `NICKNAME:${getSafelyEscapedTextForVCard(contact.extra.nickname)}` : ''} -${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''} -${ - contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) => - `TEL;TYPE=${phone.name}:${phone.value}` - ).join('\n') || '' - } -${ - contact.extra.addresses?.map((address) => - `ADR;TYPE=${address.label}:${getSafelyEscapedTextForVCard(address.line_2 || '')};${ - getSafelyEscapedTextForVCard(address.line_1 || '') - };${getSafelyEscapedTextForVCard(address.city || '')};${getSafelyEscapedTextForVCard(address.state || '')};${ - getSafelyEscapedTextForVCard(address.postal_code || '') - };${getSafelyEscapedTextForVCard(address.country || '')}` - ).join('\n') || '' - } -${ - contact.extra.fields?.filter((field) => field.type === 'email').map((email) => - `EMAIL;TYPE=${email.name}:${email.value}` - ).join('\n') || '' - } -REV:${new Date(contact.updated_at).toISOString()} -${ - contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`) - .join('\n') || '' - } -${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''} -${contact.extra.uid ? `UID:${contact.extra.uid}` : ''} -END:VCARD` - ).join('\n'); - - return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n'); -} - -type VCardVersion = '2.1' | '3.0' | '4.0'; - -export function parseVCardFromTextContents(text: string): Partial[] { - const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); - - const partialContacts: Partial[] = []; - - let partialContact: Partial = {}; - let vCardVersion: VCardVersion = '2.1'; - - // Loop through every line - for (const line of lines) { - // Start new contact and vCard version - if (line.startsWith('BEGIN:VCARD')) { - partialContact = {}; - vCardVersion = '2.1'; - continue; - } - - // Finish contact - if (line.startsWith('END:VCARD')) { - partialContacts.push(partialContact); - continue; - } - - // Select proper vCard version - if (line.startsWith('VERSION:')) { - if (line.startsWith('VERSION:2.1')) { - vCardVersion = '2.1'; - } else if (line.startsWith('VERSION:3.0')) { - vCardVersion = '3.0'; - } else if (line.startsWith('VERSION:4.0')) { - vCardVersion = '4.0'; - } else { - // Default to 2.1, log warning - vCardVersion = '2.1'; - console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`); - } - - continue; - } - - if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') { - console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`); - vCardVersion = '2.1'; - } - - if (line.startsWith('UID:')) { - const uid = line.replace('UID:', ''); - - if (!uid) { - continue; - } - - partialContact.extra = { - ...(partialContact.extra || {}), - uid, - }; - - continue; - } - - if (line.startsWith('N:')) { - const names = line.split('N:')[1].split(';'); - - const lastName = names[0] || ''; - const firstName = names[1] || ''; - const middleNames = names.slice(2, -1).filter(Boolean); - const title = names.slice(-1).join(' ') || ''; - - if (!firstName) { - continue; - } - - partialContact.first_name = firstName; - partialContact.last_name = lastName; - partialContact.extra = { - ...(partialContact.extra || {}), - middle_names: middleNames, - name_title: title, - }; - - continue; - } - - if (line.startsWith('ORG:')) { - const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ','); - - if (!organization) { - continue; - } - - partialContact.extra = { - ...(partialContact.extra || {}), - organization, - }; - - continue; - } - - if (line.startsWith('BDAY:')) { - const birthday = line.split('BDAY:')[1] || ''; - - partialContact.extra = { - ...(partialContact.extra || {}), - birthday, - }; - - continue; - } - - if (line.startsWith('NICKNAME:')) { - const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || ''; - - if (!nickname) { - continue; - } - - partialContact.extra = { - ...(partialContact.extra || {}), - nickname, - }; - - continue; - } - - if (line.startsWith('TITLE:')) { - const role = line.split('TITLE:')[1] || ''; - - partialContact.extra = { - ...(partialContact.extra || {}), - role, - }; - - continue; - } - - if (line.startsWith('NOTE:')) { - const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || ''); - - partialContact.extra = { - ...(partialContact.extra || {}), - notes, - }; - - continue; - } - - if (line.includes('ADR;')) { - const addressInfo = line.split('ADR;')[1] || ''; - const addressParts = (addressInfo.split(':')[1] || '').split(';'); - const country = getSafelyUnescapedTextFromVCard(addressParts.slice(-1, addressParts.length).join(' ')); - const postalCode = getSafelyUnescapedTextFromVCard(addressParts.slice(-2, addressParts.length - 1).join(' ')); - const state = getSafelyUnescapedTextFromVCard(addressParts.slice(-3, addressParts.length - 2).join(' ')); - const city = getSafelyUnescapedTextFromVCard(addressParts.slice(-4, addressParts.length - 3).join(' ')); - const line1 = getSafelyUnescapedTextFromVCard(addressParts.slice(-5, addressParts.length - 4).join(' ')); - const line2 = getSafelyUnescapedTextFromVCard(addressParts.slice(-6, addressParts.length - 5).join(' ')); - - const label = getSafelyUnescapedTextFromVCard( - ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''), - ); - - if (!country && !postalCode && !state && !city && !line2 && !line1) { - continue; - } - - const address: ContactAddress = { - label, - line_1: line1, - line_2: line2, - city, - state, - postal_code: postalCode, - country, - }; - - partialContact.extra = { - ...(partialContact.extra || {}), - addresses: [...(partialContact.extra?.addresses || []), address], - }; - - continue; - } - - if (line.includes('PHOTO;')) { - const photoInfo = line.split('PHOTO;')[1] || ''; - const photoUrl = photoInfo.split(':')[1]; - const photoMediaTypeInfo = photoInfo.split(':')[0]; - let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || ''; - - if (!photoMediaType) { - photoMediaType = 'image/jpeg'; - } - - if (!photoMediaType.startsWith('image/')) { - photoMediaType = `image/${photoMediaType.toLowerCase()}`; - } - - if (!photoUrl) { - continue; - } - - partialContact.extra = { - ...(partialContact.extra || {}), - photo_mediatype: photoMediaType, - photo_url: photoUrl, - }; - - continue; - } - - if (line.includes('TEL;')) { - const phoneInfo = line.split('TEL;')[1] || ''; - const phoneNumber = phoneInfo.split(':')[1] || ''; - const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); - - if (!phoneNumber) { - continue; - } - - const field: ContactField = { - name, - value: phoneNumber, - type: 'phone', - }; - - partialContact.extra = { - ...(partialContact.extra || {}), - fields: [...(partialContact.extra?.fields || []), field], - }; - - continue; - } - - if (line.includes('EMAIL;')) { - const emailInfo = line.split('EMAIL;')[1] || ''; - const emailAddress = emailInfo.split(':')[1] || ''; - const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); - - if (!emailAddress) { - continue; - } - - const field: ContactField = { - name, - value: emailAddress, - type: 'email', - }; - - partialContact.extra = { - ...(partialContact.extra || {}), - fields: [...(partialContact.extra?.fields || []), field], - }; - - continue; - } - } - - return partialContacts; -} diff --git a/lib/utils/files.ts b/lib/utils/files.ts new file mode 100644 index 0000000..d5f542d --- /dev/null +++ b/lib/utils/files.ts @@ -0,0 +1,57 @@ +import { Directory, DirectoryFile } from '/lib/types.ts'; + +export const TRASH_PATH = `/.Trash/`; + +export function humanFileSize(bytes: number) { + if (Math.abs(bytes) < 1024) { + return `${bytes} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + let unitIndex = -1; + const roundedPower = 10 ** 2; + + do { + bytes /= 1024; + ++unitIndex; + } while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1); + + return `${bytes.toFixed(2)} ${units[unitIndex]}`; +} + +export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) { + if (entryA.name > entryB.name) { + return 1; + } + + if (entryA.name < entryB.name) { + return -1; + } + + return 0; +} + +export function sortDirectoriesByName(directoryA: Directory, directoryB: Directory) { + if (directoryA.directory_name > directoryB.directory_name) { + return 1; + } + + if (directoryA.directory_name < directoryB.directory_name) { + return -1; + } + + return 0; +} + +export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) { + if (fileA.file_name > fileB.file_name) { + return 1; + } + + if (fileA.file_name < fileB.file_name) { + return -1; + } + + return 0; +} diff --git a/lib/utils/files_test.ts b/lib/utils/files_test.ts new file mode 100644 index 0000000..304ab99 --- /dev/null +++ b/lib/utils/files_test.ts @@ -0,0 +1,36 @@ +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { humanFileSize } from './files.ts'; + +Deno.test('that humanFileSize works', () => { + const tests: { input: number; expected: string }[] = [ + { + input: 1000, + expected: '1000 B', + }, + { + input: 1024, + expected: '1.00 KB', + }, + { + input: 10000, + expected: '9.77 KB', + }, + { + input: 1, + expected: '1 B', + }, + { + input: 1048576, + expected: '1.00 MB', + }, + { + input: 1073741824, + expected: '1.00 GB', + }, + ]; + + for (const test of tests) { + const output = humanFileSize(test.input); + assertEquals(output, test.expected); + } +}); diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts index 224f0f6..1103168 100644 --- a/lib/utils/misc.ts +++ b/lib/utils/misc.ts @@ -10,15 +10,9 @@ if (typeof Deno !== 'undefined') { export const baseUrl = BASE_URL || 'http://localhost:8000'; export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud'; -export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`; +export const defaultDescription = `Have your files under your own control.`; export const helpEmail = 'help@bewcloud.com'; -export const DAV_RESPONSE_HEADER = '1, 2, 3, 4, addressbook, calendar-access'; -// Response headers from Nextcloud: -// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar -// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar -// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar - export function isRunningLocally(request: Request) { return request.url.includes('localhost'); } diff --git a/routes/.well-known/caldav.tsx b/routes/.well-known/caldav.tsx deleted file mode 100644 index e479c7a..0000000 --- a/routes/.well-known/caldav.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// Nextcloud/ownCloud mimicry -export function handler(): Response { - return new Response('Redirecting...', { - status: 307, - headers: { Location: '/dav' }, - }); -} diff --git a/routes/.well-known/carddav.tsx b/routes/.well-known/carddav.tsx deleted file mode 100644 index e479c7a..0000000 --- a/routes/.well-known/carddav.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// Nextcloud/ownCloud mimicry -export function handler(): Response { - return new Response('Redirecting...', { - status: 307, - headers: { Location: '/dav' }, - }); -} diff --git a/routes/api/calendar/add-event.tsx b/routes/api/calendar/add-event.tsx deleted file mode 100644 index 4c39370..0000000 --- a/routes/api/calendar/add-event.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { createCalendarEvent, getCalendar, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id); - - if (!calendar) { - return new Response('Not Found', { status: 404 }); - } - - const newCalendarEvent = await createCalendarEvent( - context.state.user.id, - requestBody.calendarId, - requestBody.title, - new Date(requestBody.startDate), - new Date(requestBody.endDate), - requestBody.isAllDay, - ); - - if (!newCalendarEvent) { - return new Response('Not Found', { status: 404 }); - } - - const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) }; - - if (requestBody.calendarView === 'day') { - dateRange.start.setDate(dateRange.start.getDate() - 1); - dateRange.end.setDate(dateRange.end.getDate() + 1); - } else if (requestBody.calendarView === 'week') { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 7); - } else { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 31); - } - - const newCalendarEvents = await getCalendarEvents(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/add.tsx b/routes/api/calendar/add.tsx deleted file mode 100644 index d217016..0000000 --- a/routes/api/calendar/add.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Calendar, FreshContextState } from '/lib/types.ts'; -import { createCalendar, getCalendars } from '/lib/data/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) { - const newCalendar = await createCalendar(context.state.user.id, requestBody.name); - - if (!newCalendar) { - return new Response('Not found', { status: 404 }); - } - } - - const newCalendars = await getCalendars(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 deleted file mode 100644 index 70c6a15..0000000 --- a/routes/api/calendar/delete-event.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { deleteCalendarEvent, getCalendar, getCalendarEvent, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id); - - if (!calendar) { - return new Response('Not Found', { status: 404 }); - } - - const calendarEvent = await getCalendarEvent( - requestBody.calendarEventId, - context.state.user.id, - ); - - if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendar_id) { - return new Response('Not Found', { status: 404 }); - } - - await deleteCalendarEvent(requestBody.calendarEventId, requestBody.calendarId, context.state.user.id); - - const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) }; - - if (requestBody.calendarView === 'day') { - dateRange.start.setDate(dateRange.start.getDate() - 1); - dateRange.end.setDate(dateRange.end.getDate() + 1); - } else if (requestBody.calendarView === 'week') { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 7); - } else { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 31); - } - - const newCalendarEvents = await getCalendarEvents(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 deleted file mode 100644 index 53086b1..0000000 --- a/routes/api/calendar/delete.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Calendar, FreshContextState } from '/lib/types.ts'; -import { deleteCalendar, getCalendar, getCalendars } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id); - - if (!calendar) { - return new Response('Not found', { status: 404 }); - } - - await deleteCalendar(requestBody.calendarId, context.state.user.id); - } - - const newCalendars = await getCalendars(context.state.user.id); - - const responseBody: ResponseBody = { success: true, newCalendars }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/calendar/get-events.tsx b/routes/api/calendar/get-events.tsx deleted file mode 100644 index 84e7d67..0000000 --- a/routes/api/calendar/get-events.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { getCalendarEvents } from '/lib/data/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 getCalendarEvents( - 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 deleted file mode 100644 index 35a1fd4..0000000 --- a/routes/api/calendar/import.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils/misc.ts'; -import { createCalendarEvent, getCalendar, getCalendarEvents, updateCalendarEvent } from '/lib/data/calendar.ts'; - -interface Data {} - -export interface RequestBody { - calendarIds: string[]; - calendarView: 'day' | 'week' | 'month'; - calendarStartDate: string; - partialCalendarEvents: Partial[]; - 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.partialCalendarEvents || - !requestBody.calendarView || !requestBody.calendarStartDate - ) { - return new Response('Bad Request', { status: 400 }); - } - - const calendar = await getCalendar(requestBody.calendarId, context.state.user.id); - - if (!calendar) { - return new Response('Not Found', { status: 404 }); - } - - if (requestBody.partialCalendarEvents.length === 0) { - return new Response('Not found', { status: 404 }); - } - - await concurrentPromises( - requestBody.partialCalendarEvents.map((partialCalendarEvent) => async () => { - if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) { - const calendarEvent = await createCalendarEvent( - context.state.user!.id, - requestBody.calendarId, - partialCalendarEvent.title, - new Date(partialCalendarEvent.start_date), - new Date(partialCalendarEvent.end_date), - partialCalendarEvent.is_all_day, - ); - - const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {}); - - if (parsedExtra !== '{}') { - calendarEvent.extra = partialCalendarEvent.extra!; - - if ( - calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_sequence === 0 && - !calendarEvent.extra.recurring_id - ) { - calendarEvent.extra.recurring_id = calendarEvent.id; - } - - await updateCalendarEvent(calendarEvent); - } - } - }), - 5, - ); - - const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) }; - - if (requestBody.calendarView === 'day') { - dateRange.start.setDate(dateRange.start.getDate() - 1); - dateRange.end.setDate(dateRange.end.getDate() + 1); - } else if (requestBody.calendarView === 'week') { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 7); - } else { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 31); - } - - const newCalendarEvents = await getCalendarEvents(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 deleted file mode 100644 index 103018d..0000000 --- a/routes/api/calendar/search-events.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { searchCalendarEvents } from '/lib/data/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 matchingCalendarEvents = await searchCalendarEvents( - requestBody.searchTerm, - context.state.user.id, - requestBody.calendarIds, - ); - - 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 deleted file mode 100644 index 9045ec9..0000000 --- a/routes/api/calendar/update.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Calendar, FreshContextState } from '/lib/types.ts'; -import { getCalendar, getCalendars, updateCalendar } from '/lib/data/calendar.ts'; - -interface Data {} - -export interface RequestBody { - id: string; - name: string; - color: string; - is_visible: 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 getCalendar(requestBody.id, context.state.user.id); - - if (!calendar) { - return new Response('Not found', { status: 404 }); - } - - calendar.name = requestBody.name; - calendar.color = requestBody.color; - calendar.is_visible = requestBody.is_visible; - - await updateCalendar(calendar); - } - - const newCalendars = await getCalendars(context.state.user.id); - - const responseBody: ResponseBody = { success: true, newCalendars }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/contacts/add.tsx b/routes/api/contacts/add.tsx deleted file mode 100644 index edc1369..0000000 --- a/routes/api/contacts/add.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { createContact, getContacts } from '/lib/data/contacts.ts'; - -interface Data {} - -export interface RequestBody { - firstName: string; - lastName: string; - page: number; -} - -export interface ResponseBody { - success: boolean; - contacts: Pick[]; -} - -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.firstName) { - const contact = await createContact(context.state.user.id, requestBody.firstName, requestBody.lastName); - - if (!contact) { - return new Response('Not found', { status: 404 }); - } - } - - const contacts = await getContacts(context.state.user.id, requestBody.page - 1); - - const responseBody: ResponseBody = { success: true, contacts }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/contacts/delete.tsx b/routes/api/contacts/delete.tsx deleted file mode 100644 index 36acfeb..0000000 --- a/routes/api/contacts/delete.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { deleteContact, getContact, getContacts } from '/lib/data/contacts.ts'; - -interface Data {} - -export interface RequestBody { - contactId: string; - page: number; -} - -export interface ResponseBody { - success: boolean; - contacts: Pick[]; -} - -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.contactId) { - const contact = await getContact(requestBody.contactId, context.state.user.id); - - if (!contact) { - return new Response('Not found', { status: 404 }); - } - - await deleteContact(requestBody.contactId, context.state.user.id); - } - - const contacts = await getContacts(context.state.user.id, requestBody.page - 1); - - const responseBody: ResponseBody = { success: true, contacts }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/contacts/get.tsx b/routes/api/contacts/get.tsx deleted file mode 100644 index 0ac27c4..0000000 --- a/routes/api/contacts/get.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { getAllContacts } from '/lib/data/contacts.ts'; - -interface Data {} - -export interface RequestBody {} - -export interface ResponseBody { - success: boolean; - contacts: Contact[]; -} - -export const handler: Handlers = { - async POST(request, context) { - if (!context.state.user) { - return new Response('Unauthorized', { status: 401 }); - } - - const contacts = await getAllContacts(context.state.user.id); - - const responseBody: ResponseBody = { success: true, contacts }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/contacts/import.tsx b/routes/api/contacts/import.tsx deleted file mode 100644 index 3b407aa..0000000 --- a/routes/api/contacts/import.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Handlers } from 'fresh/server.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { concurrentPromises } from '/lib/utils/misc.ts'; -import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts'; - -interface Data {} - -export interface RequestBody { - partialContacts: Partial[]; - page: number; -} - -export interface ResponseBody { - success: boolean; - contacts: Pick[]; -} - -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.partialContacts) { - if (requestBody.partialContacts.length === 0) { - return new Response('Not found', { status: 404 }); - } - - await concurrentPromises( - requestBody.partialContacts.map((partialContact) => async () => { - if (partialContact.first_name) { - const contact = await createContact( - context.state.user!.id, - partialContact.first_name, - partialContact.last_name || '', - ); - - const parsedExtra = JSON.stringify(partialContact.extra || {}); - - if (parsedExtra !== '{}') { - contact.extra = partialContact.extra!; - - await updateContact(contact); - } - } - }), - 5, - ); - } - - const contacts = await getContacts(context.state.user.id, requestBody.page - 1); - - const responseBody: ResponseBody = { success: true, contacts }; - - return new Response(JSON.stringify(responseBody)); - }, -}; diff --git a/routes/api/files/create-directory.tsx b/routes/api/files/create-directory.tsx new file mode 100644 index 0000000..910be36 --- /dev/null +++ b/routes/api/files/create-directory.tsx @@ -0,0 +1,47 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Directory, FreshContextState } from '/lib/types.ts'; +import { createDirectory, getDirectories } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + name: string; +} + +export interface ResponseBody { + success: boolean; + newDirectories: Directory[]; +} + +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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') || + requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path and get the appropriate ownerUserId + + const createdDirectory = await createDirectory( + context.state.user.id, + requestBody.parentPath, + requestBody.name.trim(), + ); + + const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + + const responseBody: ResponseBody = { success: createdDirectory, newDirectories }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/delete-directory.tsx b/routes/api/files/delete-directory.tsx new file mode 100644 index 0000000..8499298 --- /dev/null +++ b/routes/api/files/delete-directory.tsx @@ -0,0 +1,47 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Directory, FreshContextState } from '/lib/types.ts'; +import { deleteDirectoryOrFile, getDirectories } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + name: string; +} + +export interface ResponseBody { + success: boolean; + newDirectories: Directory[]; +} + +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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') || + requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path and get the appropriate ownerUserId + + const deletedDirectory = await deleteDirectoryOrFile( + context.state.user.id, + requestBody.parentPath, + requestBody.name.trim(), + ); + + const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + + const responseBody: ResponseBody = { success: deletedDirectory, newDirectories }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/delete.tsx b/routes/api/files/delete.tsx new file mode 100644 index 0000000..26ecd03 --- /dev/null +++ b/routes/api/files/delete.tsx @@ -0,0 +1,47 @@ +import { Handlers } from 'fresh/server.ts'; + +import { DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { deleteDirectoryOrFile, getFiles } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + name: string; +} + +export interface ResponseBody { + success: boolean; + newFiles: DirectoryFile[]; +} + +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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') || + requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path/file and get the appropriate ownerUserId + + const deletedFile = await deleteDirectoryOrFile( + context.state.user.id, + requestBody.parentPath, + requestBody.name.trim(), + ); + + const newFiles = await getFiles(context.state.user.id, requestBody.parentPath); + + const responseBody: ResponseBody = { success: deletedFile, newFiles }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/get-directories.tsx b/routes/api/files/get-directories.tsx new file mode 100644 index 0000000..f61b3cc --- /dev/null +++ b/routes/api/files/get-directories.tsx @@ -0,0 +1,47 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Directory, FreshContextState } from '/lib/types.ts'; +import { getDirectories } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + directoryPathToExclude?: string; +} + +export interface ResponseBody { + success: boolean; + directories: Directory[]; +} + +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.parentPath || !requestBody.parentPath.startsWith('/') || requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + const directories = await getDirectories( + context.state.user.id, + requestBody.parentPath, + ); + + const filteredDirectories = requestBody.directoryPathToExclude + ? directories.filter((directory) => + `${directory.parent_path}${directory.directory_name}` !== requestBody.directoryPathToExclude + ) + : directories; + + const responseBody: ResponseBody = { success: true, directories: filteredDirectories }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/move-directory.tsx b/routes/api/files/move-directory.tsx new file mode 100644 index 0000000..54ef239 --- /dev/null +++ b/routes/api/files/move-directory.tsx @@ -0,0 +1,52 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Directory, FreshContextState } from '/lib/types.ts'; +import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + oldParentPath: string; + newParentPath: string; + name: string; +} + +export interface ResponseBody { + success: boolean; + newDirectories: Directory[]; +} + +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.oldParentPath || !requestBody.newParentPath || !requestBody.name?.trim() || + !requestBody.oldParentPath.startsWith('/') || + requestBody.oldParentPath.includes('../') || !requestBody.newParentPath.startsWith('/') || + requestBody.newParentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to old and new paths and get the appropriate ownerUserIds + + const movedDirectory = await renameDirectoryOrFile( + context.state.user.id, + requestBody.oldParentPath, + requestBody.newParentPath, + requestBody.name.trim(), + requestBody.name.trim(), + ); + + const newDirectories = await getDirectories(context.state.user.id, requestBody.oldParentPath); + + const responseBody: ResponseBody = { success: movedDirectory, newDirectories }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/move.tsx b/routes/api/files/move.tsx new file mode 100644 index 0000000..96a1562 --- /dev/null +++ b/routes/api/files/move.tsx @@ -0,0 +1,52 @@ +import { Handlers } from 'fresh/server.ts'; + +import { DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + oldParentPath: string; + newParentPath: string; + name: string; +} + +export interface ResponseBody { + success: boolean; + newFiles: DirectoryFile[]; +} + +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.oldParentPath || !requestBody.newParentPath || !requestBody.name?.trim() || + !requestBody.oldParentPath.startsWith('/') || + requestBody.oldParentPath.includes('../') || !requestBody.newParentPath.startsWith('/') || + requestBody.newParentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to old and new paths/files and get the appropriate ownerUserIds + + const movedFile = await renameDirectoryOrFile( + context.state.user.id, + requestBody.oldParentPath, + requestBody.newParentPath, + requestBody.name.trim(), + requestBody.name.trim(), + ); + + const newFiles = await getFiles(context.state.user.id, requestBody.oldParentPath); + + const responseBody: ResponseBody = { success: movedFile, newFiles }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/rename-directory.tsx b/routes/api/files/rename-directory.tsx new file mode 100644 index 0000000..fd1a7cd --- /dev/null +++ b/routes/api/files/rename-directory.tsx @@ -0,0 +1,51 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Directory, FreshContextState } from '/lib/types.ts'; +import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + oldName: string; + newName: string; +} + +export interface ResponseBody { + success: boolean; + newDirectories: Directory[]; +} + +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.parentPath || !requestBody.oldName?.trim() || !requestBody.newName?.trim() || + !requestBody.parentPath.startsWith('/') || + requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path and get the appropriate ownerUserId + + const movedDirectory = await renameDirectoryOrFile( + context.state.user.id, + requestBody.parentPath, + requestBody.parentPath, + requestBody.oldName.trim(), + requestBody.newName.trim(), + ); + + const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + + const responseBody: ResponseBody = { success: movedDirectory, newDirectories }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/rename.tsx b/routes/api/files/rename.tsx new file mode 100644 index 0000000..cca14d5 --- /dev/null +++ b/routes/api/files/rename.tsx @@ -0,0 +1,51 @@ +import { Handlers } from 'fresh/server.ts'; + +import { DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + parentPath: string; + oldName: string; + newName: string; +} + +export interface ResponseBody { + success: boolean; + newFiles: DirectoryFile[]; +} + +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.parentPath || !requestBody.oldName?.trim() || !requestBody.newName?.trim() || + !requestBody.parentPath.startsWith('/') || + requestBody.parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path/file and get the appropriate ownerUserId + + const movedFile = await renameDirectoryOrFile( + context.state.user.id, + requestBody.parentPath, + requestBody.parentPath, + requestBody.oldName.trim(), + requestBody.newName.trim(), + ); + + const newFiles = await getFiles(context.state.user.id, requestBody.parentPath); + + const responseBody: ResponseBody = { success: movedFile, newFiles }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/files/upload.tsx b/routes/api/files/upload.tsx new file mode 100644 index 0000000..d20980c --- /dev/null +++ b/routes/api/files/upload.tsx @@ -0,0 +1,42 @@ +import { Handlers } from 'fresh/server.ts'; + +import { DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { createFile, getFiles } from '/lib/data/files.ts'; + +interface Data {} + +export interface ResponseBody { + success: boolean; + newFiles: DirectoryFile[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().formData(); + + const parentPath = requestBody.get('parent_path') as string; + const name = requestBody.get('name') as string; + const contents = requestBody.get('contents') as File; + + if ( + !parentPath || !name.trim() || !contents || !parentPath.startsWith('/') || + parentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: Verify user has write access to path and get the appropriate ownerUserId + + const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), await contents.arrayBuffer()); + + const newFiles = await getFiles(context.state.user.id, parentPath); + + const responseBody: ResponseBody = { success: createdFile, newFiles }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/calendar.tsx b/routes/calendar.tsx deleted file mode 100644 index 0984f21..0000000 --- a/routes/calendar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Handlers, PageProps } from 'fresh/server.ts'; - -import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts'; -import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx'; - -interface Data { - userCalendars: Pick[]; - userCalendarEvents: CalendarEvent[]; - view: 'day' | 'week' | 'month'; - startDate: string; -} - -export const handler: Handlers = { - async GET(request, context) { - if (!context.state.user) { - return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); - } - - 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); - - const userCalendars = await getCalendars(context.state.user.id); - const visibleCalendarIds = userCalendars.filter((calendar) => calendar.is_visible).map((calendar) => calendar.id); - - const dateRange = { start: new Date(startDate), end: new Date(startDate) }; - - if (view === 'day') { - dateRange.start.setDate(dateRange.start.getDate() - 1); - dateRange.end.setDate(dateRange.end.getDate() + 1); - } else if (view === 'week') { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 7); - } else { - dateRange.start.setDate(dateRange.start.getDate() - 7); - dateRange.end.setDate(dateRange.end.getDate() + 31); - } - - const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds, dateRange); - - return await context.render({ userCalendars, userCalendarEvents, view, startDate }); - }, -}; - -export default function CalendarPage({ data }: PageProps) { - return ( -
- -
- ); -} diff --git a/routes/calendar/[calendarEventId].tsx b/routes/calendar/[calendarEventId].tsx deleted file mode 100644 index 12a94a4..0000000 --- a/routes/calendar/[calendarEventId].tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { Handlers, PageProps } from 'fresh/server.ts'; - -import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { convertFormDataToObject } from '/lib/utils/misc.ts'; -import { getCalendarEvent, getCalendars, updateCalendarEvent } from '/lib/data/calendar.ts'; -import { getFormDataField } from '/lib/form-utils.tsx'; -import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx'; - -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 { calendarEventId } = context.params; - - const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); - - if (!calendarEvent) { - return new Response('Not found', { status: 404 }); - } - - const calendars = await getCalendars(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 { calendarEventId } = context.params; - - const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); - - if (!calendarEvent) { - return new Response('Not found', { status: 404 }); - } - - const calendars = await getCalendars(context.state.user.id); - - const formData = await request.formData(); - - calendarEvent.title = getFormDataField(formData, 'title'); - calendarEvent.start_date = new Date(getFormDataField(formData, 'start_date')); - calendarEvent.end_date = new Date(getFormDataField(formData, 'end_date')); - calendarEvent.is_all_day = getFormDataField(formData, 'is_all_day') === 'true'; - calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status']; - - calendarEvent.extra.description = getFormDataField(formData, 'description') || undefined; - calendarEvent.extra.url = getFormDataField(formData, 'url') || undefined; - calendarEvent.extra.location = getFormDataField(formData, 'location') || undefined; - calendarEvent.extra.transparency = - getFormDataField(formData, 'transparency') as CalendarEvent['extra']['transparency'] || 'default'; - - const newCalendarId = getFormDataField(formData, 'calendar_id'); - let oldCalendarId: string | undefined; - - if (newCalendarId !== calendarEvent.calendar_id) { - oldCalendarId = calendarEvent.calendar_id; - } - - calendarEvent.calendar_id = newCalendarId; - - try { - if (!calendarEvent.title) { - throw new Error(`Title is required.`); - } - - formFields(calendarEvent, calendars).forEach((field) => { - if (field.required) { - const value = formData.get(field.name); - - if (!value) { - throw new Error(`${field.label} is required`); - } - } - }); - - await updateCalendarEvent(calendarEvent, oldCalendarId); - - 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.toString(), - formData: convertFormDataToObject(formData), - }); - } - }, -}; - -export default function ContactsPage({ data }: PageProps) { - return ( -
- -
- ); -} diff --git a/routes/calendars.tsx b/routes/calendars.tsx deleted file mode 100644 index d0c17d7..0000000 --- a/routes/calendars.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Handlers, PageProps } from 'fresh/server.ts'; - -import { Calendar, FreshContextState } from '/lib/types.ts'; -import { getCalendars } from '/lib/data/calendar.ts'; -import Calendars from '/islands/calendar/Calendars.tsx'; - -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 userCalendars = await getCalendars(context.state.user.id); - - return await context.render({ userCalendars }); - }, -}; - -export default function CalendarsPage({ data }: PageProps) { - return ( -
- -
- ); -} diff --git a/routes/contacts.tsx b/routes/contacts.tsx deleted file mode 100644 index 6c5e9c6..0000000 --- a/routes/contacts.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Handlers, PageProps } from 'fresh/server.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { getContacts, getContactsCount, searchContacts, searchContactsCount } from '/lib/data/contacts.ts'; -import Contacts from '/islands/contacts/Contacts.tsx'; - -interface Data { - userContacts: Pick[]; - page: number; - contactsCount: number; - search?: string; -} - -export const handler: Handlers = { - async GET(request, context) { - if (!context.state.user) { - return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); - } - - const searchParams = new URL(request.url).searchParams; - - const page = parseInt(searchParams.get('page') || '1', 10); - const search = searchParams.get('search') || undefined; - - const userContacts = search - ? await searchContacts(search, context.state.user.id, page - 1) - : await getContacts(context.state.user.id, page - 1); - - const contactsCount = search - ? await searchContactsCount(search, context.state.user.id) - : await getContactsCount(context.state.user.id); - - return await context.render({ userContacts, page, contactsCount, search }); - }, -}; - -export default function ContactsPage({ data }: PageProps) { - return ( -
- -
- ); -} diff --git a/routes/contacts/[contactId].tsx b/routes/contacts/[contactId].tsx deleted file mode 100644 index 49220f2..0000000 --- a/routes/contacts/[contactId].tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { Handlers, PageProps } from 'fresh/server.ts'; - -import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts'; -import { convertFormDataToObject } from '/lib/utils/misc.ts'; -import { getContact, updateContact } from '/lib/data/contacts.ts'; -import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx'; -import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx'; - -interface Data { - contact: Contact; - 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 { contactId } = context.params; - - const contact = await getContact(contactId, context.state.user.id); - - if (!contact) { - return new Response('Not found', { status: 404 }); - } - - return await context.render({ contact, formData: {} }); - }, - async POST(request, context) { - if (!context.state.user) { - return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); - } - - const { contactId } = context.params; - - const contact = await getContact(contactId, context.state.user.id); - - if (!contact) { - return new Response('Not found', { status: 404 }); - } - - const formData = await request.formData(); - - contact.extra.name_title = getFormDataField(formData, 'name_title') || undefined; - contact.first_name = getFormDataField(formData, 'first_name'); - contact.last_name = getFormDataField(formData, 'last_name'); - contact.extra.middle_names = getFormDataField(formData, 'middle_names').split(' ').map((name) => - (name || '').trim() - ).filter(Boolean); - if (contact.extra.middle_names.length === 0) { - contact.extra.middle_names = undefined; - } - contact.extra.birthday = getFormDataField(formData, 'birthday') || undefined; - contact.extra.nickname = getFormDataField(formData, 'nickname') || undefined; - contact.extra.organization = getFormDataField(formData, 'organization') || undefined; - contact.extra.role = getFormDataField(formData, 'role') || undefined; - contact.extra.photo_url = getFormDataField(formData, 'photo_url') || undefined; - contact.extra.photo_mediatype = contact.extra.photo_url - ? `image/${contact.extra.photo_url.split('.').slice(-1, 1).join('').toLowerCase()}` - : undefined; - contact.extra.notes = getFormDataField(formData, 'notes') || undefined; - - contact.extra.fields = []; - - // Phones - const phoneNumbers = getFormDataFieldArray(formData, 'phone_numbers'); - const phoneLabels = getFormDataFieldArray(formData, 'phone_labels'); - - for (const [index, phoneNumber] of phoneNumbers.entries()) { - if (phoneNumber.trim().length === 0) { - continue; - } - - const field: ContactField = { - name: phoneLabels[index] || 'Home', - value: phoneNumber.trim(), - type: 'phone', - }; - - contact.extra.fields.push(field); - } - - // Emails - const emailAddresses = getFormDataFieldArray(formData, 'email_addresses'); - const emailLabels = getFormDataFieldArray(formData, 'email_labels'); - - for (const [index, emailAddress] of emailAddresses.entries()) { - if (emailAddress.trim().length === 0) { - continue; - } - - const field: ContactField = { - name: emailLabels[index] || 'Home', - value: emailAddress.trim(), - type: 'email', - }; - - contact.extra.fields.push(field); - } - - // URLs - const urlAddresses = getFormDataFieldArray(formData, 'url_addresses'); - const urlLabels = getFormDataFieldArray(formData, 'url_labels'); - - for (const [index, urlAddress] of urlAddresses.entries()) { - if (urlAddress.trim().length === 0) { - continue; - } - - const field: ContactField = { - name: urlLabels[index] || 'Home', - value: urlAddress.trim(), - type: 'url', - }; - - contact.extra.fields.push(field); - } - - // Others - const otherValues = getFormDataFieldArray(formData, 'other_values'); - const otherLabels = getFormDataFieldArray(formData, 'other_labels'); - - for (const [index, otherValue] of otherValues.entries()) { - if (otherValue.trim().length === 0) { - continue; - } - - const field: ContactField = { - name: otherLabels[index] || 'Home', - value: otherValue.trim(), - type: 'other', - }; - - contact.extra.fields.push(field); - } - - contact.extra.addresses = []; - - // Addresses - const addressLine1s = getFormDataFieldArray(formData, 'address_line_1s'); - const addressLine2s = getFormDataFieldArray(formData, 'address_line_2s'); - const addressCities = getFormDataFieldArray(formData, 'address_cities'); - const addressPostalCodes = getFormDataFieldArray(formData, 'address_postal_codes'); - const addressStates = getFormDataFieldArray(formData, 'address_states'); - const addressCountries = getFormDataFieldArray(formData, 'address_countries'); - const addressLabels = getFormDataFieldArray(formData, 'address_labels'); - - for (const [index, addressLine1] of addressLine1s.entries()) { - if (addressLine1.trim().length === 0) { - continue; - } - - const address: ContactAddress = { - label: addressLabels[index] || 'Home', - line_1: addressLine1.trim(), - line_2: addressLine2s[index] || undefined, - city: addressCities[index] || undefined, - postal_code: addressPostalCodes[index] || undefined, - state: addressStates[index] || undefined, - country: addressCountries[index] || undefined, - }; - - contact.extra.addresses.push(address); - } - - try { - if (!contact.first_name) { - throw new Error(`First name is required.`); - } - - formFields(contact).forEach((field) => { - if (field.required) { - const value = formData.get(field.name); - - if (!value) { - throw new Error(`${field.label} is required`); - } - } - }); - - await updateContact(contact); - - return await context.render({ - contact, - notice: 'Contact updated successfully!', - formData: convertFormDataToObject(formData), - }); - } catch (error) { - console.error(error); - return await context.render({ contact, error: error.toString(), formData: convertFormDataToObject(formData) }); - } - }, -}; - -export default function ContactsPage({ data }: PageProps) { - return ( -
- -
- ); -} diff --git a/routes/dav/addressbooks.tsx b/routes/dav/addressbooks.tsx deleted file mode 100644 index 9d9356f..0000000 --- a/routes/dav/addressbooks.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype'?: { - 'd:collection': {}; - 'card:addressbook'?: {}; - }; - 'd:displayname'?: string; - 'd:getetag'?: string; - 'd:current-user-principal'?: { - 'd:href': string; - }; - 'd:principal-URL'?: {}; - 'card:addressbook-home-set'?: {}; - }; - 'd:status': string; - }[]; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:card': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { - return new Response('Bad Request', { status: 400 }); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [ - { - 'd:href': '/dav/addressbooks/', - 'd:propstat': [{ - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }, - { - 'd:href': '/dav/addressbooks/contacts/', - 'd:propstat': [{ - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - 'card:addressbook': {}, - }, - 'd:displayname': 'Contacts', - 'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`), - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }, - ], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/addressbooks/contacts.tsx b/routes/dav/addressbooks/contacts.tsx deleted file mode 100644 index 9535467..0000000 --- a/routes/dav/addressbooks/contacts.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { Handler } from 'fresh/server.ts'; -import { parse } from 'xml/mod.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; -import { formatContactToVCard } from '/lib/utils/contacts.ts'; -import { getAllContacts } from '/lib/data/contacts.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype'?: { - 'd:collection'?: {}; - 'card:addressbook'?: {}; - }; - 'd:displayname'?: string | {}; - 'card:address-data'?: string; - 'd:getlastmodified'?: string | {}; - 'd:getetag'?: string | {}; - 'd:getcontenttype'?: string | {}; - 'd:getcontentlength'?: number | {}; - 'd:creationdate'?: string | {}; - 'card:addressbook-description'?: string | {}; - 'cs:getctag'?: {}; - 'd:current-user-privilege-set'?: { - 'd:privilege': { - 'd:write-properties'?: {}; - 'd:write'?: {}; - 'd:write-content'?: {}; - 'd:unlock'?: {}; - 'd:bind'?: {}; - 'd:unbind'?: {}; - 'd:write-acl'?: {}; - 'd:read'?: {}; - 'd:read-acl'?: {}; - 'd:read-current-user-privilege-set'?: {}; - }[]; - }; - }; - 'd:status': string; - }[]; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:card': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - 'xmlns:cs': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { - return new Response('Bad Request', { status: 400 }); - } - - const contacts = await getAllContacts(context.state.user.id); - - const requestBody = (await request.clone().text()).toLowerCase(); - - let includeVCard = false; - let includeCollection = true; - const includePrivileges = requestBody.includes('current-user-privilege-set'); - - const filterContactIds = new Set(); - - try { - const parsedDocument = parse(requestBody); - - const multiGetRequest = (parsedDocument['addressbook-multiget'] || parsedDocument['r:addressbook-multiget'] || - parsedDocument['f:addressbook-multiget'] || parsedDocument['d:addressbook-multiget'] || - parsedDocument['r:addressbook-query'] || - parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as - | Record - | undefined; - - includeVCard = Boolean(multiGetRequest); - - const requestedHrefs: string[] = (multiGetRequest && (multiGetRequest['href'] || multiGetRequest['d:href'])) || []; - - includeCollection = requestedHrefs.length === 0; - - for (const requestedHref of requestedHrefs) { - const userVCard = requestedHref.split('/').slice(-1).join(''); - const [userId] = userVCard.split('.vcf'); - - if (userId) { - filterContactIds.add(userId); - } - } - } catch (error) { - console.error(`Failed to parse XML`, error); - } - - const filteredContacts = filterContactIds.size > 0 - ? contacts.filter((contact) => filterContactIds.has(contact.id)) - : contacts; - - const parsedContacts = filteredContacts.map((contact) => { - const parsedContact: DavResponse = { - 'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`, - 'd:propstat': [{ - 'd:prop': { - 'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()), - 'd:getetag': escapeHtml(`"${contact.revision}"`), - 'd:getcontenttype': 'text/vcard; charset=utf-8', - 'd:resourcetype': {}, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { - 'd:displayname': {}, - 'd:getcontentlength': {}, - 'd:creationdate': {}, - 'card:addressbook-description': {}, - 'cs:getctag': {}, - }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }; - - if (includeVCard) { - parsedContact['d:propstat'][0]['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact])); - } - - if (includePrivileges) { - parsedContact['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { - 'd:privilege': [ - { 'd:write-properties': {} }, - { 'd:write': {} }, - { 'd:write-content': {} }, - { 'd:unlock': {} }, - { 'd:bind': {} }, - { 'd:unbind': {} }, - { 'd:write-acl': {} }, - { 'd:read': {} }, - { 'd:read-acl': {} }, - { 'd:read-current-user-privilege-set': {} }, - ], - }; - } - - return parsedContact; - }); - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [ - ...parsedContacts, - ], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - 'xmlns:cs': 'http://calendarserver.org/ns/', - }, - }; - - if (includeCollection) { - const collectionResponse: DavResponse = { - 'd:href': '/dav/addressbooks/contacts/', - 'd:propstat': [{ - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - 'card:addressbook': {}, - }, - 'd:displayname': 'Contacts', - 'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`), - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { - 'd:getlastmodified': {}, - 'd:getcontenttype': {}, - 'd:getcontentlength': {}, - 'd:creationdate': {}, - 'card:addressbook-description': {}, - 'cs:getctag': {}, - }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }; - - if (includePrivileges) { - collectionResponse['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { - 'd:privilege': [ - { 'd:write-properties': {} }, - { 'd:write': {} }, - { 'd:write-content': {} }, - { 'd:unlock': {} }, - { 'd:bind': {} }, - { 'd:unbind': {} }, - { 'd:write-acl': {} }, - { 'd:read': {} }, - { 'd:read-acl': {} }, - { 'd:read-current-user-privilege-set': {} }, - ], - }; - } - responseBody['d:multistatus']['d:response'].unshift(collectionResponse); - } - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/addressbooks/contacts/[contactId].vcf.tsx b/routes/dav/addressbooks/contacts/[contactId].vcf.tsx deleted file mode 100644 index d56ac50..0000000 --- a/routes/dav/addressbooks/contacts/[contactId].vcf.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { Handler } from 'fresh/server.ts'; -import { parse } from 'xml/mod.ts'; - -import { Contact, FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; -import { formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts'; -import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection'?: {}; - 'card:addressbook'?: {}; - }; - 'card:address-data'?: string; - 'd:getlastmodified'?: string; - 'd:getetag'?: string; - 'd:getcontenttype'?: string; - }; - 'd:status': string; - }; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:card': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if ( - request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' && - request.method !== 'PUT' && request.method !== 'DELETE' - ) { - return new Response('Bad Request', { status: 400 }); - } - - const { contactId } = context.params; - - let contact: Contact | null = null; - - try { - contact = await getContact(contactId, context.state.user.id); - } catch (error) { - console.error(error); - } - - if (!contact) { - if (request.method === 'PUT') { - const requestBody = await request.clone().text(); - - const [partialContact] = parseVCardFromTextContents(requestBody); - - if (partialContact.first_name) { - const newContact = await createContact( - context.state.user.id, - partialContact.first_name, - partialContact.last_name || '', - ); - - // Use the sent id for the UID - if (!partialContact.extra?.uid) { - partialContact.extra = { - ...(partialContact.extra || {}), - uid: contactId, - }; - } - - newContact.extra = partialContact.extra!; - - await updateContact(newContact); - - contact = await getContact(newContact.id, context.state.user.id); - - return new Response('Created', { status: 201, headers: { 'etag': `"${contact.revision}"` } }); - } - } - - return new Response('Not found', { status: 404 }); - } - - if (request.method === 'DELETE') { - const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); - - // Don't update outdated data - if (clientRevision && clientRevision !== `"${contact.revision}"`) { - return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); - } - - await deleteContact(contactId, context.state.user.id); - - return new Response(null, { status: 202 }); - } - - if (request.method === 'PUT') { - const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); - - // Don't update outdated data - if (clientRevision && clientRevision !== `"${contact.revision}"`) { - return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); - } - - const requestBody = await request.clone().text(); - - const [partialContact] = parseVCardFromTextContents(requestBody); - - contact = { - ...contact, - ...partialContact, - }; - - await updateContact(contact); - - contact = await getContact(contactId, context.state.user.id); - - return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); - } - - if (request.method === 'GET') { - // Set a UID if there isn't one - if (!contact.extra.uid) { - contact.extra.uid = crypto.randomUUID(); - await updateContact(contact); - - contact = await getContact(contactId, context.state.user.id); - } - - const response = new Response(formatContactToVCard([contact]), { - status: 200, - headers: { 'etag': `"${contact.revision}"` }, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); - } - - const requestBody = (await request.clone().text()).toLowerCase(); - - let includeVCard = false; - - try { - const parsedDocument = parse(requestBody); - - const multiGetRequest = (parsedDocument['r:addressbook-multiget'] || parsedDocument['r:addressbook-query'] || - parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as - | Record - | undefined; - - includeVCard = Boolean(multiGetRequest); - } catch (error) { - console.error(`Failed to parse XML`, error); - } - - const parsedContact: DavResponse = { - 'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`, - 'd:propstat': { - 'd:prop': { - 'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()), - 'd:getetag': escapeHtml(`"${contact.revision}"`), - 'd:getcontenttype': 'text/vcard; charset=utf-8', - 'd:resourcetype': {}, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }; - - if (includeVCard) { - parsedContact['d:propstat']['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact])); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [parsedContact], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/calendars.tsx b/routes/dav/calendars.tsx deleted file mode 100644 index 2209b1e..0000000 --- a/routes/dav/calendars.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; -import { getColorAsHex } from '/lib/utils/calendar.ts'; -import { createSessionCookie } from '/lib/auth.ts'; -import { getCalendars } from '/lib/data/calendar.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype'?: { - 'd:collection': {}; - 'cal:calendar'?: {}; - }; - 'd:displayname'?: string | {}; - 'd:getetag'?: string | {}; - 'cs:getctag'?: string | {}; - 'd:current-user-principal'?: { - 'd:href': string; - }; - 'd:principal-URL'?: {}; - 'd:current-user-privilege-set'?: { - 'd:privilege': { - 'd:write-properties'?: {}; - 'd:write'?: {}; - 'd:write-content'?: {}; - 'd:unlock'?: {}; - 'd:bind'?: {}; - 'd:unbind'?: {}; - 'd:write-acl'?: {}; - 'd:read'?: {}; - 'd:read-acl'?: {}; - 'd:read-current-user-privilege-set'?: {}; - }[]; - }; - 'ic:calendar-color'?: string; - }; - 'd:status': string; - }[]; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:cal': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - 'xmlns:cs': string; - 'xmlns:ic': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { - return new Response('Bad Request', { status: 400 }); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [ - { - 'd:href': '/dav/calendars/', - 'd:propstat': [{ - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { 'd:principal-URL': {}, 'd:displayname': {}, 'cs:getctag': {} }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }, - ], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:cal': 'urn:ietf:params:xml:ns:caldav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - 'xmlns:cs': 'http://calendarserver.org/ns/', - 'xmlns:ic': 'http://apple.com/ns/ical/', - }, - }; - - const calendars = await getCalendars(context.state.user.id); - - const requestBody = (await request.clone().text()).toLowerCase(); - - const includePrivileges = requestBody.includes('current-user-privilege-set'); - const includeColor = requestBody.includes('calendar-color'); - - if (includePrivileges) { - responseBody['d:multistatus']['d:response'][0]['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { - 'd:privilege': [ - { 'd:write-properties': {} }, - { 'd:write': {} }, - { 'd:write-content': {} }, - { 'd:unlock': {} }, - { 'd:bind': {} }, - { 'd:unbind': {} }, - { 'd:write-acl': {} }, - { 'd:read': {} }, - { 'd:read-acl': {} }, - { 'd:read-current-user-privilege-set': {} }, - ], - }; - } - - for (const calendar of calendars) { - const parsedCalendar: DavResponse = { - 'd:href': `/dav/calendars/${calendar.id}`, - 'd:propstat': [{ - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - 'cal:calendar': {}, - }, - 'd:displayname': calendar.name, - 'd:getetag': escapeHtml(`"${calendar.revision}"`), - 'cs:getctag': calendar.revision, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { 'd:principal-URL': {} }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }; - - if (includePrivileges) { - parsedCalendar['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { - 'd:privilege': [ - { 'd:write-properties': {} }, - { 'd:write': {} }, - { 'd:write-content': {} }, - { 'd:unlock': {} }, - { 'd:bind': {} }, - { 'd:unbind': {} }, - { 'd:write-acl': {} }, - { 'd:read': {} }, - { 'd:read-acl': {} }, - { 'd:read-current-user-privilege-set': {} }, - ], - }; - } - - if (includeColor) { - parsedCalendar['d:propstat'][0]['d:prop']['ic:calendar-color'] = getColorAsHex(calendar.color); - } - - responseBody['d:multistatus']['d:response'].push(parsedCalendar); - } - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/calendars/[calendarId].tsx b/routes/dav/calendars/[calendarId].tsx deleted file mode 100644 index 3d8185f..0000000 --- a/routes/dav/calendars/[calendarId].tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { Handler } from 'fresh/server.ts'; -import { parse } from 'xml/mod.ts'; - -import { Calendar, FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; -import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; -import { - createCalendar, - createCalendarEvent, - getCalendar, - getCalendarEvent, - getCalendarEvents, - updateCalendarEvent, -} from '/lib/data/calendar.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype'?: { - 'd:collection'?: {}; - 'cal:calendar'?: {}; - }; - 'd:displayname'?: string | {}; - 'cal:calendar-data'?: string; - 'd:getlastmodified'?: string; - 'd:getetag'?: string; - 'cs:getctag'?: string; - 'd:getcontenttype'?: string; - 'd:getcontentlength'?: {}; - 'd:creationdate'?: string; - }; - 'd:status': string; - }[]; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:cal': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - 'xmlns:cs': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if ( - request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' && - request.method !== 'PUT' && request.method !== 'MKCALENDAR' - ) { - return new Response('Bad Request', { status: 400 }); - } - - const { calendarId } = context.params; - - let calendar: Calendar | null = null; - - try { - calendar = await getCalendar(calendarId, context.state.user.id); - } catch (error) { - console.error(error); - } - - if (!calendar && request.method === 'MKCALENDAR') { - const requestBody = await request.clone().text(); - - try { - const parsedDocument = parse(requestBody); - - const makeCalendarRequest = (parsedDocument['c:mkcalendar'] || parsedDocument['cal:mkcalendar']) as - | Record - | undefined; - - const name: string = makeCalendarRequest!['d:set']['d:prop']['d:displayname']!; - - const calendar = await createCalendar(context.state.user.id, name); - - return new Response('Created', { status: 201, headers: { 'etag': `"${calendar.revision}"` } }); - } catch (error) { - console.error(`Failed to parse XML`, error); - } - } - - if (!calendar) { - return new Response('Not found', { status: 404 }); - } - - if (request.method === 'PUT') { - const requestBody = await request.clone().text(); - - const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody); - - if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) { - const newCalendarEvent = await createCalendarEvent( - context.state.user.id, - calendarId, - partialCalendarEvent.title, - new Date(partialCalendarEvent.start_date), - new Date(partialCalendarEvent.end_date), - partialCalendarEvent.is_all_day, - ); - - const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {}); - - if (parsedExtra !== '{}') { - newCalendarEvent.extra = partialCalendarEvent.extra!; - - if ( - newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 && - !newCalendarEvent.extra.recurring_id - ) { - newCalendarEvent.extra.recurring_id = newCalendarEvent.id; - } - - await updateCalendarEvent(newCalendarEvent); - } - - const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id); - - return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } }); - } - - return new Response('Not found', { status: 404 }); - } - - const calendarEvents = await getCalendarEvents(context.state.user.id, [calendar.id]); - - if (request.method === 'GET') { - const response = new Response(formatCalendarEventsToVCalendar(calendarEvents, [calendar]), { - status: 200, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); - } - - const requestBody = (await request.clone().text()).toLowerCase(); - - const includeVCalendar = requestBody.includes('calendar-data'); - - const parsedCalendar: DavResponse = { - 'd:href': `/dav/calendars/${calendar.id}`, - 'd:propstat': [{ - 'd:prop': { - 'd:displayname': calendar.name, - 'd:getlastmodified': buildRFC822Date(calendar.updated_at.toISOString()), - 'd:getetag': escapeHtml(`"${calendar.revision}"`), - 'cs:getctag': calendar.revision, - 'd:getcontenttype': 'text/calendar; charset=utf-8', - 'd:resourcetype': {}, - }, - 'd:status': 'HTTP/1.1 200 OK', - }], - }; - - const parsedCalendarEvents = calendarEvents.map((calendarEvent) => { - const parsedCalendarEvent: DavResponse = { - 'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`, - 'd:propstat': [{ - 'd:prop': { - 'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()), - 'd:getetag': escapeHtml(`"${calendarEvent.revision}"`), - 'cs:getctag': calendarEvent.revision, - 'd:getcontenttype': 'text/calendar; charset=utf-8', - 'd:resourcetype': {}, - 'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()), - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { - 'd:displayname': {}, - 'd:getcontentlength': {}, - }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }; - - if (includeVCalendar) { - parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml( - formatCalendarEventsToVCalendar([calendarEvent], [calendar!]), - ); - } - - return parsedCalendarEvent; - }); - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [parsedCalendar, ...parsedCalendarEvents], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:cal': 'urn:ietf:params:xml:ns:caldav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - 'xmlns:cs': 'http://calendarserver.org/ns/', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx b/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx deleted file mode 100644 index 1026c4d..0000000 --- a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; -import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; -import { - createCalendarEvent, - deleteCalendarEvent, - getCalendar, - getCalendarEvent, - updateCalendarEvent, -} from '/lib/data/calendar.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype'?: { - 'd:collection'?: {}; - 'cal:calendar'?: {}; - }; - 'd:displayname'?: string | {}; - 'cal:calendar-data'?: string; - 'd:getlastmodified'?: string; - 'd:getetag'?: string; - 'cs:getctag'?: string; - 'd:getcontenttype'?: string; - 'd:getcontentlength'?: {}; - 'd:creationdate'?: string; - }; - 'd:status': string; - }[]; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:cal': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - 'xmlns:cs': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if ( - request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' && - request.method !== 'PUT' && request.method !== 'DELETE' - ) { - return new Response('Bad Request', { status: 400 }); - } - - const { calendarId, calendarEventId } = context.params; - - let calendar: Calendar | null = null; - let calendarEvent: CalendarEvent | null = null; - - try { - calendar = await getCalendar(calendarId, context.state.user.id); - } catch (error) { - console.error(error); - } - - if (!calendar) { - return new Response('Not found', { status: 404 }); - } - - try { - calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); - } catch (error) { - console.error(error); - } - - if (!calendarEvent) { - if (request.method === 'PUT') { - const requestBody = await request.clone().text(); - - const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody); - - if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) { - const newCalendarEvent = await createCalendarEvent( - context.state.user.id, - calendarId, - partialCalendarEvent.title, - new Date(partialCalendarEvent.start_date), - new Date(partialCalendarEvent.end_date), - partialCalendarEvent.is_all_day, - ); - - // Use the sent id for the UID - if (!partialCalendarEvent.extra?.uid) { - partialCalendarEvent.extra = { - ...(partialCalendarEvent.extra! || {}), - uid: calendarEventId, - }; - } - - const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {}); - - if (parsedExtra !== '{}') { - newCalendarEvent.extra = partialCalendarEvent.extra!; - - if ( - newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 && - !newCalendarEvent.extra.recurring_id - ) { - newCalendarEvent.extra.recurring_id = newCalendarEvent.id; - } - - await updateCalendarEvent(newCalendarEvent); - } - - const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id); - - return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } }); - } - } - - return new Response('Not found', { status: 404 }); - } - - if (request.method === 'DELETE') { - const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); - - // Don't update outdated data - if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) { - return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } }); - } - - await deleteCalendarEvent(calendarEventId, calendarId, context.state.user.id); - - return new Response(null, { status: 202 }); - } - - if (request.method === 'PUT') { - const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); - - // Don't update outdated data - if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) { - return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } }); - } - - const requestBody = await request.clone().text(); - - const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody); - - calendarEvent = { - ...calendarEvent, - ...partialCalendarEvent, - }; - - await updateCalendarEvent(calendarEvent); - - calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); - - return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } }); - } - - if (request.method === 'GET') { - // Set a UID if there isn't one - if (!calendarEvent.extra.uid) { - calendarEvent.extra.uid = crypto.randomUUID(); - await updateCalendarEvent(calendarEvent); - - calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); - } - - const response = new Response(formatCalendarEventsToVCalendar([calendarEvent], [calendar]), { - status: 200, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); - } - - const requestBody = (await request.clone().text()).toLowerCase(); - - const includeVCalendar = requestBody.includes('calendar-data'); - - const parsedCalendarEvent: DavResponse = { - 'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`, - 'd:propstat': [{ - 'd:prop': { - 'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()), - 'd:getetag': escapeHtml(`"${calendarEvent.revision}"`), - 'cs:getctag': calendarEvent.revision, - 'd:getcontenttype': 'text/calendar; charset=utf-8', - 'd:resourcetype': {}, - 'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()), - }, - 'd:status': 'HTTP/1.1 200 OK', - }, { - 'd:prop': { - 'd:displayname': {}, - 'd:getcontentlength': {}, - }, - 'd:status': 'HTTP/1.1 404 Not Found', - }], - }; - - if (includeVCalendar) { - parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml( - formatCalendarEventsToVCalendar([calendarEvent], [calendar!]), - ); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [parsedCalendarEvent], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:cal': 'urn:ietf:params:xml:ns:caldav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - 'xmlns:cs': 'http://calendarserver.org/ns/', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/files.tsx b/routes/dav/files.tsx deleted file mode 100644 index c97c47e..0000000 --- a/routes/dav/files.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection'?: {}; - }; - 'd:getlastmodified': string; - 'd:getetag': string; - 'd:getcontentlength'?: number; - 'd:getcontenttype'?: string; - }; - 'd:status': string; - }; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND') { - return new Response('Bad Request', { status: 400 }); - } - - // TODO: List directories and files in root - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [ - { - 'd:href': '/dav/files/', - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - 'd:getlastmodified': buildRFC822Date('2020-01-01'), - 'd:getetag': escapeHtml(`"fake"`), - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }, - ], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/index.tsx b/routes/dav/index.tsx deleted file mode 100644 index 6a318d1..0000000 --- a/routes/dav/index.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}; - }; - 'd:current-user-principal'?: { - 'd:href': string; - }; - 'd:getlastmodified'?: string; - 'd:getetag'?: string; - }; - 'd:status': string; - }; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { - return new Response('Bad Request', { status: 400 }); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [ - { - 'd:href': '/dav/', - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }, - { - 'd:href': '/dav/addressbooks/', - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }, - { - 'd:href': '/dav/files/', - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - }, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - 'd:getlastmodified': buildRFC822Date('2020-01-01'), - 'd:getetag': escapeHtml(`"fake"`), - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }, - ], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - }, - }; - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/dav/principals.tsx b/routes/dav/principals.tsx deleted file mode 100644 index 50f0a0b..0000000 --- a/routes/dav/principals.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Handler } from 'fresh/server.ts'; - -import { FreshContextState } from '/lib/types.ts'; -import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils/misc.ts'; -import { createSessionCookie } from '/lib/auth.ts'; - -interface Data {} - -interface DavResponse { - 'd:href': string; - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection'?: {}; - 'd:principal': {}; - }; - 'd:displayname'?: string; - 'card:addressbook-home-set'?: { - 'd:href': string; - }; - 'cal:calendar-home-set'?: { - 'd:href': string; - }; - 'd:current-user-principal'?: { - 'd:href': string; - }; - 'd:principal-URL'?: { - 'd:href': string; - }; - }; - 'd:status': string; - }; -} - -interface DavMultiStatusResponse { - 'd:multistatus': { - 'd:response': DavResponse[]; - }; - 'd:multistatus_attributes': { - 'xmlns:d': string; - 'xmlns:s': string; - 'xmlns:cal': string; - 'xmlns:cs': string; - 'xmlns:card': string; - 'xmlns:oc': string; - 'xmlns:nc': string; - }; -} - -interface ResponseBody extends DavMultiStatusResponse {} - -export const handler: Handler = async (request, context) => { - if (!context.state.user) { - return new Response('Unauthorized', { - status: 401, - headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, - }); - } - - if (request.method === 'GET') { - return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); - } - - if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { - return new Response('Bad Request', { status: 400 }); - } - - const responseBody: ResponseBody = { - 'd:multistatus': { - 'd:response': [], - }, - 'd:multistatus_attributes': { - 'xmlns:d': 'DAV:', - 'xmlns:s': 'http://sabredav.org/ns', - 'xmlns:cal': 'urn:ietf:params:xml:ns:caldav', - 'xmlns:cs': 'http://calendarserver.org/ns/', - 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', - 'xmlns:oc': 'http://owncloud.org/ns', - 'xmlns:nc': 'http://nextcloud.org/ns', - }, - }; - - if (request.method === 'PROPFIND') { - const propResponse: DavResponse = { - 'd:href': '/dav/principals/', - 'd:propstat': { - 'd:prop': { - 'd:resourcetype': { - 'd:collection': {}, - 'd:principal': {}, - }, - 'd:current-user-principal': { - 'd:href': '/dav/principals/', - }, - 'd:principal-URL': { - 'd:href': '/dav/principals/', - }, - }, - 'd:status': 'HTTP/1.1 200 OK', - }, - }; - - const requestBody = (await request.clone().text()).toLowerCase(); - - if (requestBody.includes('displayname')) { - propResponse['d:propstat']['d:prop']['d:displayname'] = `${context.state.user.email}`; - } - - if (requestBody.includes('addressbook-home-set')) { - propResponse['d:propstat']['d:prop']['card:addressbook-home-set'] = { - 'd:href': `/dav/addressbooks/`, - }; - } - - if (requestBody.includes('calendar-home-set')) { - propResponse['d:propstat']['d:prop']['cal:calendar-home-set'] = { - 'd:href': `/dav/calendars/`, - }; - } - - responseBody['d:multistatus']['d:response'].push(propResponse); - } - - const response = new Response(convertObjectToDavXml(responseBody, true), { - headers: { - 'content-type': 'application/xml; charset=utf-8', - 'dav': DAV_RESPONSE_HEADER, - }, - status: 207, - }); - - if (context.state.session) { - return response; - } - - return createSessionCookie(request, context.state.user, response, true); -}; diff --git a/routes/files.tsx b/routes/files.tsx new file mode 100644 index 0000000..60aaabd --- /dev/null +++ b/routes/files.tsx @@ -0,0 +1,51 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { getDirectories, getFiles } from '/lib/data/files.ts'; +import FilesWrapper from '/islands/files/FilesWrapper.tsx'; + +interface Data { + userDirectories: Directory[]; + userFiles: DirectoryFile[]; + currentPath: string; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const searchParams = new URL(request.url).searchParams; + + let currentPath = searchParams.get('path') || '/'; + + // Send invalid paths back to root + if (!currentPath.startsWith('/') || currentPath.includes('../')) { + currentPath = '/'; + } + + // Always append a trailing slash + if (!currentPath.endsWith('/')) { + currentPath = `${currentPath}/`; + } + + const userDirectories = await getDirectories(context.state.user.id, currentPath); + + const userFiles = await getFiles(context.state.user.id, currentPath); + + return await context.render({ userDirectories, userFiles, currentPath }); + }, +}; + +export default function FilesPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/files/open/[fileName].tsx b/routes/files/open/[fileName].tsx new file mode 100644 index 0000000..09da8f1 --- /dev/null +++ b/routes/files/open/[fileName].tsx @@ -0,0 +1,47 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getFile } from '/lib/data/files.ts'; + +interface Data {} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { fileName } = context.params; + + if (!fileName) { + return new Response('Not Found', { status: 404 }); + } + + const searchParams = new URL(request.url).searchParams; + + let currentPath = searchParams.get('path') || '/'; + + // Send invalid paths back to root + if (!currentPath.startsWith('/') || currentPath.includes('../')) { + currentPath = '/'; + } + + // Always append a trailing slash + if (!currentPath.endsWith('/')) { + currentPath = `${currentPath}/`; + } + + // TODO: Verify user has read or write access to path/file and get the appropriate ownerUserId + + const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName)); + + if (!fileResult.success) { + return new Response('Not Found', { status: 404 }); + } + + return new Response(fileResult.contents!, { + status: 200, + headers: { 'cache-control': 'no-cache, no-store, must-revalidate', 'content-type': fileResult.contentType! }, + }); + }, +}; diff --git a/routes/remote.php/[davRoute].tsx b/routes/remote.php/[davRoute].tsx deleted file mode 100644 index e479c7a..0000000 --- a/routes/remote.php/[davRoute].tsx +++ /dev/null @@ -1,7 +0,0 @@ -// Nextcloud/ownCloud mimicry -export function handler(): Response { - return new Response('Redirecting...', { - status: 307, - headers: { Location: '/dav' }, - }); -} diff --git a/static/images/directory.svg b/static/images/directory.svg new file mode 100644 index 0000000..227cec1 --- /dev/null +++ b/static/images/directory.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/file.svg b/static/images/file.svg new file mode 100644 index 0000000..e69ec3c --- /dev/null +++ b/static/images/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/move.svg b/static/images/move.svg new file mode 100644 index 0000000..90a4885 --- /dev/null +++ b/static/images/move.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/rename.svg b/static/images/rename.svg new file mode 100644 index 0000000..62dad64 --- /dev/null +++ b/static/images/rename.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/share.svg b/static/images/share.svg new file mode 100644 index 0000000..157f6db --- /dev/null +++ b/static/images/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/trash.svg b/static/images/trash.svg new file mode 100644 index 0000000..c498024 --- /dev/null +++ b/static/images/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css index 1a86199..a85dfd8 100644 --- a/static/styles.css +++ b/static/styles.css @@ -73,8 +73,3 @@ img.red { img.gray { filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%); } - -.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; -}