2025-09-27 18:39:09 +00:00
|
|
|
import { createDAVClient } from '/lib/models/dav.js';
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
import { AppConfig } from '/lib/config.ts';
|
Remove fresh
This implements a huge change, where Fresh is removed as a framework and serving files, allowing more control over importing, bundling, and serving files and components.
The biggest challenge was to continue making sure that there weren't too many places to look into for import versions, and `PasswordlessPasskeyLogin.tsx` became a prototype in migrating a component to fully SSR, no need for frontend parsing (via Babel) or bundling (via a custom-script, downloading frontend dependencies from esm.sh). Still, there are too many components to migrate like that, and it's all working, so I likely won't even attempt it unless there's some bug, new feature, or security vulnerability to address that warrants a rewrite of those.
This also updates all dependencies (except `@libs/xml` because that still causes some breaking in DAV endpoints), including Deno!
All other advantages can be seen in the related issues, and the breaking change this (v4.0.0) introduces is related simply to `config.email.tlsMode` (which had a deprecation warning throughout v3), and because, while I tested many things exhaustively, it's not impossible something broke that I didn't see.
Closes #141
Closes #132
2026-02-20 10:54:31 +00:00
|
|
|
import { getColorAsHex, parseVCalendar } from '/public/ts/utils/calendar.ts';
|
|
|
|
|
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
import { UserModel } from '/lib/models/user.ts';
|
|
|
|
|
|
|
|
|
|
interface DAVObject extends Record<string, any> {
|
|
|
|
|
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<Calendar[]> {
|
|
|
|
|
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)'),
|
2025-09-06 18:46:47 +00:00
|
|
|
calendarColor: decodeURIComponent(
|
|
|
|
|
typeof davCalendar.calendarColor === 'string' ? davCalendar.calendarColor : getColorAsHex('bg-gray-700'),
|
|
|
|
|
),
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
isVisible: !user.extra.hidden_calendar_ids?.includes(uid),
|
|
|
|
|
uid,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return calendars;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async get(
|
|
|
|
|
userId: string,
|
|
|
|
|
calendarId: string,
|
|
|
|
|
): Promise<Calendar | undefined> {
|
|
|
|
|
const calendars = await this.list(userId);
|
|
|
|
|
|
|
|
|
|
return calendars.find((calendar) => calendar.uid === calendarId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async create(
|
|
|
|
|
userId: string,
|
|
|
|
|
name: string,
|
2025-09-06 18:17:48 +00:00
|
|
|
color: string,
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
): Promise<void> {
|
|
|
|
|
const calendarId = crypto.randomUUID();
|
|
|
|
|
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
|
|
|
|
|
|
|
|
|
const client = await getClient(userId);
|
|
|
|
|
|
|
|
|
|
await client.makeCalendar({
|
|
|
|
|
url: calendarUrl,
|
|
|
|
|
props: {
|
2025-09-06 18:17:48 +00:00
|
|
|
displayname: name,
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
},
|
|
|
|
|
});
|
2025-09-06 18:46:47 +00:00
|
|
|
|
|
|
|
|
// Cannot properly set color with makeCalendar, so we quickly update it instead
|
|
|
|
|
await this.update(userId, calendarUrl, name, color);
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async update(
|
|
|
|
|
userId: string,
|
|
|
|
|
calendarUrl: string,
|
|
|
|
|
displayName: string,
|
|
|
|
|
color?: string,
|
|
|
|
|
): Promise<void> {
|
2025-09-27 18:39:09 +00:00
|
|
|
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because the dav client doesn't have PROPPATCH
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
|
|
|
|
|
<d:proppatch xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
|
|
|
|
|
<d:set>
|
|
|
|
|
<d:prop>
|
|
|
|
|
<d:displayname>${encodeURIComponent(displayName)}</d:displayname>
|
|
|
|
|
${color ? `<a:calendar-color>${encodeURIComponent(color)}</a:calendar-color>` : ''}
|
|
|
|
|
</d:prop>
|
|
|
|
|
</d:set>
|
|
|
|
|
</d:proppatch>`;
|
|
|
|
|
|
|
|
|
|
await fetch(calendarUrl, {
|
|
|
|
|
method: 'PROPPATCH',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
|
|
|
'X-Remote-User': userId,
|
|
|
|
|
},
|
|
|
|
|
body: xmlBody,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async delete(
|
|
|
|
|
userId: string,
|
2025-09-06 18:17:48 +00:00
|
|
|
calendarId: string,
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
): Promise<void> {
|
2025-09-06 18:17:48 +00:00
|
|
|
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
|
|
|
|
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
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<CalendarEvent[]> {
|
|
|
|
|
const client = await getClient(userId);
|
|
|
|
|
|
|
|
|
|
const fetchOptions: { calendar: { url: string }; timeRange?: { start: string; end: string }; expand?: boolean } = {
|
|
|
|
|
calendar: {
|
|
|
|
|
url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-27 18:39:09 +00:00
|
|
|
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions);
|
|
|
|
|
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
if (dateRange) {
|
|
|
|
|
fetchOptions.timeRange = {
|
|
|
|
|
start: dateRange.start.toISOString(),
|
|
|
|
|
end: dateRange.end.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
fetchOptions.expand = true;
|
|
|
|
|
|
2025-09-27 18:39:09 +00:00
|
|
|
// Sometimes the expand option doesn't return anything, so we we fetch with and without it, when queried for a date range
|
|
|
|
|
const davCalendarEventsWithExpansion = await client.fetchCalendarObjects(fetchOptions);
|
|
|
|
|
|
|
|
|
|
for (const davCalendarEvent of davCalendarEventsWithExpansion) {
|
|
|
|
|
// Only add the events that are not already in the list
|
|
|
|
|
if (!davCalendarEvents.some((davCalendarEvent) => davCalendarEvent.url === davCalendarEvent.url)) {
|
|
|
|
|
davCalendarEvents.push(davCalendarEvent);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
Basic CalDav UI (Calendar)
This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.
You can also import and export ICS (VCALENDAR + VEVENT) files.
It allows editing the ICS directly, for power users.
Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".
You can also change the display timezone for the calendar from the settings.
Finally, there's some minor documentation fixes and some other minor tweaks.
Closes #56
Closes #89
2025-09-06 11:46:13 +00:00
|
|
|
|
|
|
|
|
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<CalendarEvent[]> {
|
|
|
|
|
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<CalendarEvent | undefined> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<void> {
|
|
|
|
|
const client = await getClient(userId);
|
|
|
|
|
|
|
|
|
|
await client.updateCalendarObject({
|
|
|
|
|
calendarObject: {
|
|
|
|
|
url: eventUrl,
|
|
|
|
|
data: ics,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async delete(
|
|
|
|
|
userId: string,
|
|
|
|
|
eventUrl: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const client = await getClient(userId);
|
|
|
|
|
|
|
|
|
|
await client.deleteCalendarObject({
|
|
|
|
|
calendarObject: {
|
|
|
|
|
url: eventUrl,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|