Add CalDav routes and methods, with mock data
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
// This file SHOULD be checked into source version control.
|
// This file SHOULD be checked into source version control.
|
||||||
// This file is automatically updated during development when running `dev.ts`.
|
// 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 $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
||||||
import * as $_404 from './routes/_404.tsx';
|
import * as $_404 from './routes/_404.tsx';
|
||||||
import * as $_app from './routes/_app.tsx';
|
import * as $_app from './routes/_app.tsx';
|
||||||
@@ -24,6 +25,9 @@ import * as $dashboard from './routes/dashboard.tsx';
|
|||||||
import * as $dav_addressbooks from './routes/dav/addressbooks.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 from './routes/dav/addressbooks/contacts.tsx';
|
||||||
import * as $dav_addressbooks_contacts_contactId_vcf from './routes/dav/addressbooks/contacts/[contactId].vcf.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_files from './routes/dav/files.tsx';
|
||||||
import * as $dav_index from './routes/dav/index.tsx';
|
import * as $dav_index from './routes/dav/index.tsx';
|
||||||
import * as $dav_principals from './routes/dav/principals.tsx';
|
import * as $dav_principals from './routes/dav/principals.tsx';
|
||||||
@@ -47,6 +51,7 @@ import { type Manifest } from '$fresh/server.ts';
|
|||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
routes: {
|
routes: {
|
||||||
|
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
|
||||||
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
||||||
'./routes/_404.tsx': $_404,
|
'./routes/_404.tsx': $_404,
|
||||||
'./routes/_app.tsx': $_app,
|
'./routes/_app.tsx': $_app,
|
||||||
@@ -69,6 +74,9 @@ const manifest = {
|
|||||||
'./routes/dav/addressbooks.tsx': $dav_addressbooks,
|
'./routes/dav/addressbooks.tsx': $dav_addressbooks,
|
||||||
'./routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts,
|
'./routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts,
|
||||||
'./routes/dav/addressbooks/contacts/[contactId].vcf.tsx': $dav_addressbooks_contacts_contactId_vcf,
|
'./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/files.tsx': $dav_files,
|
||||||
'./routes/dav/index.tsx': $dav_index,
|
'./routes/dav/index.tsx': $dav_index,
|
||||||
'./routes/dav/principals.tsx': $dav_principals,
|
'./routes/dav/principals.tsx': $dav_principals,
|
||||||
|
|||||||
@@ -757,13 +757,14 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
|
|||||||
|
|
||||||
const isFirstDay = dayIndex === 0;
|
const isFirstDay = dayIndex === 0;
|
||||||
const isLastDay = dayIndex === 6;
|
const isLastDay = dayIndex === 6;
|
||||||
|
const isToday = new Date(day.date).toISOString().substring(0, 10) === today;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
class={`flex justify-center bg-gray-900 py-2 ${isFirstDay ? 'rounded-tl-md' : ''} ${
|
class={`flex justify-center ${isToday ? 'bg-[#51A4FB]' : 'bg-gray-900'} py-2 ${
|
||||||
isLastDay ? 'rounded-tr-md' : ''
|
isFirstDay ? 'rounded-tl-md' : ''
|
||||||
} text-center text-xs font-semibold text-white`}
|
} ${isLastDay ? 'rounded-tr-md' : ''} text-center text-xs font-semibold text-white`}
|
||||||
>
|
>
|
||||||
<span>{weekDayFormat.format(day.date)}</span>
|
<span>{weekDayFormat.format(day.date)}</span>
|
||||||
</section>
|
</section>
|
||||||
@@ -1083,9 +1084,9 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
|
|||||||
<section
|
<section
|
||||||
class={`fixed ${
|
class={`fixed ${
|
||||||
openEvent.value ? 'block' : 'hidden'
|
openEvent.value ? 'block' : 'hidden'
|
||||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 space-y-5 drop-shadow-lg`}
|
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||||
>
|
>
|
||||||
<h1 class='text-2xl font-semibold'>{openEvent.value?.title || ''}</h1>
|
<h1 class='text-2xl font-semibold my-5'>{openEvent.value?.title || ''}</h1>
|
||||||
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between'>
|
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between'>
|
||||||
<span>
|
<span>
|
||||||
{openEvent.value?.start_date ? allDayEventDateFormat.format(new Date(openEvent.value.start_date)) : ''}
|
{openEvent.value?.start_date ? allDayEventDateFormat.format(new Date(openEvent.value.start_date)) : ''}
|
||||||
@@ -1097,15 +1098,40 @@ export default function MainCalendar({ initialCalendars, initialCalendarEvents,
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p>TODO: calendar, recurrence</p>
|
||||||
|
</section>
|
||||||
{openEvent.value?.extra.description
|
{openEvent.value?.extra.description
|
||||||
? (
|
? (
|
||||||
<section class='py-5 border-b border-slate-500'>
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
<p>{openEvent.value.extra.description}</p>
|
<p>{openEvent.value.extra.description}</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
<section class='py-5 border-b border-slate-500'>
|
{openEvent.value?.extra.url
|
||||||
<p>TODO: location, calendar, recurrence, reminders</p>
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a href={openEvent.value.extra.url} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{openEvent.value.extra.url}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{openEvent.value?.extra.location
|
||||||
|
? (
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/maps?q=${encodeURIComponent(openEvent.value.extra.location)}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{openEvent.value.extra.location}
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<section class='py-5 my-0 border-b border-slate-500'>
|
||||||
|
<p>TODO: reminders</p>
|
||||||
</section>
|
</section>
|
||||||
<footer class='flex justify-between'>
|
<footer class='flex justify-between'>
|
||||||
<button
|
<button
|
||||||
|
|||||||
250
lib/data/calendar.ts
Normal file
250
lib/data/calendar.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||||
|
|
||||||
|
// const db = new Database();
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
export async function getCalendars(userId: string): Promise<Calendar[]> {
|
||||||
|
// TODO: Remove this
|
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'family-1',
|
||||||
|
user_id: userId,
|
||||||
|
name: 'Family',
|
||||||
|
color: 'bg-purple-500',
|
||||||
|
is_visible: true,
|
||||||
|
revision: 'fake-rev',
|
||||||
|
extra: {
|
||||||
|
default_transparency: 'opaque',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal-1',
|
||||||
|
user_id: userId,
|
||||||
|
name: 'Personal',
|
||||||
|
color: 'bg-sky-600',
|
||||||
|
is_visible: true,
|
||||||
|
revision: 'fake-rev',
|
||||||
|
extra: {
|
||||||
|
default_transparency: 'opaque',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'house-chores-1',
|
||||||
|
user_id: userId,
|
||||||
|
name: 'House Chores',
|
||||||
|
color: 'bg-red-700',
|
||||||
|
is_visible: true,
|
||||||
|
revision: 'fake-rev',
|
||||||
|
extra: {
|
||||||
|
default_transparency: 'opaque',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
export async function getCalendarEvents(userId: string, calendarIds: string[]): Promise<CalendarEvent[]> {
|
||||||
|
// TODO: Remove this
|
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().substring(0, 10);
|
||||||
|
const tomorrow = new Date(new Date(now).setDate(now.getDate() + 1)).toISOString().substring(0, 10);
|
||||||
|
const twoDaysFromNow = new Date(new Date(now).setDate(now.getDate() + 2)).toISOString().substring(0, 10);
|
||||||
|
|
||||||
|
const calendarEvents = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
user_id: userId,
|
||||||
|
calendar_id: 'family-1',
|
||||||
|
revision: 'fake-rev',
|
||||||
|
title: 'Dentist',
|
||||||
|
start_date: new Date(`${today}T14:00:00.000Z`),
|
||||||
|
end_date: new Date(`${today}T15:00:00.000Z`),
|
||||||
|
is_all_day: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
extra: {
|
||||||
|
organizer_email: 'user@example.com',
|
||||||
|
transparency: 'default',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
user_id: userId,
|
||||||
|
calendar_id: 'family-1',
|
||||||
|
revision: 'fake-rev',
|
||||||
|
title: 'Dermatologist',
|
||||||
|
start_date: new Date(`${today}T16:30:00.000Z`),
|
||||||
|
end_date: new Date(`${today}T17:30:00.000Z`),
|
||||||
|
is_all_day: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
extra: {
|
||||||
|
organizer_email: 'user@example.com',
|
||||||
|
transparency: 'default',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-3',
|
||||||
|
user_id: userId,
|
||||||
|
calendar_id: 'house-chores-1',
|
||||||
|
revision: 'fake-rev',
|
||||||
|
title: 'Vacuum',
|
||||||
|
start_date: new Date(`${tomorrow}T15:00:00.000Z`),
|
||||||
|
end_date: new Date(`${tomorrow}T16:00:00.000Z`),
|
||||||
|
is_all_day: false,
|
||||||
|
status: 'scheduled',
|
||||||
|
extra: {
|
||||||
|
organizer_email: 'user@example.com',
|
||||||
|
transparency: 'default',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-4',
|
||||||
|
user_id: userId,
|
||||||
|
calendar_id: 'personal-1',
|
||||||
|
revision: 'fake-rev',
|
||||||
|
title: 'Schedule server updates',
|
||||||
|
start_date: new Date(`${twoDaysFromNow}T09:00:00.000Z`),
|
||||||
|
end_date: new Date(`${twoDaysFromNow}T21:00:00.000Z`),
|
||||||
|
is_all_day: true,
|
||||||
|
status: 'scheduled',
|
||||||
|
extra: {
|
||||||
|
organizer_email: 'user@example.com',
|
||||||
|
transparency: 'default',
|
||||||
|
},
|
||||||
|
updated_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return calendarEvents.filter((calendarEvent) => calendarIds.includes(calendarEvent.calendar_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
export async function getCalendarEvent(id: string, calendarId: string, userId: string): Promise<CalendarEvent> {
|
||||||
|
// TODO: Build this
|
||||||
|
// const calendarEvents = await db.query<CalendarEvent>(
|
||||||
|
// sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3 LIMIT 1`,
|
||||||
|
// [
|
||||||
|
// id,
|
||||||
|
// calendarId,
|
||||||
|
// userId,
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return calendarEvents[0];
|
||||||
|
const calendarEvents = await getCalendarEvents(userId, [calendarId]);
|
||||||
|
|
||||||
|
return calendarEvents.find((calendarEvent) => calendarEvent.id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCalendar(id: string, userId: string) {
|
||||||
|
// TODO: Build this
|
||||||
|
// const calendars = await db.query<Calendar>(
|
||||||
|
// sql`SELECT * FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||||
|
// [
|
||||||
|
// id,
|
||||||
|
// userId,
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// return calendars[0];
|
||||||
|
|
||||||
|
const calendars = await getCalendars(userId);
|
||||||
|
|
||||||
|
return calendars.find((calendar) => calendar.id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCalendar(userId: string, name: string, color?: string) {
|
||||||
|
const extra: Calendar['extra'] = {
|
||||||
|
default_transparency: 'opaque',
|
||||||
|
};
|
||||||
|
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
// const newCalendar = (await db.query<Calendar>(
|
||||||
|
// sql`INSERT INTO "bewcloud_calendars" (
|
||||||
|
// "user_id",
|
||||||
|
// "revision",
|
||||||
|
// "name",
|
||||||
|
// "color",
|
||||||
|
// "extra"
|
||||||
|
// ) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
// RETURNING *`,
|
||||||
|
// [
|
||||||
|
// userId,
|
||||||
|
// revision,
|
||||||
|
// name,
|
||||||
|
// color,
|
||||||
|
// JSON.stringify(extra),
|
||||||
|
// ],
|
||||||
|
// ))[0];
|
||||||
|
|
||||||
|
// TODO: Generate new, non-existing color
|
||||||
|
const newColor = color || 'bg-green-600';
|
||||||
|
|
||||||
|
const calendars = await getCalendars(userId);
|
||||||
|
const newCalendar = { ...calendars[0], id: crypto.randomUUID(), revision, extra, name, color: newColor };
|
||||||
|
|
||||||
|
return newCalendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCalendar(calendar: Calendar) {
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
// await db.query(
|
||||||
|
// sql`UPDATE "bewcloud_calendars" SET
|
||||||
|
// "revision" = $3,
|
||||||
|
// "name" = $4,
|
||||||
|
// "color" = $5,
|
||||||
|
// "extra" = $6,
|
||||||
|
// "updated_at" = now()
|
||||||
|
// WHERE "id" = $1 AND "revision" = $2`,
|
||||||
|
// [
|
||||||
|
// calendar.id,
|
||||||
|
// calendar.revision,
|
||||||
|
// revision,
|
||||||
|
// calendar.name,
|
||||||
|
// calendar.color,
|
||||||
|
// JSON.stringify(calendar.extra),
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
calendar.revision = revision;
|
||||||
|
|
||||||
|
// TODO: Remove this
|
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCalendar(_id: string, _userId: string) {
|
||||||
|
// TODO: Build this
|
||||||
|
// await db.query(
|
||||||
|
// sql`DELETE FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2`,
|
||||||
|
// [
|
||||||
|
// id,
|
||||||
|
// userId,
|
||||||
|
// ],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// TODO: Remove this
|
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: When creating, updating, or deleting events, also update the calendar's revision
|
||||||
23
lib/types.ts
23
lib/types.ts
@@ -141,7 +141,8 @@ export interface Calendar {
|
|||||||
extra: {
|
extra: {
|
||||||
shared_read_user_ids?: string[];
|
shared_read_user_ids?: string[];
|
||||||
shared_write_user_ids?: string[];
|
shared_write_user_ids?: string[];
|
||||||
default_visibility: 'private';
|
default_transparency: 'opaque' | 'transparent';
|
||||||
|
calendar_timezone?: string;
|
||||||
};
|
};
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -159,12 +160,23 @@ export interface CalendarEvent {
|
|||||||
is_all_day: boolean;
|
is_all_day: boolean;
|
||||||
status: 'scheduled' | 'pending' | 'canceled';
|
status: 'scheduled' | 'pending' | 'canceled';
|
||||||
extra: {
|
extra: {
|
||||||
|
organizer_email: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
url?: string;
|
||||||
attendees?: CalendarEventAttendee[];
|
attendees?: CalendarEventAttendee[];
|
||||||
visibility: 'default' | 'public' | 'private';
|
transparency: 'default' | Calendar['extra']['default_transparency'];
|
||||||
is_recurring?: boolean;
|
is_recurring?: boolean;
|
||||||
|
recurring_id?: string;
|
||||||
|
recurring_sequence?: number;
|
||||||
recurring_rrule?: string;
|
recurring_rrule?: string;
|
||||||
|
recurring_rdate?: string;
|
||||||
|
recurring_exdate?: string;
|
||||||
|
is_task?: boolean;
|
||||||
|
task_due_date?: string;
|
||||||
|
task_completed_at?: string;
|
||||||
|
uid?: string;
|
||||||
|
reminders?: CalendarEventReminder[];
|
||||||
};
|
};
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -175,3 +187,10 @@ export interface CalendarEventAttendee {
|
|||||||
status: 'accepted' | 'rejected' | 'invited';
|
status: 'accepted' | 'rejected' | 'invited';
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventReminder {
|
||||||
|
uid?: string;
|
||||||
|
start_date: string;
|
||||||
|
type: 'email' | 'sound';
|
||||||
|
acknowledged_at?: string;
|
||||||
|
}
|
||||||
|
|||||||
113
lib/utils.ts
113
lib/utils.ts
@@ -1,4 +1,4 @@
|
|||||||
import { Contact, ContactAddress, ContactField } from './types.ts';
|
import { Calendar, CalendarEvent, Contact, ContactAddress, ContactField } from './types.ts';
|
||||||
|
|
||||||
let BASE_URL = typeof window !== 'undefined' && window.location
|
let BASE_URL = typeof window !== 'undefined' && window.location
|
||||||
? `${window.location.protocol}//${window.location.host}`
|
? `${window.location.protocol}//${window.location.host}`
|
||||||
@@ -17,9 +17,11 @@ export const helpEmail = 'help@bewcloud.com';
|
|||||||
|
|
||||||
export const CONTACTS_PER_PAGE_COUNT = 20;
|
export const CONTACTS_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
export const DAV_RESPONSE_HEADER = '1, 3, 4, addressbook';
|
export const DAV_RESPONSE_HEADER = '1, 2, 3, 4, addressbook, calendar-access';
|
||||||
// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar'
|
// Response headers from Nextcloud:
|
||||||
// '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, 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) {
|
export function isRunningLocally(request: Request) {
|
||||||
return request.url.includes('localhost');
|
return request.url.includes('localhost');
|
||||||
@@ -388,8 +390,8 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||||
vCardVersion = '2.1';
|
|
||||||
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||||
|
vCardVersion = '2.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (line.startsWith('UID:')) {
|
if (line.startsWith('UID:')) {
|
||||||
@@ -625,6 +627,105 @@ export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
|||||||
return partialContacts;
|
return partialContacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
export function formatCalendarEventsToVCalendar(calendarEvents: CalendarEvent[], _calendar: Calendar): string {
|
||||||
|
const vCalendarText = calendarEvents.map((calendarEvent) =>
|
||||||
|
`BEGIN:VEVENT
|
||||||
|
DTSTAMP:${calendarEvent.start_date.toISOString().substring(0, 19).replaceAll('T', '').replaceAll(':', '')}
|
||||||
|
DTSTART:${calendarEvent.start_date.toISOString().substring(0, 19).replaceAll('T', '').replaceAll(':', '')}
|
||||||
|
DTEND:${calendarEvent.end_date.toISOString().substring(0, 19).replaceAll('T', '').replaceAll(':', '')}
|
||||||
|
ORGANIZER;CN=:MAILTO:${calendarEvent.extra.organizer_email}
|
||||||
|
SUMMARY:${calendarEvent.title}
|
||||||
|
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
|
||||||
|
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<CalendarEvent>[] {
|
||||||
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
||||||
|
|
||||||
|
let partialCalendarEvent: Partial<CalendarEvent> = {};
|
||||||
|
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 contact
|
||||||
|
if (line.startsWith('END:VCARD')) {
|
||||||
|
partialCalendarEvents.push(partialCalendarEvent);
|
||||||
|
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:', '');
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialCalendarEvent.extra = {
|
||||||
|
...(partialCalendarEvent.extra! || {}),
|
||||||
|
uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters )
|
||||||
|
|
||||||
|
if (line.startsWith('SUMMARY:')) {
|
||||||
|
const title = line.split('SUMMARY:')[1] || '';
|
||||||
|
|
||||||
|
partialCalendarEvent.title = title;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return partialCalendarEvents;
|
||||||
|
}
|
||||||
|
|
||||||
export const capitalizeWord = (string: string) => {
|
export const capitalizeWord = (string: string) => {
|
||||||
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
|
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
|
||||||
};
|
};
|
||||||
@@ -684,7 +785,7 @@ export function getDaysForWeek(
|
|||||||
const dayDate = new Date(startingDate);
|
const dayDate = new Date(startingDate);
|
||||||
dayDate.setDate(dayDate.getDate() + dayIndex);
|
dayDate.setDate(dayDate.getDate() + dayIndex);
|
||||||
|
|
||||||
const isSameDay = dayDate.toISOString() === shortIsoDate;
|
const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate;
|
||||||
|
|
||||||
days[dayIndex] = {
|
days[dayIndex] = {
|
||||||
date: dayDate,
|
date: dayDate,
|
||||||
|
|||||||
7
routes/.well-known/caldav.tsx
Normal file
7
routes/.well-known/caldav.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Nextcloud/ownCloud mimicry
|
||||||
|
export function handler(): Response {
|
||||||
|
return new Response('Redirecting...', {
|
||||||
|
status: 307,
|
||||||
|
headers: { Location: '/dav' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,107 +1,9 @@
|
|||||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
// import { getCalendars, getCalendarEvents } from '/lib/data/calendar.ts';
|
import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts';
|
||||||
import MainCalendar from '/islands/calendar/MainCalendar.tsx';
|
import MainCalendar from '/islands/calendar/MainCalendar.tsx';
|
||||||
|
|
||||||
async function getCalendars(userId: string): Promise<Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[]> {
|
|
||||||
// TODO: Remove this
|
|
||||||
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'family-1',
|
|
||||||
name: 'Family',
|
|
||||||
color: 'bg-purple-500',
|
|
||||||
is_visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'personal-1',
|
|
||||||
name: 'Personal',
|
|
||||||
color: 'bg-sky-600',
|
|
||||||
is_visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'house-chores-1',
|
|
||||||
name: 'House Chores',
|
|
||||||
color: 'bg-red-700',
|
|
||||||
is_visible: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCalendarEvents(userId: string, calendarIds: string[]): Promise<CalendarEvent[]> {
|
|
||||||
// TODO: Remove this
|
|
||||||
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'event-1',
|
|
||||||
user_id: userId,
|
|
||||||
calendar_id: 'family-1',
|
|
||||||
revision: 'fake-rev',
|
|
||||||
title: 'Dentist',
|
|
||||||
start_date: new Date('2024-03-18T14:00:00.000Z'),
|
|
||||||
end_date: new Date('2024-03-18T15:00:00.000Z'),
|
|
||||||
is_all_day: false,
|
|
||||||
status: 'scheduled',
|
|
||||||
extra: {
|
|
||||||
visibility: 'default',
|
|
||||||
},
|
|
||||||
updated_at: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'event-2',
|
|
||||||
user_id: userId,
|
|
||||||
calendar_id: 'family-1',
|
|
||||||
revision: 'fake-rev',
|
|
||||||
title: 'Dermatologist',
|
|
||||||
start_date: new Date('2024-03-18T16:30:00.000Z'),
|
|
||||||
end_date: new Date('2024-03-18T17:30:00.000Z'),
|
|
||||||
is_all_day: false,
|
|
||||||
status: 'scheduled',
|
|
||||||
extra: {
|
|
||||||
visibility: 'default',
|
|
||||||
},
|
|
||||||
updated_at: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'event-3',
|
|
||||||
user_id: userId,
|
|
||||||
calendar_id: 'house-chores-1',
|
|
||||||
revision: 'fake-rev',
|
|
||||||
title: 'Vacuum',
|
|
||||||
start_date: new Date('2024-03-19T15:00:00.000Z'),
|
|
||||||
end_date: new Date('2024-03-19T16:00:00.000Z'),
|
|
||||||
is_all_day: false,
|
|
||||||
status: 'scheduled',
|
|
||||||
extra: {
|
|
||||||
visibility: 'default',
|
|
||||||
},
|
|
||||||
updated_at: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'event-4',
|
|
||||||
user_id: userId,
|
|
||||||
calendar_id: 'personal-1',
|
|
||||||
revision: 'fake-rev',
|
|
||||||
title: 'Schedule server updates',
|
|
||||||
start_date: new Date('2024-03-20T09:00:00.000Z'),
|
|
||||||
end_date: new Date('2024-03-20T21:00:00.000Z'),
|
|
||||||
is_all_day: true,
|
|
||||||
status: 'scheduled',
|
|
||||||
extra: {
|
|
||||||
visibility: 'default',
|
|
||||||
},
|
|
||||||
updated_at: new Date(),
|
|
||||||
created_at: new Date(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||||
userCalendarEvents: CalendarEvent[];
|
userCalendarEvents: CalendarEvent[];
|
||||||
|
|||||||
133
routes/dav/calendars.tsx
Normal file
133
routes/dav/calendars.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.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: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<Data, FreshContextState> = 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/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendars = await getCalendars(context.state.user.id);
|
||||||
|
|
||||||
|
for (const calendar of calendars) {
|
||||||
|
responseBody['d:multistatus']['d:response'].push({
|
||||||
|
'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',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
209
routes/dav/calendars/[calendarId].tsx
Normal file
209
routes/dav/calendars/[calendarId].tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
buildRFC822Date,
|
||||||
|
convertObjectToDavXml,
|
||||||
|
DAV_RESPONSE_HEADER,
|
||||||
|
escapeHtml,
|
||||||
|
escapeXml,
|
||||||
|
formatCalendarEventsToVCalendar,
|
||||||
|
parseVCalendarFromTextContents,
|
||||||
|
} from '/lib/utils.ts';
|
||||||
|
import { getCalendar, getCalendarEvents } 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<Data, FreshContextState> = 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) {
|
||||||
|
// TODO: Support MKCALENDAR
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'PUT') {
|
||||||
|
const requestBody = await request.clone().text();
|
||||||
|
|
||||||
|
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,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // Use the sent id for the UID
|
||||||
|
// if (!partialCalendarEvent.extra?.uid) {
|
||||||
|
// partialCalendarEvent.extra = {
|
||||||
|
// ...(partialCalendarEvent.extra! || {}),
|
||||||
|
// uid: calendarId,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// newCalendarEvent.extra = partialCalendarEvent.extra!;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
229
routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx
Normal file
229
routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
buildRFC822Date,
|
||||||
|
convertObjectToDavXml,
|
||||||
|
DAV_RESPONSE_HEADER,
|
||||||
|
escapeHtml,
|
||||||
|
escapeXml,
|
||||||
|
formatCalendarEventsToVCalendar,
|
||||||
|
parseVCalendarFromTextContents,
|
||||||
|
} from '/lib/utils.ts';
|
||||||
|
import { getCalendar, getCalendarEvent, getCalendarEvents } 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<Data, FreshContextState> = 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, calendarId, context.state.user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!calendarEvent) {
|
||||||
|
if (request.method === 'PUT') {
|
||||||
|
// TODO: Build this
|
||||||
|
// const newCalendarEvent = await createCalendarEvent(
|
||||||
|
// context.state.user.id,
|
||||||
|
// partialCalendarEvent.title,
|
||||||
|
// partialCalendarEvent.start_date,
|
||||||
|
// partialCalendarEvent.end_date,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// // Use the sent id for the UID
|
||||||
|
// if (!partialCalendarEvent.extra?.uid) {
|
||||||
|
// partialCalendarEvent.extra = {
|
||||||
|
// ...(partialCalendarEvent.extra! || {}),
|
||||||
|
// uid: calendarId,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// newCalendarEvent.extra = partialCalendarEvent.extra!;
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build this
|
||||||
|
// 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 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') {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -18,6 +18,9 @@ interface DavResponse {
|
|||||||
'card:addressbook-home-set'?: {
|
'card:addressbook-home-set'?: {
|
||||||
'd:href': string;
|
'd:href': string;
|
||||||
};
|
};
|
||||||
|
'cal:calendar-home-set'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
'd:current-user-principal'?: {
|
'd:current-user-principal'?: {
|
||||||
'd:href': string;
|
'd:href': string;
|
||||||
};
|
};
|
||||||
@@ -109,6 +112,12 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
responseBody['d:multistatus']['d:response'].push(propResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user