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
This commit is contained in:
74
routes/api/calendar/add-event.tsx
Normal file
74
routes/api/calendar/add-event.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { generateVCalendar, getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
calendarId: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isAllDay?: boolean;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.title || !requestBody.startDate ||
|
||||
!requestBody.endDate || !requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const userId = context.state.user.id;
|
||||
|
||||
const eventId = crypto.randomUUID();
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
calendarId: requestBody.calendarId,
|
||||
title: requestBody.title,
|
||||
startDate: new Date(requestBody.startDate),
|
||||
endDate: new Date(requestBody.endDate),
|
||||
isAllDay: Boolean(requestBody.isAllDay),
|
||||
organizerEmail: context.state.user.email,
|
||||
transparency: 'opaque',
|
||||
url: `${calendar.url}/${eventId}.ics`,
|
||||
uid: eventId,
|
||||
};
|
||||
|
||||
const vCalendar = generateVCalendar([newEvent]);
|
||||
|
||||
await CalendarEventModel.create(userId, requestBody.calendarId, eventId, vCalendar);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(userId, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
35
routes/api/calendar/add.tsx
Normal file
35
routes/api/calendar/add.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.name) {
|
||||
await CalendarModel.create(context.state.user.id, requestBody.name);
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
64
routes/api/calendar/delete-event.tsx
Normal file
64
routes/api/calendar/delete-event.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
calendarId: string;
|
||||
calendarEventId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.calendarEventId ||
|
||||
!requestBody.calendarEventId ||
|
||||
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendarEvent = await CalendarEventModel.get(
|
||||
context.state.user.id,
|
||||
calendar.uid!,
|
||||
requestBody.calendarEventId,
|
||||
);
|
||||
|
||||
if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendarId) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
await CalendarEventModel.delete(context.state.user.id, calendarEvent.url);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
41
routes/api/calendar/delete.tsx
Normal file
41
routes/api/calendar/delete.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.calendarId) {
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await CalendarModel.delete(context.state.user.id, requestBody.calendarId);
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
38
routes/api/calendar/export-events.tsx
Normal file
38
routes/api/calendar/export-events.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
calendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (!requestBody.calendarIds) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendarEvents = await CalendarEventModel.list(
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, calendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
67
routes/api/calendar/import.tsx
Normal file
67
routes/api/calendar/import.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { getDateRangeForCalendarView, getIdFromVEvent, splitTextIntoVEvents } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
icsToImport: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.icsToImport ||
|
||||
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const vEvents = splitTextIntoVEvents(requestBody.icsToImport);
|
||||
|
||||
if (vEvents.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
vEvents.map((vEvent) => async () => {
|
||||
const eventId = getIdFromVEvent(vEvent);
|
||||
|
||||
await CalendarEventModel.create(context.state.user!.id, calendar.uid!, eventId, vEvent);
|
||||
}),
|
||||
5,
|
||||
);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
53
routes/api/calendar/search-events.tsx
Normal file
53
routes/api/calendar/search-events.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
matchingCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarIds || !requestBody.searchTerm
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const allCalendarEvents = await CalendarEventModel.list(
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const lowerSearchTerm = requestBody.searchTerm.toLowerCase();
|
||||
|
||||
const matchingCalendarEvents = allCalendarEvents.filter((calendarEvent) =>
|
||||
calendarEvent.title.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.description?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.location?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.eventUrl?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.organizerEmail?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.attendees?.some((attendee) => attendee.email.toLowerCase().includes(lowerSearchTerm)) ||
|
||||
calendarEvent.reminders?.some((reminder) => reminder.description?.toLowerCase().includes(lowerSearchTerm))
|
||||
).sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||
|
||||
const responseBody: ResponseBody = { success: true, matchingCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
65
routes/api/calendar/update.tsx
Normal file
65
routes/api/calendar/update.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { UserModel } from '/lib/models/user.ts';
|
||||
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.id) {
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
calendar.displayName = requestBody.name;
|
||||
calendar.calendarColor = requestBody.color?.startsWith('#')
|
||||
? requestBody.color
|
||||
: getColorAsHex(requestBody.color || 'bg-gray-700');
|
||||
|
||||
await CalendarModel.update(context.state.user.id, calendar.url, calendar.displayName, calendar.calendarColor);
|
||||
|
||||
if (requestBody.isVisible !== calendar.isVisible) {
|
||||
const user = await UserModel.getById(context.state.user.id);
|
||||
|
||||
if (requestBody.isVisible) {
|
||||
user.extra.hidden_calendar_ids = user.extra.hidden_calendar_ids?.filter((id) => id !== calendar.uid!);
|
||||
} else if (Array.isArray(user.extra.hidden_calendar_ids)) {
|
||||
user.extra.hidden_calendar_ids.push(calendar.uid!);
|
||||
} else {
|
||||
user.extra.hidden_calendar_ids = [calendar.uid!];
|
||||
}
|
||||
|
||||
await UserModel.update(user);
|
||||
}
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user