import { createDAVClient } from 'tsdav'; import { AppConfig } from '/lib/config.ts'; import { getColorAsHex, parseVCalendar } from '/lib/utils/calendar.ts'; import { concurrentPromises } from '/lib/utils/misc.ts'; import { UserModel } from '/lib/models/user.ts'; interface DAVObject extends Record { data?: string; displayName?: string; ctag?: string; url: string; uid?: string; } export interface Calendar extends DAVObject { calendarColor?: string; isVisible: boolean; } export interface CalendarEvent extends DAVObject { calendarId: string; startDate: Date; endDate: Date; title: string; isAllDay: boolean; organizerEmail: string; attendees?: CalendarEventAttendee[]; reminders?: CalendarEventReminder[]; transparency: 'opaque' | 'transparent'; description?: string; location?: string; eventUrl?: string; sequence?: number; isRecurring?: boolean; recurringRrule?: string; recurrenceId?: string; recurrenceMasterUid?: string; } export interface CalendarEventAttendee { email: string; status: 'accepted' | 'rejected' | 'invited'; name?: string; } export interface CalendarEventReminder { uid?: string; startDate: string; type: 'email' | 'sound' | 'display'; acknowledgedAt?: string; description?: string; } const calendarConfig = await AppConfig.getCalendarConfig(); async function getClient(userId: string) { const client = await createDAVClient({ serverUrl: calendarConfig.calDavUrl, credentials: {}, authMethod: 'Custom', // deno-lint-ignore require-await authFunction: async () => { return { 'X-Remote-User': userId, }; }, fetchOptions: { timeout: 15_000, }, defaultAccountType: 'caldav', rootUrl: `${calendarConfig.calDavUrl}/`, principalUrl: `${calendarConfig.calDavUrl}/${userId}/`, homeUrl: `${calendarConfig.calDavUrl}/${userId}/`, }); return client; } export class CalendarModel { static async list( userId: string, ): Promise { const client = await getClient(userId); const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/`; const davCalendars: DAVObject[] = await client.fetchCalendars({ calendar: { url: calendarUrl, }, }); const user = await UserModel.getById(userId); const calendars: Calendar[] = davCalendars.map((davCalendar) => { const uid = davCalendar.url.split('/').filter(Boolean).pop()!; return { ...davCalendar, displayName: decodeURIComponent(davCalendar.displayName || '(empty)'), calendarColor: decodeURIComponent(davCalendar.calendarColor || getColorAsHex('bg-gray-700')), isVisible: !user.extra.hidden_calendar_ids?.includes(uid), uid, }; }); return calendars; } static async get( userId: string, calendarId: string, ): Promise { const calendars = await this.list(userId); return calendars.find((calendar) => calendar.uid === calendarId); } static async create( userId: string, name: string, color: string, ): Promise { const calendarId = crypto.randomUUID(); const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`; const client = await getClient(userId); await client.makeCalendar({ url: calendarUrl, props: { displayname: name, calendarColor: color, }, }); } static async update( userId: string, calendarUrl: string, displayName: string, color?: string, ): Promise { // Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because tsdav doesn't have PROPPATCH const xmlBody = ` ${encodeURIComponent(displayName)} ${color ? `${encodeURIComponent(color)}` : ''} `; await fetch(calendarUrl, { method: 'PROPPATCH', headers: { 'Content-Type': 'application/xml; charset=utf-8', 'X-Remote-User': userId, }, body: xmlBody, }); } static async delete( userId: string, calendarId: string, ): Promise { const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`; const client = await getClient(userId); await client.deleteObject({ url: calendarUrl, }); } } export class CalendarEventModel { private static async fetchByCalendarId( userId: string, calendarId: string, dateRange?: { start: Date; end: Date }, ): Promise { const client = await getClient(userId); const fetchOptions: { calendar: { url: string }; timeRange?: { start: string; end: string }; expand?: boolean } = { calendar: { url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`, }, }; if (dateRange) { fetchOptions.timeRange = { start: dateRange.start.toISOString(), end: dateRange.end.toISOString(), }; fetchOptions.expand = true; } const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions); const calendarEvents: CalendarEvent[] = []; for (const davCalendarEvent of davCalendarEvents) { let uid = davCalendarEvent.url.split('/').filter(Boolean).pop()!; const parsedEvents = parseVCalendar(davCalendarEvent.data || ''); for (const parsedEvent of parsedEvents) { if (parsedEvent.uid) { uid = parsedEvent.uid; } calendarEvents.push({ ...davCalendarEvent, ...parsedEvent, uid, calendarId, }); } } return calendarEvents; } static async list( userId: string, calendarIds: string[], dateRange?: { start: Date; end: Date }, ): Promise { const allCalendarEvents: CalendarEvent[] = []; await concurrentPromises( calendarIds.map((calendarId) => async () => { const calendarEvents = await this.fetchByCalendarId(userId, calendarId, dateRange); allCalendarEvents.push(...calendarEvents); return calendarEvents; }), 5, ); return allCalendarEvents; } static async get( userId: string, calendarId: string, eventId: string, ): Promise { const client = await getClient(userId); const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects({ calendar: { url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`, }, objectUrls: [`${calendarConfig.calDavUrl}/${userId}/${calendarId}/${eventId}.ics`], }); if (davCalendarEvents.length === 0) { return undefined; } const davCalendarEvent = davCalendarEvents[0]; const calendarEvent: CalendarEvent = { ...davCalendarEvent, ...parseVCalendar(davCalendarEvent.data || '')[0], uid: eventId, calendarId, }; return calendarEvent; } static async create( userId: string, calendarId: string, eventId: string, vCalendar: string, ): Promise { const client = await getClient(userId); const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`; await client.createCalendarObject({ calendar: { url: calendarUrl, }, iCalString: vCalendar, filename: `${eventId}.ics`, }); } static async update( userId: string, eventUrl: string, ics: string, ): Promise { const client = await getClient(userId); await client.updateCalendarObject({ calendarObject: { url: eventUrl, data: ics, }, }); } static async delete( userId: string, eventUrl: string, ): Promise { const client = await getClient(userId); await client.deleteCalendarObject({ calendarObject: { url: eventUrl, }, }); } }