readonly calendar access

This commit is contained in:
azykov@mail.ru
2026-07-10 15:26:40 +03:00
parent 3b46f15e9f
commit 6bd101cab4
7 changed files with 810 additions and 4 deletions
+6
View File
@@ -0,0 +1,6 @@
users:
- id: anton
calendars:
- uri: https://c.aggtaa.com/
username: anton
password: hP81HfY3a4UTEnaTVZWaZJ6bL6BvMYL2
+4 -1
View File
@@ -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",
+19 -1
View File
@@ -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):
+36
View File
@@ -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();
+98
View File
@@ -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);
},
};
+2 -1
View File
@@ -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';
+644
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// -------------------------------------------------------------------
// 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()));
}
}