readonly calendar access
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
users:
|
||||||
|
- id: anton
|
||||||
|
calendars:
|
||||||
|
- uri: https://c.aggtaa.com/
|
||||||
|
username: anton
|
||||||
|
password: hP81HfY3a4UTEnaTVZWaZJ6bL6BvMYL2
|
||||||
+4
-1
@@ -18,17 +18,20 @@
|
|||||||
"pnpm": ">=11.4.0 <12"
|
"pnpm": ">=11.4.0 <12"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"types": "node",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||||
"fast-xml-parser": "^5.9.3",
|
"fast-xml-parser": "^5.9.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
|
"yaml": "^2.9.0",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zod-to-json-schema": "^3.25.2"
|
"zod-to-json-schema": "^3.25.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.20.0",
|
||||||
"esbuild": "^0.28.1",
|
"esbuild": "^0.28.1",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.21.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
Generated
+19
-1
@@ -219,6 +219,12 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.9.0
|
version: 5.9.0
|
||||||
|
ical.js:
|
||||||
|
specifier: ^2.2.1
|
||||||
|
version: 2.2.1
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.9.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -230,7 +236,7 @@ importers:
|
|||||||
specifier: ^9.21.0
|
specifier: ^9.21.0
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.5
|
specifier: ^22.20.0
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
esbuild:
|
esbuild:
|
||||||
specifier: ^0.28.1
|
specifier: ^0.28.1
|
||||||
@@ -972,6 +978,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
ical.js@2.2.1:
|
||||||
|
resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1412,6 +1421,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
yaml@2.9.0:
|
||||||
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2186,6 +2200,8 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
ical.js@2.2.1: {}
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -2596,6 +2612,8 @@ snapshots:
|
|||||||
|
|
||||||
xml-naming@0.1.0: {}
|
xml-naming@0.1.0: {}
|
||||||
|
|
||||||
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// src/config/index.ts
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
users: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
calendars: z.array(z.object({
|
||||||
|
uri: z.string().url(),
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppConfig = z.infer<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
function loadConfig(): AppConfig {
|
||||||
|
const path = `config.yaml`;
|
||||||
|
|
||||||
|
const raw = readFileSync(path, "utf-8");
|
||||||
|
const parsed = parse(raw);
|
||||||
|
|
||||||
|
const result = ConfigSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Invalid config:", result.error.format());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ToolDef } from "../types";
|
||||||
|
import { CaldavServerConnection, CalendarEvent } from "../utils/caldavServerConnection";
|
||||||
|
import { config } from '../config';
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
function getCaldavConnection(): CaldavServerConnection {
|
||||||
|
const calendarConfig = config.users.find((u) => u.id === 'anton')?.calendars[0];
|
||||||
|
if (!calendarConfig)
|
||||||
|
throw new Error('User not found');
|
||||||
|
|
||||||
|
return new CaldavServerConnection(
|
||||||
|
calendarConfig.uri,
|
||||||
|
{ username: calendarConfig.username, password: calendarConfig.password },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const caldavListCalendarsTool: ToolDef = {
|
||||||
|
title: "caldav-list-calendars",
|
||||||
|
config: {
|
||||||
|
description: "List all calendars available to user",
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
const calendars = await getCaldavConnection().listCalendars();
|
||||||
|
return JSON.stringify(calendars);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const caldavGetCalendarEventsForDateRangeTool: ToolDef = {
|
||||||
|
title: "caldav-get-calendar-events-for-date-range",
|
||||||
|
config: {
|
||||||
|
description: "Get calendar events for date range",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Calendar url obtained from caldav-list-calendars method'),
|
||||||
|
start: z.coerce.date().describe('Range start, date or datetime in ISO format'),
|
||||||
|
end: z.coerce.date().describe('Range end, date or datetime in ISO format'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl, start, end }) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events = await conn.getCalendarEventsForDateRange(calendarUrl, new Date(start), new Date(end));
|
||||||
|
return JSON.stringify(events);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const caldavGetAllCalendarsEventsForDateRangeTool: ToolDef = {
|
||||||
|
title: "caldav-get-all-calendars-events-for-date-range",
|
||||||
|
config: {
|
||||||
|
description: "Get events for date range from all calendars available to user",
|
||||||
|
inputSchema: {
|
||||||
|
start: z.coerce.date().describe('Range start, date or datetime in ISO format'),
|
||||||
|
end: z.coerce.date().describe('Range end, date or datetime in ISO format'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ start, end }) => {
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
for (const calendar of await conn.listCalendars())
|
||||||
|
events.push(...await conn.getCalendarEventsForDateRange(calendar.url, new Date(start), new Date(end)));
|
||||||
|
CaldavServerConnection.sort(events);
|
||||||
|
return JSON.stringify(events);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const caldavGetCalendarEventsForTodayTool: ToolDef = {
|
||||||
|
title: "caldav-get-calendar-events-for-today",
|
||||||
|
config: {
|
||||||
|
description: "Get calendar events for today",
|
||||||
|
inputSchema: {
|
||||||
|
calendarUrl: z.string().describe('Calendar url obtained from caldav-list-calendars method'),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async ({ calendarUrl }) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events = await conn.getCalendarEventsForDateRange(calendarUrl, today, today);
|
||||||
|
return JSON.stringify(events);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const caldavGetAllCalendarsEventsForTodayTool: ToolDef = {
|
||||||
|
title: "caldav-get-all-calendars-events-for-today",
|
||||||
|
config: {
|
||||||
|
description: "Get events for today from all calendars available to user",
|
||||||
|
inputSchema: {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async () => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const conn = await getCaldavConnection();
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
for (const calendar of await conn.listCalendars())
|
||||||
|
events.push(...await conn.getCalendarEventsForDateRange(calendar.url, today, today));
|
||||||
|
CaldavServerConnection.sort(events);
|
||||||
|
return JSON.stringify(events);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './ping';
|
export * from './ping';
|
||||||
// export * from './listTools';
|
// export * from './listTools';
|
||||||
// export * from './invokeTool';
|
// export * from './invokeTool';
|
||||||
export * from './testAddRemoveTool';
|
// export * from './testAddRemoveTool';
|
||||||
export * from './gismeteo_weather';
|
export * from './gismeteo_weather';
|
||||||
|
export * from './caldav';
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
/**
|
||||||
|
* CaldavServerConnection
|
||||||
|
*
|
||||||
|
* Minimal-dependency CalDAV client. Talks CalDAV/WebDAV directly over
|
||||||
|
* PROPFIND/REPORT/PUT/DELETE using the platform fetch (Node >= 18).
|
||||||
|
*
|
||||||
|
* Dependencies:
|
||||||
|
* npm i ical.js fast-xml-parser
|
||||||
|
* npm i -D @types/ical.js (types are incomplete/loose; see the `as any`
|
||||||
|
* casts below where the upstream .d.ts is thin)
|
||||||
|
*
|
||||||
|
* Assumption: `serverUrl` passed to the constructor is the CalDAV
|
||||||
|
* *collection root* for a single user (e.g. a Radicale/Baikal user's home,
|
||||||
|
* such as https://caldav.example.com/dav/anton/), i.e. the URL you'd PROPFIND
|
||||||
|
* with Depth: 1 to enumerate calendars. This class does not perform
|
||||||
|
* .well-known / current-user-principal / calendar-home-set discovery — if
|
||||||
|
* your server needs that, resolve it once externally and pass the resulting
|
||||||
|
* home-set URL in here.
|
||||||
|
*
|
||||||
|
* Known limitation: recurrence EXDATE/RECURRENCE-ID overrides (exceptions to
|
||||||
|
* a recurring series) are not merged into expanded occurrences — each
|
||||||
|
* VEVENT component in a calendar-data block is parsed independently, so an
|
||||||
|
* override component will surface as its own event rather than replacing
|
||||||
|
* the corresponding master occurrence. Fine for read-mostly use; flag if you
|
||||||
|
* need proper exception merging and it can be added.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ICAL from "ical.js";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CaldavAuth {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarInfo {
|
||||||
|
url: string;
|
||||||
|
displayName: string;
|
||||||
|
ctag?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecurrenceFreq = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
|
||||||
|
|
||||||
|
export interface RecurrenceRule {
|
||||||
|
freq: RecurrenceFreq;
|
||||||
|
interval?: number;
|
||||||
|
count?: number;
|
||||||
|
until?: Date;
|
||||||
|
byDay?: string[]; // e.g. ["MO", "WE", "FR"]
|
||||||
|
byMonthDay?: number[];
|
||||||
|
byMonth?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventInput {
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
recurrence?: RecurrenceRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string; // UID
|
||||||
|
url: string; // absolute resource URL (the .ics resource for the master)
|
||||||
|
etag?: string;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
recurrenceRule?: string; // raw RRULE value, if any, from the master
|
||||||
|
isRecurrenceInstance?: boolean;
|
||||||
|
recurrenceId?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CaldavError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status?: number,
|
||||||
|
public readonly url?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CaldavError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaf DAV element value: bare string if no attributes, `{ "#text": ... }`
|
||||||
|
* if it has any (fast-xml-parser's convention with ignoreAttributes:false).
|
||||||
|
* Everything reading a leaf goes through `textOf()`, which normalizes both. */
|
||||||
|
type DavLeaf = string | { "#text": string };
|
||||||
|
|
||||||
|
/** A self-closing flag element, e.g. `<D:collection/>`, parses to `""`. */
|
||||||
|
type DavFlag = "";
|
||||||
|
|
||||||
|
/** <D:resourcetype> children — only `collection`/`calendar` are read by this
|
||||||
|
* client, but servers can report others (principal, addressbook, ...), so
|
||||||
|
* the index signature keeps unrecognized flags from being a type error. */
|
||||||
|
interface DavResourceType {
|
||||||
|
collection?: DavFlag;
|
||||||
|
calendar?: DavFlag; // urn:ietf:params:xml:ns:caldav
|
||||||
|
[key: string]: DavFlag | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <D:prop> shape for the PROPFIND in listCalendars(). */
|
||||||
|
interface DavCalendarProp {
|
||||||
|
resourcetype?: DavResourceType;
|
||||||
|
displayname?: DavLeaf;
|
||||||
|
getctag?: DavLeaf; // CS:getctag
|
||||||
|
"calendar-color"?: DavLeaf; // IC:calendar-color
|
||||||
|
}
|
||||||
|
|
||||||
|
type DavUserPrincipalProp = {
|
||||||
|
"current-user-principal": {
|
||||||
|
href: DavLeaf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** <D:prop> shape for the REPORT (calendar-query) in
|
||||||
|
* getCalendarEventsForDateRange() and getCalendarEventById(). */
|
||||||
|
type DavCalendarObjectProp = {
|
||||||
|
getetag?: DavLeaf;
|
||||||
|
"calendar-data"?: DavLeaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavPropstat<P> {
|
||||||
|
status: DavLeaf;
|
||||||
|
prop?: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavResponse<P> {
|
||||||
|
href: DavLeaf;
|
||||||
|
propstat?: DavPropstat<P> | DavPropstat<P>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultistatus<P> {
|
||||||
|
multistatus?: {
|
||||||
|
response?: DavResponse<P> | DavResponse<P>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class CaldavServerConnection {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly auth?: CaldavAuth;
|
||||||
|
|
||||||
|
constructor(serverUrl: string, auth?: CaldavAuth) {
|
||||||
|
console.log(serverUrl);
|
||||||
|
this.baseUrl = CaldavServerConnection.ensureTrailingSlash(serverUrl);
|
||||||
|
this.auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPrincipalUrl(): Promise<string> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||||
|
<D:prop>
|
||||||
|
<D:current-user-principal/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`;
|
||||||
|
const res = await this.request("PROPFIND", this.baseUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavUserPrincipalProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
const rootResponse = responses.find((r) => r.href === this.okProp(r)?.["current-user-principal"].href);
|
||||||
|
if (!rootResponse)
|
||||||
|
throw new Error('No root principal returned');
|
||||||
|
|
||||||
|
const rootHref = this.textOf(rootResponse.href);
|
||||||
|
if (!rootHref)
|
||||||
|
throw new Error('Invalid root principal returned');
|
||||||
|
|
||||||
|
return this.resolveUrl(rootHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listCalendars(): Promise<CalendarInfo[]> {
|
||||||
|
const userPrincipalUrl = await this.getUserPrincipalUrl();
|
||||||
|
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:IC="http://apple.com/ns/ical/">
|
||||||
|
<D:prop>
|
||||||
|
<D:resourcetype/>
|
||||||
|
<D:displayname/>
|
||||||
|
<CS:getctag/>
|
||||||
|
<IC:calendar-color/>
|
||||||
|
</D:prop>
|
||||||
|
</D:propfind>`;
|
||||||
|
|
||||||
|
const res = await this.request("PROPFIND", userPrincipalUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
|
||||||
|
const calendars: CalendarInfo[] = [];
|
||||||
|
for (const r of responses) {
|
||||||
|
const prop = this.okProp(r);
|
||||||
|
if (!prop)
|
||||||
|
continue;
|
||||||
|
const resourcetype = prop.resourcetype;
|
||||||
|
const isCalendar = resourcetype != null && typeof resourcetype === "object" && "calendar" in resourcetype;
|
||||||
|
if (!isCalendar)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
calendars.push({
|
||||||
|
url: this.resolveUrl(this.hrefOf(r)),
|
||||||
|
displayName: this.textOf(prop.displayname) ?? this.hrefOf(r),
|
||||||
|
ctag: this.textOf(prop.getctag),
|
||||||
|
color: this.textOf(prop["calendar-color"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return calendars;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEventsForDateRange(
|
||||||
|
calendarUrl: string,
|
||||||
|
start: Date,
|
||||||
|
end: Date,
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:time-range start="${this.formatICalUTC(start)}" end="${this.formatICalUTC(end)}"/>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>`;
|
||||||
|
|
||||||
|
const res = await this.request("REPORT", calendarUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarObjectProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
for (const r of responses) {
|
||||||
|
const prop = this.okProp(r);
|
||||||
|
if (!prop)
|
||||||
|
continue;
|
||||||
|
const icalText = this.textOf(prop["calendar-data"]);
|
||||||
|
if (!icalText)
|
||||||
|
continue;
|
||||||
|
const etag = this.textOf(prop.getetag);
|
||||||
|
const resourceUrl = this.resolveUrl(this.hrefOf(r));
|
||||||
|
|
||||||
|
for (const masterComp of this.masterVeventComponents(icalText)) {
|
||||||
|
const { master, icalEvent } = this.toCalendarEvent(masterComp, resourceUrl, etag);
|
||||||
|
const occurences = this.expandOccurrences(icalEvent, master, start, end);
|
||||||
|
events.push(...occurences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CaldavServerConnection.sort(events);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCalendarEventById(calendarUrl: string, eventId: string): Promise<CalendarEvent | undefined> {
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop>
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:prop-filter name="UID">
|
||||||
|
<C:text-match collation="i;octet">${CaldavServerConnection.escapeXml(eventId)}</C:text-match>
|
||||||
|
</C:prop-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>`;
|
||||||
|
|
||||||
|
const res = await this.request("REPORT", calendarUrl, {
|
||||||
|
headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const parsed = this.parseXml<DavMultistatus<DavCalendarObjectProp>>(await res.text());
|
||||||
|
const responses = this.asArray(parsed?.multistatus?.response);
|
||||||
|
if (responses.length === 0)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const prop = this.okProp(responses[0]);
|
||||||
|
if (!prop)
|
||||||
|
return undefined;
|
||||||
|
const icalText = this.textOf(prop["calendar-data"]);
|
||||||
|
if (!icalText)
|
||||||
|
return undefined;
|
||||||
|
const etag = this.textOf(prop.getetag);
|
||||||
|
const resourceUrl = this.resolveUrl(this.hrefOf(responses[0]));
|
||||||
|
|
||||||
|
const masters = this.masterVeventComponents(icalText);
|
||||||
|
const master = masters.find((v) => v.getFirstPropertyValue("uid") === eventId) ?? masters[0];
|
||||||
|
if (!master)
|
||||||
|
return undefined;
|
||||||
|
return this.toCalendarEvent(master, resourceUrl, etag).master;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCalendarEvent(calendarUrl: string, input: CalendarEventInput): Promise<CalendarEvent> {
|
||||||
|
const uid = randomUUID();
|
||||||
|
const ics = this.buildEventIcs(uid, input);
|
||||||
|
const eventUrl = new URL(`${uid}.ics`, CaldavServerConnection.ensureTrailingSlash(calendarUrl)).toString();
|
||||||
|
|
||||||
|
const res = await this.request("PUT", eventUrl, {
|
||||||
|
headers: { "Content-Type": 'text/calendar; charset="utf-8"', "If-None-Match": "*" },
|
||||||
|
body: ics,
|
||||||
|
});
|
||||||
|
const etag = res.headers.get("etag") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uid,
|
||||||
|
url: eventUrl,
|
||||||
|
etag,
|
||||||
|
summary: input.summary,
|
||||||
|
description: input.description,
|
||||||
|
location: input.location,
|
||||||
|
start: input.start,
|
||||||
|
end: input.end,
|
||||||
|
allDay: !!input.allDay,
|
||||||
|
recurrenceRule: input.recurrence ? this.buildRRuleValue(input.recurrence) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async editCalendarEvent(
|
||||||
|
eventUrl: string,
|
||||||
|
updates: Partial<CalendarEventInput>,
|
||||||
|
): Promise<CalendarEvent> {
|
||||||
|
const getRes = await this.request("GET", eventUrl, {});
|
||||||
|
const currentEtag = getRes.headers.get("etag") ?? undefined;
|
||||||
|
const icalText = await getRes.text();
|
||||||
|
|
||||||
|
const vevents = this.veventComponents(icalText);
|
||||||
|
if (vevents.length === 0) {
|
||||||
|
throw new CaldavError("No VEVENT found in resource", getRes.status, eventUrl);
|
||||||
|
}
|
||||||
|
const vevent = vevents[0];
|
||||||
|
const existing = this.toCalendarEvent(vevent, eventUrl, currentEtag).master;
|
||||||
|
const uid = existing.id;
|
||||||
|
|
||||||
|
const merged: CalendarEventInput = {
|
||||||
|
summary: updates.summary ?? existing.summary,
|
||||||
|
description: updates.description ?? existing.description,
|
||||||
|
location: updates.location ?? existing.location,
|
||||||
|
start: updates.start ?? existing.start,
|
||||||
|
end: updates.end ?? existing.end,
|
||||||
|
allDay: updates.allDay ?? existing.allDay,
|
||||||
|
recurrence: updates.recurrence, // explicit: omit to drop, pass to replace
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSeq = Number(vevent.getFirstPropertyValue("sequence") ?? 0);
|
||||||
|
const ics = this.buildEventIcs(uid, merged, currentSeq + 1);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { "Content-Type": 'text/calendar; charset="utf-8"' };
|
||||||
|
if (currentEtag) headers["If-Match"] = currentEtag;
|
||||||
|
|
||||||
|
const putRes = await this.request("PUT", eventUrl, { headers, body: ics });
|
||||||
|
const newEtag = putRes.headers.get("etag") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uid,
|
||||||
|
url: eventUrl,
|
||||||
|
etag: newEtag,
|
||||||
|
summary: merged.summary,
|
||||||
|
description: merged.description,
|
||||||
|
location: merged.location,
|
||||||
|
start: merged.start,
|
||||||
|
end: merged.end,
|
||||||
|
allDay: !!merged.allDay,
|
||||||
|
recurrenceRule: merged.recurrence ? this.buildRRuleValue(merged.recurrence) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCalendarEvent(eventUrl: string, etag?: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (etag) headers["If-Match"] = etag;
|
||||||
|
await this.request("DELETE", eventUrl, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// HTTP plumbing
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private authHeader(): string | undefined {
|
||||||
|
if (!this.auth) return undefined;
|
||||||
|
return "Basic " + Buffer.from(`${this.auth.username}:${this.auth.password}`).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
opts: { headers?: Record<string, string>; body?: string },
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = { ...opts.headers };
|
||||||
|
const auth = this.authHeader();
|
||||||
|
if (auth) headers.Authorization = auth;
|
||||||
|
|
||||||
|
const res = await fetch(url, { method, headers, body: opts.body });
|
||||||
|
// 207 Multi-Status is the normal success code for PROPFIND/REPORT.
|
||||||
|
if (!res.ok && res.status !== 207) {
|
||||||
|
throw new CaldavError(`${method} ${url} failed: ${res.status} ${res.statusText}`, res.status, url);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// XML (WebDAV multistatus) helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private parseXml<T>(xml: string): T {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: false,
|
||||||
|
});
|
||||||
|
return parser.parse(xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private asArray<T>(v: T | T[] | undefined | null): T[] {
|
||||||
|
if (v == null) return [];
|
||||||
|
return Array.isArray(v) ? v : [v];
|
||||||
|
}
|
||||||
|
|
||||||
|
private hrefOf<T>(response: DavResponse<T>): string {
|
||||||
|
return this.textOf(response?.href) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private okProp<T>(response: DavResponse<T>): T | undefined {
|
||||||
|
const propstats = this.asArray(response?.propstat);
|
||||||
|
const ok = propstats.find((p) => this.textOf(p?.status)?.includes("200"));
|
||||||
|
return ok?.prop ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private textOf(v: DavLeaf | undefined | null): string | undefined {
|
||||||
|
if (!v)
|
||||||
|
return;
|
||||||
|
if (typeof v === "string")
|
||||||
|
return v;
|
||||||
|
if (typeof v === "object" && "#text" in v)
|
||||||
|
return String(v["#text"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveUrl(href: string): string {
|
||||||
|
return new URL(href, this.baseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ensureTrailingSlash(url: string): string {
|
||||||
|
return url.endsWith("/") ? url : `${url}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static escapeXml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// iCalendar (ical.js) helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private veventComponents(icalText: string): ICAL.Component[] {
|
||||||
|
const jcal = ICAL.parse(icalText);
|
||||||
|
const vcalendar = new ICAL.Component(jcal);
|
||||||
|
return vcalendar.getAllSubcomponents("vevent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one component per UID: the master (no RECURRENCE-ID). Override
|
||||||
|
* components (RECURRENCE-ID set — single-instance edits/exceptions to a
|
||||||
|
* recurring series) are deliberately excluded from the returned list so
|
||||||
|
* they don't surface as standalone top-level events. They remain attached
|
||||||
|
* to the master via `component.parent` (the shared VCALENDAR), which lets
|
||||||
|
* `new ICAL.Event(master)` auto-discover and merge them (see ical.js'
|
||||||
|
* `relateException`), so `expandOccurrences` can apply them per-instance.
|
||||||
|
*
|
||||||
|
* A resource containing only override components with no master (an
|
||||||
|
* orphaned exception — shouldn't normally happen from a spec-compliant
|
||||||
|
* server) is skipped, since there's nothing to safely expand.
|
||||||
|
*/
|
||||||
|
private masterVeventComponents(icalText: string): ICAL.Component[] {
|
||||||
|
return this.veventComponents(icalText).filter((v) => !v.hasProperty("recurrence-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatICalUTC(date: Date): string {
|
||||||
|
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRRuleValue(rule: RecurrenceRule): string {
|
||||||
|
const parts: string[] = [`FREQ=${rule.freq}`];
|
||||||
|
if (rule.interval) parts.push(`INTERVAL=${rule.interval}`);
|
||||||
|
if (rule.count) parts.push(`COUNT=${rule.count}`);
|
||||||
|
if (rule.until) parts.push(`UNTIL=${this.formatICalUTC(rule.until)}`);
|
||||||
|
if (rule.byDay?.length) parts.push(`BYDAY=${rule.byDay.join(",")}`);
|
||||||
|
if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
|
||||||
|
if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`);
|
||||||
|
return parts.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEventIcs(uid: string, input: CalendarEventInput, sequence = 0): string {
|
||||||
|
const vcalendar = new ICAL.Component(["vcalendar", [], []]);
|
||||||
|
vcalendar.updatePropertyWithValue("prodid", "-//CaldavServerConnection//EN");
|
||||||
|
vcalendar.updatePropertyWithValue("version", "2.0");
|
||||||
|
|
||||||
|
const vevent = new ICAL.Component("vevent");
|
||||||
|
vevent.updatePropertyWithValue("uid", uid);
|
||||||
|
vevent.updatePropertyWithValue("summary", input.summary);
|
||||||
|
if (input.description) vevent.updatePropertyWithValue("description", input.description);
|
||||||
|
if (input.location) vevent.updatePropertyWithValue("location", input.location);
|
||||||
|
vevent.updatePropertyWithValue("sequence", sequence);
|
||||||
|
vevent.updatePropertyWithValue("dtstamp", ICAL.Time.now());
|
||||||
|
|
||||||
|
if (input.allDay) {
|
||||||
|
const dtstart = ICAL.Time.fromJSDate(input.start, false);
|
||||||
|
dtstart.isDate = true;
|
||||||
|
const dtend = ICAL.Time.fromJSDate(input.end, false);
|
||||||
|
dtend.isDate = true;
|
||||||
|
vevent.updatePropertyWithValue("dtstart", dtstart);
|
||||||
|
vevent.updatePropertyWithValue("dtend", dtend);
|
||||||
|
} else {
|
||||||
|
vevent.updatePropertyWithValue("dtstart", ICAL.Time.fromJSDate(input.start, true));
|
||||||
|
vevent.updatePropertyWithValue("dtend", ICAL.Time.fromJSDate(input.end, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.recurrence) {
|
||||||
|
const rruleProp = new ICAL.Property("rrule");
|
||||||
|
rruleProp.setValue(ICAL.Recur.fromString(this.buildRRuleValue(input.recurrence)));
|
||||||
|
vevent.addProperty(rruleProp);
|
||||||
|
}
|
||||||
|
|
||||||
|
vcalendar.addSubcomponent(vevent);
|
||||||
|
return vcalendar.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCalendarEvent(
|
||||||
|
vevent: ICAL.Component,
|
||||||
|
resourceUrl: string,
|
||||||
|
etag?: string,
|
||||||
|
): { master: CalendarEvent; icalEvent: ICAL.Event } {
|
||||||
|
const icalEvent = new ICAL.Event(vevent);
|
||||||
|
const allDay = icalEvent.startDate.isDate;
|
||||||
|
const rruleProp = vevent.getFirstProperty("rrule");
|
||||||
|
|
||||||
|
const master: CalendarEvent = {
|
||||||
|
id: icalEvent.uid,
|
||||||
|
url: resourceUrl,
|
||||||
|
etag,
|
||||||
|
summary: icalEvent.summary ?? "",
|
||||||
|
description: icalEvent.description || undefined,
|
||||||
|
location: icalEvent.location || undefined,
|
||||||
|
start: icalEvent.startDate.toJSDate(),
|
||||||
|
end: icalEvent.endDate.toJSDate(),
|
||||||
|
allDay,
|
||||||
|
recurrenceRule: rruleProp ? String(rruleProp.getFirstValue()) : undefined,
|
||||||
|
};
|
||||||
|
return { master, icalEvent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private expandOccurrences(
|
||||||
|
icalEvent: ICAL.Event,
|
||||||
|
master: CalendarEvent,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): CalendarEvent[] {
|
||||||
|
if (!icalEvent.isRecurring()) {
|
||||||
|
return master.start < rangeEnd && master.end > rangeStart ? [master] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CalendarEvent[] = [];
|
||||||
|
const iterator = icalEvent.iterator();
|
||||||
|
|
||||||
|
// Safety valve against unbounded/huge recurrences (e.g. FREQ=DAILY with
|
||||||
|
// no COUNT/UNTIL) — stop once we're well past the requested range or
|
||||||
|
// after a generous cap on iterations.
|
||||||
|
let next: ICAL.Time | null;
|
||||||
|
let guard = 0;
|
||||||
|
while ((next = iterator.next()) && guard < 10_000) {
|
||||||
|
guard++;
|
||||||
|
const occStart = next.toJSDate();
|
||||||
|
if (occStart.getTime() > rangeEnd.getTime()) break;
|
||||||
|
|
||||||
|
// getOccurrenceDetails resolves RECURRENCE-ID overrides automatically
|
||||||
|
// (relateException, wired up when the ICAL.Event was constructed from
|
||||||
|
// a component with a `.parent` containing sibling override VEVENTs).
|
||||||
|
// `details.item === icalEvent` for a plain, unmodified instance;
|
||||||
|
// any other value means this occurrence was overridden.
|
||||||
|
const details = icalEvent.getOccurrenceDetails(next);
|
||||||
|
const override = details.item !== icalEvent ? details.item : undefined;
|
||||||
|
|
||||||
|
// A cancelled override subtracts this occurrence from the series
|
||||||
|
// entirely — it must not appear in results, moved or otherwise.
|
||||||
|
// (EXDATE-cancelled occurrences never reach this point at all: the
|
||||||
|
// iterator itself skips dates listed in EXDATE.)
|
||||||
|
if (override) {
|
||||||
|
const status = override.component.getFirstPropertyValue("status");
|
||||||
|
if (typeof status === "string" && status.toUpperCase() === "CANCELLED") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const occEnd = details.endDate.toJSDate();
|
||||||
|
const occRealStart = details.startDate.toJSDate();
|
||||||
|
if (occEnd.getTime() > rangeStart.getTime()) {
|
||||||
|
results.push({
|
||||||
|
...master,
|
||||||
|
summary: override?.summary ?? master.summary,
|
||||||
|
description: override?.description || master.description,
|
||||||
|
location: override?.location || master.location,
|
||||||
|
start: occRealStart,
|
||||||
|
end: occEnd,
|
||||||
|
isRecurrenceInstance: true,
|
||||||
|
recurrenceId: occStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static sort(events: CalendarEvent[]) {
|
||||||
|
events.sort((a, b) => Math.abs(a.start.getDate() - b.start.getDate()) * 10 + Math.abs(a.end.getDate() - b.end.getDate()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user