Support CalDav
This commit is contained in:
@@ -23,6 +23,29 @@ export const CALENDAR_COLOR_OPTIONS = [
|
|||||||
'bg-rose-700',
|
'bg-rose-700',
|
||||||
] as const;
|
] 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
|
// 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 = [
|
export const CALENDAR_BORDER_COLOR_OPTIONS = [
|
||||||
'border-red-700',
|
'border-red-700',
|
||||||
@@ -47,6 +70,12 @@ export const CALENDAR_BORDER_COLOR_OPTIONS = [
|
|||||||
'border-rose-700',
|
'border-rose-700',
|
||||||
] as const;
|
] 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']) {
|
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
|
||||||
if (status === 'accepted' || status === 'rejected') {
|
if (status === 'accepted' || status === 'rejected') {
|
||||||
return status.toUpperCase();
|
return status.toUpperCase();
|
||||||
@@ -77,7 +106,6 @@ function getSafelyUnescapedTextFromVCalendar(text: string) {
|
|||||||
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Build this
|
|
||||||
export function formatCalendarEventsToVCalendar(
|
export function formatCalendarEventsToVCalendar(
|
||||||
calendarEvents: CalendarEvent[],
|
calendarEvents: CalendarEvent[],
|
||||||
calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[],
|
calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[],
|
||||||
@@ -448,8 +476,6 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters )
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return partialCalendarEvents;
|
return partialCalendarEvents;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Handler } from 'fresh/server.ts';
|
|||||||
|
|
||||||
import { FreshContextState } from '/lib/types.ts';
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.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 { createSessionCookie } from '/lib/auth.ts';
|
||||||
import { getCalendars } from '/lib/data/calendar.ts';
|
import { getCalendars } from '/lib/data/calendar.ts';
|
||||||
|
|
||||||
@@ -22,6 +23,21 @@ interface DavResponse {
|
|||||||
'd:href': string;
|
'd:href': string;
|
||||||
};
|
};
|
||||||
'd:principal-URL'?: {};
|
'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;
|
'd:status': string;
|
||||||
}[];
|
}[];
|
||||||
@@ -38,6 +54,7 @@ interface DavMultiStatusResponse {
|
|||||||
'xmlns:oc': string;
|
'xmlns:oc': string;
|
||||||
'xmlns:nc': string;
|
'xmlns:nc': string;
|
||||||
'xmlns:cs': string;
|
'xmlns:cs': string;
|
||||||
|
'xmlns:ic': string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +105,36 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
'xmlns:oc': 'http://owncloud.org/ns',
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||||
|
'xmlns:ic': 'http://apple.com/ns/ical/',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendars = await getCalendars(context.state.user.id);
|
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) {
|
for (const calendar of calendars) {
|
||||||
responseBody['d:multistatus']['d:response'].push({
|
const parsedCalendar: DavResponse = {
|
||||||
'd:href': `/dav/calendars/${calendar.id}`,
|
'd:href': `/dav/calendars/${calendar.id}`,
|
||||||
'd:propstat': [{
|
'd:propstat': [{
|
||||||
'd:prop': {
|
'd:prop': {
|
||||||
@@ -114,7 +154,30 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
'd:prop': { 'd:principal-URL': {} },
|
'd:prop': { 'd:principal-URL': {} },
|
||||||
'd:status': 'HTTP/1.1 404 Not Found',
|
'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), {
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Handler } from 'fresh/server.ts';
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
import { parse } from 'xml/mod.ts';
|
||||||
|
|
||||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||||
import {
|
import {
|
||||||
|
createCalendar,
|
||||||
createCalendarEvent,
|
createCalendarEvent,
|
||||||
getCalendar,
|
getCalendar,
|
||||||
getCalendarEvent,
|
getCalendarEvent,
|
||||||
@@ -76,8 +78,27 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
console.error(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<string, any>
|
||||||
|
| 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) {
|
if (!calendar) {
|
||||||
// TODO: Support MKCALENDAR
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { Handler } from 'fresh/server.ts';
|
|||||||
|
|
||||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||||
import { formatCalendarEventsToVCalendar } from '/lib/utils/calendar.ts';
|
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||||
import { getCalendar, getCalendarEvent, updateCalendarEvent } from '/lib/data/calendar.ts';
|
import {
|
||||||
|
createCalendarEvent,
|
||||||
|
deleteCalendarEvent,
|
||||||
|
getCalendar,
|
||||||
|
getCalendarEvent,
|
||||||
|
updateCalendarEvent,
|
||||||
|
} from '/lib/data/calendar.ts';
|
||||||
import { createSessionCookie } from '/lib/auth.ts';
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
interface Data {}
|
interface Data {}
|
||||||
@@ -83,71 +89,88 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
|
|||||||
|
|
||||||
if (!calendarEvent) {
|
if (!calendarEvent) {
|
||||||
if (request.method === 'PUT') {
|
if (request.method === 'PUT') {
|
||||||
// TODO: Build this
|
const requestBody = await request.clone().text();
|
||||||
// const newCalendarEvent = await createCalendarEvent(
|
|
||||||
// context.state.user.id,
|
|
||||||
// partialCalendarEvent.title,
|
|
||||||
// partialCalendarEvent.start_date,
|
|
||||||
// partialCalendarEvent.end_date,
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // Use the sent id for the UID
|
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||||
// if (!partialCalendarEvent.extra?.uid) {
|
|
||||||
// partialCalendarEvent.extra = {
|
|
||||||
// ...(partialCalendarEvent.extra! || {}),
|
|
||||||
// uid: calendarId,
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// newCalendarEvent.extra = partialCalendarEvent.extra!;
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
// await updateCalendarEvent(newCalendarEvent);
|
// Use the sent id for the UID
|
||||||
|
if (!partialCalendarEvent.extra?.uid) {
|
||||||
|
partialCalendarEvent.extra = {
|
||||||
|
...(partialCalendarEvent.extra! || {}),
|
||||||
|
uid: calendarEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
|
const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
|
||||||
|
|
||||||
// return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
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 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Build this
|
if (request.method === 'DELETE') {
|
||||||
// if (request.method === 'DELETE') {
|
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||||
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
|
||||||
|
|
||||||
// // Don't update outdated data
|
// Don't update outdated data
|
||||||
// if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
|
if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
|
||||||
// return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// await deleteContact(contactId, context.state.user.id);
|
await deleteCalendarEvent(calendarEventId, calendarId, context.state.user.id);
|
||||||
|
|
||||||
// return new Response(null, { status: 202 });
|
return new Response(null, { status: 202 });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (request.method === 'PUT') {
|
if (request.method === 'PUT') {
|
||||||
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||||
|
|
||||||
// // Don't update outdated data
|
// Don't update outdated data
|
||||||
// if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
|
||||||
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const requestBody = await request.clone().text();
|
const requestBody = await request.clone().text();
|
||||||
|
|
||||||
// const [partialContact] = parseVCardFromTextContents(requestBody);
|
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||||
|
|
||||||
// contact = {
|
calendarEvent = {
|
||||||
// ...contact,
|
...calendarEvent,
|
||||||
// ...partialContact,
|
...partialCalendarEvent,
|
||||||
// };
|
};
|
||||||
|
|
||||||
// await updateContact(contact);
|
await updateCalendarEvent(calendarEvent);
|
||||||
|
|
||||||
// contact = await getContact(contactId, context.state.user.id);
|
calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||||
|
|
||||||
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (request.method === 'GET') {
|
if (request.method === 'GET') {
|
||||||
// Set a UID if there isn't one
|
// Set a UID if there isn't one
|
||||||
|
|||||||
Reference in New Issue
Block a user