|
|
|
@@ -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()));
|
|
|
|
|
}
|
|
|
|
|
}
|