From 321341a2fb541718d952e80d1f3344843bcc15b4 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sun, 31 Mar 2024 13:19:13 +0100 Subject: [PATCH] Support automatically creating recurring events Also implement locks for tasks that can have unintended side-effects if ran simultaneously. Other minor UI tweaks. --- components/calendar/ViewEventModal.tsx | 18 +-- crons/news.ts | 7 ++ import_map.json | 1 + lib/data/calendar.ts | 165 ++++++++++++++++++++++++- lib/interfaces/locker.ts | 36 ++++++ lib/utils/calendar.ts | 57 +++++---- lib/utils/contacts.ts | 71 ++++------- routes/api/calendar/import.tsx | 7 ++ routes/calendar.tsx | 2 +- routes/dav/calendars/[calendarId].tsx | 53 ++++---- 10 files changed, 312 insertions(+), 105 deletions(-) create mode 100644 lib/interfaces/locker.ts diff --git a/components/calendar/ViewEventModal.tsx b/components/calendar/ViewEventModal.tsx index be6e96c..ee56602 100644 --- a/components/calendar/ViewEventModal.tsx +++ b/components/calendar/ViewEventModal.tsx @@ -52,13 +52,6 @@ export default function ViewEventModal( title={calendar.color} /> - {calendarEvent.extra.recurring_rrule - ? ( -
-

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

-
- ) - : null} {calendarEvent.extra.description ? (
@@ -104,11 +97,18 @@ export default function ViewEventModal(
) : 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}.

diff --git a/crons/news.ts b/crons/news.ts index 3be18b4..3cb080b 100644 --- a/crons/news.ts +++ b/crons/news.ts @@ -1,4 +1,5 @@ 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'; @@ -8,6 +9,10 @@ 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`, @@ -22,4 +27,6 @@ export async function fetchNewArticles(forceFetch = false) { } catch (error) { console.log(error); } + + lock.release(); } diff --git a/import_map.json b/import_map.json index d13da9a..b6659a9 100644 --- a/import_map.json +++ b/import_map.json @@ -3,6 +3,7 @@ "/": "./", "./": "./", "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/lib/data/calendar.ts b/lib/data/calendar.ts index 203d940..a6605a4 100644 --- a/lib/data/calendar.ts +++ b/lib/data/calendar.ts @@ -1,7 +1,10 @@ +import { RRuleSet } from 'rrule-rust'; + import Database, { sql } from '/lib/interfaces/database.ts'; -import { Calendar, CalendarEvent } from '/lib/types.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 } from '/lib/utils/calendar.ts'; +import { CALENDAR_COLOR_OPTIONS, getVCalendarDate } from '/lib/utils/calendar.ts'; import { getUserById } from './user.ts'; const db = new Database(); @@ -33,8 +36,129 @@ export async function getCalendarEvents( 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`, + 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, @@ -43,8 +167,6 @@ export async function getCalendarEvents( ], ); - // TODO: Fetch initial recurring events and calculate any necessary to create/show for the date range - return calendarEvents; } } @@ -183,9 +305,18 @@ export async function createCalendarEvent( 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(); @@ -240,6 +371,30 @@ export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalen 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, diff --git a/lib/interfaces/locker.ts b/lib/interfaces/locker.ts new file mode 100644 index 0000000..2e7fd0c --- /dev/null +++ b/lib/interfaces/locker.ts @@ -0,0 +1,36 @@ +// This is a simple, in-memory lock, which works well enough if you're serving from a single source/server + +function wait(milliseconds = 100) { + return new Promise((resolve) => setTimeout(() => resolve(true), milliseconds)); +} + +const expirationInMilliseconds = 15 * 1000; + +const locks: Map = new Map(); + +export default class Locker { + protected lockName: string = ''; + + constructor(lockName: string) { + this.lockName = lockName; + } + + public async acquire() { + const currentLock = locks.get(this.lockName); + + while (currentLock) { + // Only wait if the lock hasn't expired + if (currentLock.expiresAt > new Date()) { + await wait(); + } + } + + locks.set(this.lockName, { + expiresAt: new Date(new Date().setMilliseconds(new Date().getMilliseconds() + expirationInMilliseconds)), + }); + } + + public release() { + locks.delete(this.lockName); + } +} diff --git a/lib/utils/calendar.ts b/lib/utils/calendar.ts index 870e818..fd20b1a 100644 --- a/lib/utils/calendar.ts +++ b/lib/utils/calendar.ts @@ -65,6 +65,18 @@ function getAttendeeStatusFromVCalendar( 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('\\,', ','); +} + // TODO: Build this export function formatCalendarEventsToVCalendar( calendarEvents: CalendarEvent[], @@ -72,23 +84,25 @@ export function formatCalendarEventsToVCalendar( ): string { const vCalendarText = calendarEvents.map((calendarEvent) => `BEGIN:VEVENT -DTSTAMP:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -DTSTART:${new Date(calendarEvent.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -DTEND:${new Date(calendarEvent.end_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} +DTSTAMP:${getVCalendarDate(calendarEvent.created_at)} +DTSTART:${getVCalendarDate(calendarEvent.start_date)} +DTEND:${getVCalendarDate(calendarEvent.end_date)} ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email} -SUMMARY:${calendarEvent.title.replaceAll('\n', '\\n').replaceAll(',', '\\,')} +SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)} TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()} ${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''} -${calendarEvent.extra.recurring_rrule ? `RRULE:${calendarEvent.extra.recurring_rrule}` : ''} -SEQUENCE:${calendarEvent.extra.recurring_sequence || 0} -CREATED:${new Date(calendarEvent.created_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '')} -LAST-MODIFIED:${ - new Date(calendarEvent.updated_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '') +${ + 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=${ - attendee.name?.replaceAll('\n', '\\n').replaceAll(',', '\\,') || '' + getSafelyEscapedTextForVCalendar(attendee.name || '') }:mailto:${attendee.email}` ).join('\n') || '' } @@ -96,18 +110,10 @@ ${ calendarEvent.extra.reminders?.map((reminder) => `BEGIN:VALARM ACTION:${reminder.type.toUpperCase()} -${reminder.description ? `DESCRIPTION:${reminder.description.replaceAll('\n', '\\n').replaceAll(',', '\\,')}` : ''} -TRIGGER;VALUE=DATE-TIME:${ - new Date(reminder.start_date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '') - } +${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''} +TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)} ${reminder.uid ? `UID:${reminder.uid}` : ''} -${ - reminder.acknowledged_at - ? `ACKNOWLEDGED:${ - new Date(reminder.acknowledged_at).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '') - }` - : '' - } +${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''} END:VALARM` ).join('\n') || '' } @@ -222,7 +228,7 @@ export function parseVCalendarFromTextContents(text: string): Partial `BEGIN:VCARD @@ -10,10 +18,10 @@ 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:${contact.extra.organization.replaceAll(',', '\\,')}` : ''} -${contact.extra.role ? `TITLE:${contact.extra.role}` : ''} +${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:${contact.extra.nickname}` : ''} +${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) => @@ -22,13 +30,11 @@ ${ } ${ contact.extra.addresses?.map((address) => - `ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - };${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - };${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ - (address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') - }` + `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') || '' } ${ @@ -41,11 +47,7 @@ ${ contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`) .join('\n') || '' } -${ - contact.extra.notes - ? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}` - : '' - } +${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''} ${contact.extra.uid ? `UID:${contact.extra.uid}` : ''} END:VCARD` ).join('\n'); @@ -191,7 +193,7 @@ export function parseVCardFromTextContents(text: string): Partial[] { } if (line.startsWith('NOTE:')) { - const notes = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ','); + const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || ''); partialContact.extra = { ...(partialContact.extra || {}), @@ -204,35 +206,16 @@ export function parseVCardFromTextContents(text: string): Partial[] { if (line.includes('ADR;')) { const addressInfo = line.split('ADR;')[1] || ''; const addressParts = (addressInfo.split(':')[1] || '').split(';'); - const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); - const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll( - '\\,', - ',', - ); + 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 = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll( - '\\n', - '\n', - ).replaceAll('\\,', ','); + const label = getSafelyUnescapedTextFromVCard( + ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''), + ); if (!country && !postalCode && !state && !city && !line2 && !line1) { continue; diff --git a/routes/api/calendar/import.tsx b/routes/api/calendar/import.tsx index 4ca9d17..35a1fd4 100644 --- a/routes/api/calendar/import.tsx +++ b/routes/api/calendar/import.tsx @@ -61,6 +61,13 @@ export const handler: Handlers = { 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); } } diff --git a/routes/calendar.tsx b/routes/calendar.tsx index 1eb49bb..0984f21 100644 --- a/routes/calendar.tsx +++ b/routes/calendar.tsx @@ -38,7 +38,7 @@ export const handler: Handlers = { dateRange.end.setDate(dateRange.end.getDate() + 31); } - const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds); + const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds, dateRange); return await context.render({ userCalendars, userCalendarEvents, view, startDate }); }, diff --git a/routes/dav/calendars/[calendarId].tsx b/routes/dav/calendars/[calendarId].tsx index 7248dd3..0fe14d0 100644 --- a/routes/dav/calendars/[calendarId].tsx +++ b/routes/dav/calendars/[calendarId].tsx @@ -3,7 +3,13 @@ import { Handler } from 'fresh/server.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 { getCalendar, getCalendarEvents } from '/lib/data/calendar.ts'; +import { + createCalendarEvent, + getCalendar, + getCalendarEvent, + getCalendarEvents, + updateCalendarEvent, +} from '/lib/data/calendar.ts'; import { createSessionCookie } from '/lib/auth.ts'; interface Data {} @@ -80,32 +86,37 @@ export const handler: Handler = async (request, context const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody); - if (partialCalendarEvent.title) { - // TODO: Build this - // const newCalendarEvent = await createCalendarEvent( - // context.state.user.id, - // partialCalendarEvent.title, - // partialCalendarEvent.start_date, - // partialCalendarEvent.end_date, - // ); + 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: calendarId, - // }; - // } + const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {}); - // newCalendarEvent.extra = partialCalendarEvent.extra!; + if (parsedExtra !== '{}') { + newCalendarEvent.extra = partialCalendarEvent.extra!; - // await updateCalendarEvent(newCalendarEvent); + if ( + newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 && + !newCalendarEvent.extra.recurring_id + ) { + newCalendarEvent.extra.recurring_id = newCalendarEvent.id; + } - // const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id); + await updateCalendarEvent(newCalendarEvent); + } - // return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } }); - return new Response('Not found', { status: 404 }); + 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]);