diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6c56d54 --- /dev/null +++ b/config.yaml @@ -0,0 +1,6 @@ +users: + - id: anton + calendars: + - uri: https://c.aggtaa.com/ + username: anton + password: hP81HfY3a4UTEnaTVZWaZJ6bL6BvMYL2 \ No newline at end of file diff --git a/package.json b/package.json index 9040137..d0ba6c2 100644 --- a/package.json +++ b/package.json @@ -18,17 +18,20 @@ "pnpm": ">=11.4.0 <12" }, "type": "module", + "types": "node", "dependencies": { "@fastify/cors": "^11.2.0", "@modelcontextprotocol/sdk": "^1.13.1", "fast-xml-parser": "^5.9.3", "fastify": "^5.2.1", + "ical.js": "^2.2.1", + "yaml": "^2.9.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.25.2" }, "devDependencies": { "@eslint/js": "^9.21.0", - "@types/node": "^22.13.5", + "@types/node": "^22.20.0", "esbuild": "^0.28.1", "eslint": "^9.21.0", "pino-pretty": "^13.1.3", @@ -36,4 +39,4 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.24.1" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 730482e..11b6999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,12 @@ importers: fastify: specifier: ^5.2.1 version: 5.9.0 + ical.js: + specifier: ^2.2.1 + version: 2.2.1 + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod: specifier: ^3.24.2 version: 3.25.76 @@ -230,7 +236,7 @@ importers: specifier: ^9.21.0 version: 9.39.4 '@types/node': - specifier: ^22.13.5 + specifier: ^22.20.0 version: 22.20.0 esbuild: specifier: ^0.28.1 @@ -972,6 +978,9 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + ical.js@2.2.1: + resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1412,6 +1421,11 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} 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: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2186,6 +2200,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + ical.js@2.2.1: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -2596,6 +2612,8 @@ snapshots: xml-naming@0.1.0: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.2(zod@3.25.76): diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..216741f --- /dev/null +++ b/src/config.ts @@ -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; + +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(); diff --git a/src/mcpTools/caldav.ts b/src/mcpTools/caldav.ts new file mode 100644 index 0000000..60bd711 --- /dev/null +++ b/src/mcpTools/caldav.ts @@ -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); + }, +}; diff --git a/src/mcpTools/index.ts b/src/mcpTools/index.ts index 8271184..cab13b2 100644 --- a/src/mcpTools/index.ts +++ b/src/mcpTools/index.ts @@ -1,5 +1,6 @@ export * from './ping'; // export * from './listTools'; // export * from './invokeTool'; -export * from './testAddRemoveTool'; +// export * from './testAddRemoveTool'; export * from './gismeteo_weather'; +export * from './caldav'; diff --git a/src/utils/caldavServerConnection.ts b/src/utils/caldavServerConnection.ts new file mode 100644 index 0000000..6facae0 --- /dev/null +++ b/src/utils/caldavServerConnection.ts @@ -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. ``, parses to `""`. */ +type DavFlag = ""; + +/** 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; +} + +/** 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; + } +} + +/** shape for the REPORT (calendar-query) in + * getCalendarEventsForDateRange() and getCalendarEventById(). */ +type DavCalendarObjectProp = { + getetag?: DavLeaf; + "calendar-data"?: DavLeaf; +} + +interface DavPropstat

{ + status: DavLeaf; + prop?: P; +} + +interface DavResponse

{ + href: DavLeaf; + propstat?: DavPropstat

| DavPropstat

[]; +} + +interface DavMultistatus

{ + multistatus?: { + response?: DavResponse

| DavResponse

[]; + }; +} + +// --------------------------------------------------------------------------- +// 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 { + const body = ` + + + + +`; + const res = await this.request("PROPFIND", this.baseUrl, { + headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" }, + body, + }); + const parsed = this.parseXml>(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 { + const userPrincipalUrl = await this.getUserPrincipalUrl(); + + const body = ` + + + + + + + +`; + + const res = await this.request("PROPFIND", userPrincipalUrl, { + headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" }, + body, + }); + const parsed = this.parseXml>(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 { + const body = ` + + + + + + + + + + + + +`; + + const res = await this.request("REPORT", calendarUrl, { + headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" }, + body, + }); + const parsed = this.parseXml>(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 { + const body = ` + + + + + + + + + + ${CaldavServerConnection.escapeXml(eventId)} + + + + +`; + + const res = await this.request("REPORT", calendarUrl, { + headers: { "Content-Type": 'application/xml; charset="utf-8"', Depth: "1" }, + body, + }); + const parsed = this.parseXml>(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 { + 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, + ): Promise { + 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 = { "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 { + const headers: Record = {}; + 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; body?: string }, + ): Promise { + const headers: Record = { ...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(xml: string): T { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + parseTagValue: false, + }); + return parser.parse(xml); + } + + private asArray(v: T | T[] | undefined | null): T[] { + if (v == null) return []; + return Array.isArray(v) ? v : [v]; + } + + private hrefOf(response: DavResponse): string { + return this.textOf(response?.href) ?? ""; + } + + private okProp(response: DavResponse): 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, ">"); + } + + // ------------------------------------------------------------------- + // 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())); + } +} \ No newline at end of file