@luckydye/calendar 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,36 @@
1
+ import type {
2
+ CalendarCredentials,
3
+ CalendarSource,
4
+ } from "./CalendarIntegration.js";
1
5
  import type { CalendarEvent } from "./CalendarInternal.js";
2
- import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
3
- import { parseSingleICalEvent, formatICalDate } from "./ICal.js";
6
+ import { formatICalDate, parseSingleICalEvent } from "./ICal.js";
4
7
 
5
8
  /**
6
9
  * CalDAV credentials.
7
10
  */
8
11
  export interface CalDAVCredentials extends CalendarCredentials {
9
- /**
10
- * CalDAV server URL
11
- */
12
- serverUrl: string;
13
- /**
14
- * Username for authentication
15
- */
16
- username: string;
17
- /**
18
- * Password for authentication
19
- */
20
- password: string;
12
+ /**
13
+ * CalDAV server URL
14
+ */
15
+ serverUrl: string;
16
+ /**
17
+ * Username for authentication
18
+ */
19
+ username: string;
20
+ /**
21
+ * Password for authentication
22
+ */
23
+ password: string;
21
24
  }
22
25
 
23
26
  /**
24
27
  * CalDAV calendar information.
25
28
  */
26
29
  interface CalDAVCalendarInfo {
27
- displayName: string;
28
- url: string;
29
- ctag: string;
30
- color?: string;
30
+ displayName: string;
31
+ url: string;
32
+ ctag: string;
33
+ color?: string;
31
34
  }
32
35
 
33
36
  /**
@@ -48,159 +51,194 @@ interface CalDAVCalendarInfo {
48
51
  * const events = await source.fetchEvents();
49
52
  */
50
53
  export class CalDAVSource implements CalendarSource {
51
- readonly type = 'caldav';
52
- credentials: CalDAVCredentials;
53
- enabled: boolean;
54
-
55
- constructor(
56
- public id: string,
57
- public name: string,
58
- public color: string,
59
- credentials: CalDAVCredentials,
60
- enabled = true
61
- ) {
62
- this.credentials = credentials;
63
- this.enabled = enabled;
64
- }
65
-
66
- /**
67
- * Get the authorization header for Basic auth.
68
- */
69
- private getAuthHeader(): string {
70
- return 'Basic ' + btoa(`${this.credentials.username}:${this.credentials.password}`);
71
- }
72
-
73
- /**
74
- * Make an authenticated request to the CalDAV server.
75
- */
76
- async request(url: string, method: string, body?: string, headers: Record<string, string> = {}): Promise<Response> {
77
- const requestHeaders: Record<string, string> = {
78
- 'Authorization': this.getAuthHeader(),
79
- ...headers,
80
- };
81
-
82
- if (body && !Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type')) {
83
- requestHeaders['Content-Type'] = 'application/xml; charset=utf-8';
84
- }
85
-
86
- const response = await fetch(url, {
87
- method,
88
- headers: requestHeaders,
89
- body,
90
- });
91
-
92
- if (!response.ok) {
93
- throw new Error(`CalDAV request failed: ${response.status} ${response.statusText}`);
94
- }
95
-
96
- return response;
97
- }
98
-
99
- /**
100
- * Find the calendar home URL using the well-known CalDAV endpoint.
101
- */
102
- private async findCalendarHome(): Promise<string> {
103
- const wellKnownUrl = new URL('/.well-known/caldav', this.credentials.serverUrl).toString();
104
-
105
- const response = await this.request(
106
- wellKnownUrl,
107
- 'PROPFIND',
108
- `<?xml version="1.0" encoding="utf-8" ?>
54
+ readonly type = "caldav";
55
+ credentials: CalDAVCredentials;
56
+ enabled: boolean;
57
+
58
+ constructor(
59
+ public id: string,
60
+ public name: string,
61
+ public color: string,
62
+ credentials: CalDAVCredentials,
63
+ enabled = true,
64
+ ) {
65
+ this.credentials = credentials;
66
+ this.enabled = enabled;
67
+ }
68
+
69
+ /**
70
+ * Get the authorization header for Basic auth.
71
+ */
72
+ private getAuthHeader(): string {
73
+ return `Basic ${btoa(
74
+ `${this.credentials.username}:${this.credentials.password}`,
75
+ )}`;
76
+ }
77
+
78
+ /**
79
+ * Make an authenticated request to the CalDAV server.
80
+ */
81
+ async request(
82
+ url: string,
83
+ method: string,
84
+ body?: string,
85
+ headers: Record<string, string> = {},
86
+ ): Promise<Response> {
87
+ const requestHeaders: Record<string, string> = {
88
+ Authorization: this.getAuthHeader(),
89
+ ...headers,
90
+ };
91
+
92
+ if (
93
+ body &&
94
+ !Object.keys(requestHeaders).some(
95
+ (key) => key.toLowerCase() === "content-type",
96
+ )
97
+ ) {
98
+ requestHeaders["Content-Type"] = "application/xml; charset=utf-8";
99
+ }
100
+
101
+ const response = await fetch(url, {
102
+ method,
103
+ headers: requestHeaders,
104
+ body,
105
+ });
106
+
107
+ if (!response.ok) {
108
+ throw new Error(
109
+ `CalDAV request failed: ${response.status} ${response.statusText}`,
110
+ );
111
+ }
112
+
113
+ return response;
114
+ }
115
+
116
+ /**
117
+ * Find the calendar home URL using the well-known CalDAV endpoint.
118
+ */
119
+ private async findCalendarHome(): Promise<string> {
120
+ const wellKnownUrl = new URL(
121
+ "/.well-known/caldav",
122
+ this.credentials.serverUrl,
123
+ ).toString();
124
+
125
+ const response = await this.request(
126
+ wellKnownUrl,
127
+ "PROPFIND",
128
+ `<?xml version="1.0" encoding="utf-8" ?>
109
129
  <d:propfind xmlns:d="DAV:">
110
130
  <d:prop>
111
131
  <d:current-user-principal/>
112
132
  </d:prop>
113
133
  </d:propfind>`,
114
- { 'Depth': '0' }
115
- );
116
-
117
- const text = await response.text();
118
-
119
- const principalMatch = text.match(/<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
120
- if (!principalMatch) {
121
- throw new Error('Could not find current-user-principal');
122
- }
123
-
124
- const principalPath = principalMatch[1];
125
- const principalUrl = new URL(principalPath, this.credentials.serverUrl).toString();
126
-
127
- const principalResponse = await this.request(
128
- principalUrl,
129
- 'PROPFIND',
130
- `<?xml version="1.0" encoding="utf-8" ?>
134
+ { Depth: "0" },
135
+ );
136
+
137
+ const text = await response.text();
138
+
139
+ const principalMatch = text.match(
140
+ /<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s,
141
+ );
142
+ if (!principalMatch) {
143
+ throw new Error("Could not find current-user-principal");
144
+ }
145
+
146
+ const principalPath = principalMatch[1];
147
+ const principalUrl = new URL(
148
+ principalPath,
149
+ this.credentials.serverUrl,
150
+ ).toString();
151
+
152
+ const principalResponse = await this.request(
153
+ principalUrl,
154
+ "PROPFIND",
155
+ `<?xml version="1.0" encoding="utf-8" ?>
131
156
  <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
132
157
  <d:prop>
133
158
  <c:calendar-home-set/>
134
159
  </d:prop>
135
160
  </d:propfind>`,
136
- { 'Depth': '0' }
137
- );
138
-
139
- const principalText = await principalResponse.text();
140
-
141
- const homeMatch = principalText.match(/<[^:]+:calendar-home-set[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
142
- if (!homeMatch) {
143
- throw new Error('Could not find calendar-home-set');
144
- }
145
-
146
- return new URL(homeMatch[1], this.credentials.serverUrl).toString();
147
- }
148
-
149
- /**
150
- * Fetch the current user's email address via the CalDAV principal's
151
- * calendar-user-address-set property (RFC 4791).
152
- * Falls back to the configured username if the server doesn't support it.
153
- */
154
- async fetchCurrentUserEmail(): Promise<string> {
155
- const wellKnownUrl = new URL('/.well-known/caldav', this.credentials.serverUrl).toString();
156
-
157
- const discoveryResponse = await this.request(
158
- wellKnownUrl,
159
- 'PROPFIND',
160
- `<?xml version="1.0" encoding="utf-8" ?>
161
+ { Depth: "0" },
162
+ );
163
+
164
+ const principalText = await principalResponse.text();
165
+
166
+ const homeMatch = principalText.match(
167
+ /<[^:]+:calendar-home-set[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s,
168
+ );
169
+ if (!homeMatch) {
170
+ throw new Error("Could not find calendar-home-set");
171
+ }
172
+
173
+ return new URL(homeMatch[1], this.credentials.serverUrl).toString();
174
+ }
175
+
176
+ /**
177
+ * Fetch the current user's email address via the CalDAV principal's
178
+ * calendar-user-address-set property (RFC 4791).
179
+ * Falls back to the configured username if the server doesn't support it.
180
+ */
181
+ async fetchCurrentUserEmail(): Promise<string> {
182
+ const wellKnownUrl = new URL(
183
+ "/.well-known/caldav",
184
+ this.credentials.serverUrl,
185
+ ).toString();
186
+
187
+ const discoveryResponse = await this.request(
188
+ wellKnownUrl,
189
+ "PROPFIND",
190
+ `<?xml version="1.0" encoding="utf-8" ?>
161
191
  <d:propfind xmlns:d="DAV:">
162
192
  <d:prop>
163
193
  <d:current-user-principal/>
164
194
  </d:prop>
165
195
  </d:propfind>`,
166
- { 'Depth': '0' }
167
- );
168
-
169
- const discoveryText = await discoveryResponse.text();
170
- const principalMatch = discoveryText.match(/<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
171
- if (!principalMatch) throw new Error('Could not find current-user-principal');
172
-
173
- const principalUrl = new URL(principalMatch[1], this.credentials.serverUrl).toString();
174
-
175
- const principalResponse = await this.request(
176
- principalUrl,
177
- 'PROPFIND',
178
- `<?xml version="1.0" encoding="utf-8" ?>
196
+ { Depth: "0" },
197
+ );
198
+
199
+ const discoveryText = await discoveryResponse.text();
200
+ const principalMatch = discoveryText.match(
201
+ /<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s,
202
+ );
203
+ if (!principalMatch)
204
+ throw new Error("Could not find current-user-principal");
205
+
206
+ const principalUrl = new URL(
207
+ principalMatch[1],
208
+ this.credentials.serverUrl,
209
+ ).toString();
210
+
211
+ const principalResponse = await this.request(
212
+ principalUrl,
213
+ "PROPFIND",
214
+ `<?xml version="1.0" encoding="utf-8" ?>
179
215
  <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
180
216
  <d:prop>
181
217
  <c:calendar-user-address-set/>
182
218
  </d:prop>
183
219
  </d:propfind>`,
184
- { 'Depth': '0' }
185
- );
186
-
187
- const principalText = await principalResponse.text();
188
- const emailMatch = principalText.match(/mailto:([^\s<"]+)/i);
189
- if (emailMatch) return emailMatch[1];
190
-
191
- throw new Error('Could not determine current user email from CalDAV principal');
192
- }
193
-
194
- /**
195
- * Fetch available calendars from the CalDAV server.
196
- */
197
- async fetchCalendars(): Promise<CalDAVCalendarInfo[]> {
198
- const calendarHome = await this.findCalendarHome();
199
-
200
- const response = await this.request(
201
- calendarHome,
202
- 'PROPFIND',
203
- `<?xml version="1.0" encoding="utf-8" ?>
220
+ { Depth: "0" },
221
+ );
222
+
223
+ const principalText = await principalResponse.text();
224
+ const emailMatch = principalText.match(/mailto:([^\s<"]+)/i);
225
+ if (emailMatch) return emailMatch[1];
226
+
227
+ throw new Error(
228
+ "Could not determine current user email from CalDAV principal",
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Fetch available calendars from the CalDAV server.
234
+ */
235
+ async fetchCalendars(): Promise<CalDAVCalendarInfo[]> {
236
+ const calendarHome = await this.findCalendarHome();
237
+
238
+ const response = await this.request(
239
+ calendarHome,
240
+ "PROPFIND",
241
+ `<?xml version="1.0" encoding="utf-8" ?>
204
242
  <d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/" xmlns:apple="http://apple.com/ns/ical/">
205
243
  <d:prop>
206
244
  <d:resourcetype/>
@@ -210,48 +248,64 @@ export class CalDAVSource implements CalendarSource {
210
248
  <apple:calendar-color/>
211
249
  </d:prop>
212
250
  </d:propfind>`,
213
- { 'Depth': '1' }
214
- );
215
-
216
- const text = await response.text();
217
-
218
- const calendars: CalDAVCalendarInfo[] = [];
219
- const responseRegex = /<[^:]+:response[^>]*>(.*?)<\/[^:]+:response>/gs;
220
-
221
- for (const match of text.matchAll(responseRegex)) {
222
- const responseBlock = match[1];
223
-
224
- if (!responseBlock.includes('calendar') && !responseBlock.includes('CALENDAR')) {
225
- continue;
226
- }
227
-
228
- const hrefMatch = responseBlock.match(/<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/);
229
- const displayNameMatch = responseBlock.match(/<[^:]+:displayname[^>]*>(.*?)<\/[^:]+:displayname>/i);
230
- const ctagMatch = responseBlock.match(/<[^:]+:getctag[^>]*>(.*?)<\/[^:]+:getctag>/i);
231
- const colorMatch = responseBlock.match(/<[^:]+:calendar-color[^>]*>(.*?)<\/[^:]+:calendar-color>/i);
232
-
233
- if (hrefMatch) {
234
- const url = new URL(hrefMatch[1], this.credentials.serverUrl).toString();
235
- calendars.push({
236
- displayName: displayNameMatch?.[1] || 'Unnamed Calendar',
237
- url,
238
- ctag: ctagMatch?.[1] || '',
239
- color: colorMatch?.[1] || undefined,
240
- });
241
- }
242
- }
243
-
244
- return calendars;
245
- }
246
-
247
- /**
248
- * Fetch calendar objects (events) from a specific calendar.
249
- */
250
- private async fetchCalendarObjects(calendarUrl: string): Promise<Array<{ href: string; icalData: string }>> {
251
- const response = await this.request(
252
- calendarUrl,
253
- 'REPORT',
254
- `<?xml version="1.0" encoding="utf-8" ?>
251
+ { Depth: "1" },
252
+ );
253
+
254
+ const text = await response.text();
255
+
256
+ const calendars: CalDAVCalendarInfo[] = [];
257
+ const responseRegex = /<[^:]+:response[^>]*>(.*?)<\/[^:]+:response>/gs;
258
+
259
+ for (const match of text.matchAll(responseRegex)) {
260
+ const responseBlock = match[1];
261
+
262
+ if (
263
+ !responseBlock.includes("calendar") &&
264
+ !responseBlock.includes("CALENDAR")
265
+ ) {
266
+ continue;
267
+ }
268
+
269
+ const hrefMatch = responseBlock.match(
270
+ /<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/,
271
+ );
272
+ const displayNameMatch = responseBlock.match(
273
+ /<[^:]+:displayname[^>]*>(.*?)<\/[^:]+:displayname>/i,
274
+ );
275
+ const ctagMatch = responseBlock.match(
276
+ /<[^:]+:getctag[^>]*>(.*?)<\/[^:]+:getctag>/i,
277
+ );
278
+ const colorMatch = responseBlock.match(
279
+ /<[^:]+:calendar-color[^>]*>(.*?)<\/[^:]+:calendar-color>/i,
280
+ );
281
+
282
+ if (hrefMatch) {
283
+ const url = new URL(
284
+ hrefMatch[1],
285
+ this.credentials.serverUrl,
286
+ ).toString();
287
+ calendars.push({
288
+ displayName: displayNameMatch?.[1] || "Unnamed Calendar",
289
+ url,
290
+ ctag: ctagMatch?.[1] || "",
291
+ color: colorMatch?.[1] || undefined,
292
+ });
293
+ }
294
+ }
295
+
296
+ return calendars;
297
+ }
298
+
299
+ /**
300
+ * Fetch calendar objects (events) from a specific calendar.
301
+ */
302
+ private async fetchCalendarObjects(
303
+ calendarUrl: string,
304
+ ): Promise<Array<{ href: string; icalData: string }>> {
305
+ const response = await this.request(
306
+ calendarUrl,
307
+ "REPORT",
308
+ `<?xml version="1.0" encoding="utf-8" ?>
255
309
  <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
256
310
  <d:prop>
257
311
  <d:getetag/>
@@ -263,277 +317,318 @@ export class CalDAVSource implements CalendarSource {
263
317
  </c:comp-filter>
264
318
  </c:filter>
265
319
  </c:calendar-query>`,
266
- { 'Depth': '1' }
267
- );
268
-
269
- const text = await response.text();
270
-
271
- const calendarObjects: Array<{ href: string; icalData: string }> = [];
272
- const responseRegex = /<[^:]+:response[^>]*>([\s\S]*?)<\/[^:]+:response>/g;
273
-
274
- for (const match of text.matchAll(responseRegex)) {
275
- const responseBlock = match[1];
276
- const hrefMatch = responseBlock.match(/<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/);
277
- const calendarDataMatch = responseBlock.match(/<[^:]+:calendar-data[^>]*>([\s\S]*?)<\/[^:]+:calendar-data>/);
278
- if (!hrefMatch || !calendarDataMatch) continue;
279
-
280
- const href = new URL(hrefMatch[1], this.credentials.serverUrl).toString();
281
- let icalData = calendarDataMatch[1].trim();
282
- // Unescape XML entities
283
- icalData = icalData
284
- .replace(/&lt;/g, '<')
285
- .replace(/&gt;/g, '>')
286
- .replace(/&amp;/g, '&')
287
- .replace(/&quot;/g, '"')
288
- .replace(/&apos;/g, "'");
289
-
290
- if (icalData) {
291
- calendarObjects.push({ href, icalData });
292
- }
293
- }
294
-
295
- return calendarObjects;
296
- }
297
-
298
-
299
- /**
300
- * Serialize a CalendarEvent to iCal format.
301
- */
302
- private serializeEventToICal(event: CalendarEvent): string {
303
- const now = formatICalDate(new Date());
304
- const lines = [
305
- 'BEGIN:VCALENDAR',
306
- 'VERSION:2.0',
307
- 'PRODID:-//Calendar//EN',
308
- 'CALSCALE:GREGORIAN',
309
- 'BEGIN:VEVENT',
310
- `UID:${event.id}`,
311
- `DTSTAMP:${now}`,
312
- `SUMMARY:${event.title}`,
313
- `CREATED:${now}`,
314
- `LAST-MODIFIED:${now}`,
315
- ];
316
-
317
- if (event.isAllDay) {
318
- const startStr = formatICalDate(event.start).slice(0, 8);
319
- const endStr = formatICalDate(event.end).slice(0, 8);
320
- lines.push(`DTSTART;VALUE=DATE:${startStr}`);
321
- lines.push(`DTEND;VALUE=DATE:${endStr}`);
322
- } else {
323
- lines.push(`DTSTART:${formatICalDate(event.start)}`);
324
- lines.push(`DTEND:${formatICalDate(event.end)}`);
325
- }
326
-
327
- if (event.description) {
328
- const escaped = event.description.replace(/\n/g, '\\n');
329
- lines.push(`DESCRIPTION:${escaped}`);
330
- }
331
-
332
- if (event.location) {
333
- lines.push(`LOCATION:${event.location}`);
334
- }
335
-
336
- if (event.url) {
337
- lines.push(`URL:${event.url}`);
338
- }
339
-
340
- if (event.rrule) {
341
- lines.push(`RRULE:${event.rrule}`);
342
- }
343
-
344
- if (event.organizer) {
345
- const cn = event.organizer.name ? `CN="${event.organizer.name}"` : '';
346
- lines.push(`ORGANIZER${cn ? ';' + cn : ''}:mailto:${event.organizer.email}`);
347
- }
348
-
349
- if (event.attendees && event.attendees.length > 0) {
350
- for (const attendee of event.attendees) {
351
- const params: string[] = [];
352
- if (attendee.name) params.push(`CN="${attendee.name}"`);
353
- if (attendee.role) params.push(`ROLE=${attendee.role}`);
354
- if (attendee.status) params.push(`PARTSTAT=${attendee.status}`);
355
- const paramString = params.join(';');
356
- lines.push(`ATTENDEE${paramString ? ';' + paramString : ''}:mailto:${attendee.email}`);
357
- }
358
- }
359
-
360
- if (event.reminders?.length) {
361
- for (const reminder of event.reminders) {
362
- const minutes = reminder.triggerOffset;
363
- lines.push('BEGIN:VALARM');
364
- lines.push('ACTION:DISPLAY');
365
- lines.push(`DESCRIPTION:Reminder`);
366
- lines.push(`TRIGGER:-PT${minutes}M`);
367
- lines.push('END:VALARM');
368
- }
369
- }
370
-
371
- lines.push('END:VEVENT');
372
- lines.push('END:VCALENDAR');
373
-
374
- return lines.join('\r\n');
375
- }
376
-
377
- /**
378
- * Map a parsed VCALENDAR event to CalendarEvent.
379
- */
380
- private mapToCalendarEvent(
381
- parsed: Partial<CalendarEvent>,
382
- calendarDisplayName: string,
383
- calendarUrl: string,
384
- calendarColor?: string,
385
- resourceUrl?: string
386
- ): CalendarEvent | null {
387
- if (!parsed.start || !parsed.id) return null;
388
-
389
- return {
390
- id: parsed.id,
391
- title: parsed.title || 'Untitled Event',
392
- start: parsed.start,
393
- end: parsed.end || parsed.start,
394
- color: calendarColor || this.color,
395
- calendar: calendarDisplayName,
396
- calendarId: calendarUrl,
397
- sourceId: this.id,
398
- description: parsed.description,
399
- location: parsed.location,
400
- url: parsed.url,
401
- rrule: parsed.rrule,
402
- organizer: parsed.organizer,
403
- attendees: parsed.attendees,
404
- readOnly: false,
405
- isAllDay: parsed.isAllDay,
406
- reminders: parsed.reminders,
407
- resourceUrl,
408
- };
409
- }
410
-
411
- /**
412
- * Fetch events from a single CalDAV calendar URL.
413
- */
414
- async fetchEventsForCalendar(calendarUrl: string, displayName: string, color?: string): Promise<CalendarEvent[]> {
415
- const calendarObjects = await this.fetchCalendarObjects(calendarUrl);
416
- const events: CalendarEvent[] = [];
417
-
418
- for (const calendarObject of calendarObjects) {
419
- const event = this.mapToCalendarEvent(
420
- parseSingleICalEvent(calendarObject.icalData),
421
- displayName,
422
- calendarUrl,
423
- color,
424
- calendarObject.href
425
- );
426
- if (!event) continue;
427
- events.push(event);
428
- }
429
-
430
- return events;
431
- }
432
-
433
- /**
434
- * Fetch events from the CalDAV server.
435
- * Fetches events from all available calendars.
436
- */
437
- async fetchEvents(): Promise<CalendarEvent[]> {
438
- if (!this.enabled) return [];
439
-
440
- const calendars = await this.fetchCalendars();
441
- const allEvents: CalendarEvent[] = [];
442
-
443
- for (const calendar of calendars) {
444
- const events = await this.fetchEventsForCalendar(calendar.url, calendar.displayName, calendar.color);
445
- allEvents.push(...events);
446
- }
447
-
448
- return allEvents;
449
- }
450
-
451
- /**
452
- * Create a new event on the CalDAV server.
453
- * The event must have calendarId set to the target calendar URL.
454
- */
455
- async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
456
- if (!event.calendarId) throw new Error('Cannot create event: calendarId is required');
457
- const calendarUrl = event.calendarId;
458
-
459
- const fullEvent: CalendarEvent = {
460
- ...event,
461
- calendar: this.name,
462
- color: this.color,
463
- calendarId: calendarUrl,
464
- sourceId: this.id,
465
- };
466
-
467
- const eventUrl = `${calendarUrl.replace(/\/$/, '')}/${event.id}.ics`;
468
- const icalData = this.serializeEventToICal(fullEvent);
469
-
470
- await this.request(eventUrl, 'PUT', icalData, {
471
- 'Content-Type': 'text/calendar; charset=utf-8',
472
- });
473
- fullEvent.resourceUrl = eventUrl;
474
-
475
- return fullEvent;
476
- }
477
-
478
- /**
479
- * Update an existing event on the CalDAV server.
480
- * Derives the calendar URL from updates.calendarId, falling back to the event cache.
481
- */
482
- async updateEvent(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
483
- const id = event.id;
484
- const calendarUrl = updates.calendarId ?? event.calendarId;
485
- if (!calendarUrl) throw new Error(`Cannot update event ${id}: calendar URL unknown`);
486
- const eventUrl = updates.resourceUrl ?? event.resourceUrl ?? `${calendarUrl.replace(/\/$/, '')}/${id}.ics`;
487
-
488
- // For updates, we need to fetch the existing event first
489
- // This is a simplified implementation
490
- const response = await this.request(eventUrl, 'GET', undefined, {
491
- 'Accept': 'text/calendar',
492
- });
493
-
494
- const existingIcal = await response.text();
495
- const existing = parseSingleICalEvent(existingIcal);
496
-
497
- const updatedEvent: CalendarEvent = {
498
- id,
499
- title: updates.title ?? existing.title ?? 'Untitled Event',
500
- start: updates.start ?? existing.start ?? new Date(),
501
- end: updates.end ?? existing.end ?? new Date(),
502
- color: this.color,
503
- calendar: this.name,
504
- calendarId: updates.calendarId ?? existing.calendarId ?? calendarUrl,
505
- sourceId: updates.sourceId ?? existing.sourceId ?? this.id,
506
- description: updates.description !== undefined ? updates.description : existing.description,
507
- location: updates.location !== undefined ? updates.location : existing.location,
508
- url: updates.url !== undefined ? updates.url : existing.url,
509
- rrule: updates.rrule !== undefined ? updates.rrule : existing.rrule,
510
- organizer: updates.organizer !== undefined ? updates.organizer : existing.organizer,
511
- attendees: updates.attendees !== undefined ? updates.attendees : existing.attendees,
512
- readOnly: false,
513
- isAllDay: updates.isAllDay !== undefined ? updates.isAllDay : existing.isAllDay,
514
- reminders: updates.reminders !== undefined ? updates.reminders : existing.reminders,
515
- resourceUrl: updates.resourceUrl ?? eventUrl,
516
- };
517
-
518
- const icalData = this.serializeEventToICal(updatedEvent);
519
-
520
- await this.request(eventUrl, 'PUT', icalData, {
521
- 'Content-Type': 'text/calendar; charset=utf-8',
522
- });
523
-
524
- return updatedEvent;
525
- }
526
-
527
- /**
528
- * Test the connection to the CalDAV server.
529
- * Returns true if the credentials are valid.
530
- */
531
- async testConnection(): Promise<boolean> {
532
- try {
533
- await this.findCalendarHome();
534
- return true;
535
- } catch (error) {
536
- return false;
537
- }
538
- }
320
+ { Depth: "1" },
321
+ );
322
+
323
+ const text = await response.text();
324
+
325
+ const calendarObjects: Array<{ href: string; icalData: string }> = [];
326
+ const responseRegex = /<[^:]+:response[^>]*>([\s\S]*?)<\/[^:]+:response>/g;
327
+
328
+ for (const match of text.matchAll(responseRegex)) {
329
+ const responseBlock = match[1];
330
+ const hrefMatch = responseBlock.match(
331
+ /<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/,
332
+ );
333
+ const calendarDataMatch = responseBlock.match(
334
+ /<[^:]+:calendar-data[^>]*>([\s\S]*?)<\/[^:]+:calendar-data>/,
335
+ );
336
+ if (!hrefMatch || !calendarDataMatch) continue;
337
+
338
+ const href = new URL(hrefMatch[1], this.credentials.serverUrl).toString();
339
+ let icalData = calendarDataMatch[1].trim();
340
+ // Unescape XML entities
341
+ icalData = icalData
342
+ .replace(/&lt;/g, "<")
343
+ .replace(/&gt;/g, ">")
344
+ .replace(/&amp;/g, "&")
345
+ .replace(/&quot;/g, '"')
346
+ .replace(/&apos;/g, "'");
347
+
348
+ if (icalData) {
349
+ calendarObjects.push({ href, icalData });
350
+ }
351
+ }
352
+
353
+ return calendarObjects;
354
+ }
355
+
356
+ /**
357
+ * Serialize a CalendarEvent to iCal format.
358
+ */
359
+ private serializeEventToICal(event: CalendarEvent): string {
360
+ const now = formatICalDate(new Date());
361
+ const lines = [
362
+ "BEGIN:VCALENDAR",
363
+ "VERSION:2.0",
364
+ "PRODID:-//Calendar//EN",
365
+ "CALSCALE:GREGORIAN",
366
+ "BEGIN:VEVENT",
367
+ `UID:${event.id}`,
368
+ `DTSTAMP:${now}`,
369
+ `SUMMARY:${event.title}`,
370
+ `CREATED:${now}`,
371
+ `LAST-MODIFIED:${now}`,
372
+ ];
373
+
374
+ if (event.isAllDay) {
375
+ const startStr = formatICalDate(event.start).slice(0, 8);
376
+ const endStr = formatICalDate(event.end).slice(0, 8);
377
+ lines.push(`DTSTART;VALUE=DATE:${startStr}`);
378
+ lines.push(`DTEND;VALUE=DATE:${endStr}`);
379
+ } else {
380
+ lines.push(`DTSTART:${formatICalDate(event.start)}`);
381
+ lines.push(`DTEND:${formatICalDate(event.end)}`);
382
+ }
383
+
384
+ if (event.description) {
385
+ const escaped = event.description.replace(/\n/g, "\\n");
386
+ lines.push(`DESCRIPTION:${escaped}`);
387
+ }
388
+
389
+ if (event.location) {
390
+ lines.push(`LOCATION:${event.location}`);
391
+ }
392
+
393
+ if (event.url) {
394
+ lines.push(`URL:${event.url}`);
395
+ }
396
+
397
+ if (event.rrule) {
398
+ lines.push(`RRULE:${event.rrule}`);
399
+ }
400
+
401
+ if (event.organizer) {
402
+ const cn = event.organizer.name ? `CN="${event.organizer.name}"` : "";
403
+ lines.push(
404
+ `ORGANIZER${cn ? `;${cn}` : ""}:mailto:${event.organizer.email}`,
405
+ );
406
+ }
407
+
408
+ if (event.attendees && event.attendees.length > 0) {
409
+ for (const attendee of event.attendees) {
410
+ const params: string[] = [];
411
+ if (attendee.name) params.push(`CN="${attendee.name}"`);
412
+ if (attendee.role) params.push(`ROLE=${attendee.role}`);
413
+ if (attendee.status) params.push(`PARTSTAT=${attendee.status}`);
414
+ const paramString = params.join(";");
415
+ lines.push(
416
+ `ATTENDEE${paramString ? `;${paramString}` : ""}:mailto:${
417
+ attendee.email
418
+ }`,
419
+ );
420
+ }
421
+ }
422
+
423
+ if (event.reminders?.length) {
424
+ for (const reminder of event.reminders) {
425
+ const minutes = reminder.triggerOffset;
426
+ lines.push("BEGIN:VALARM");
427
+ lines.push("ACTION:DISPLAY");
428
+ lines.push("DESCRIPTION:Reminder");
429
+ lines.push(`TRIGGER:-PT${minutes}M`);
430
+ lines.push("END:VALARM");
431
+ }
432
+ }
433
+
434
+ lines.push("END:VEVENT");
435
+ lines.push("END:VCALENDAR");
436
+
437
+ return lines.join("\r\n");
438
+ }
439
+
440
+ /**
441
+ * Map a parsed VCALENDAR event to CalendarEvent.
442
+ */
443
+ private mapToCalendarEvent(
444
+ parsed: Partial<CalendarEvent>,
445
+ calendarDisplayName: string,
446
+ calendarUrl: string,
447
+ calendarColor?: string,
448
+ resourceUrl?: string,
449
+ ): CalendarEvent | null {
450
+ if (!parsed.start || !parsed.id) return null;
451
+
452
+ return {
453
+ id: parsed.id,
454
+ title: parsed.title || "Untitled Event",
455
+ start: parsed.start,
456
+ end: parsed.end || parsed.start,
457
+ color: calendarColor || this.color,
458
+ calendar: calendarDisplayName,
459
+ calendarId: calendarUrl,
460
+ sourceId: this.id,
461
+ description: parsed.description,
462
+ location: parsed.location,
463
+ url: parsed.url,
464
+ rrule: parsed.rrule,
465
+ organizer: parsed.organizer,
466
+ attendees: parsed.attendees,
467
+ readOnly: false,
468
+ isAllDay: parsed.isAllDay,
469
+ reminders: parsed.reminders,
470
+ resourceUrl,
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Fetch events from a single CalDAV calendar URL.
476
+ */
477
+ async fetchEventsForCalendar(
478
+ calendarUrl: string,
479
+ displayName: string,
480
+ color?: string,
481
+ ): Promise<CalendarEvent[]> {
482
+ const calendarObjects = await this.fetchCalendarObjects(calendarUrl);
483
+ const events: CalendarEvent[] = [];
484
+
485
+ for (const calendarObject of calendarObjects) {
486
+ const event = this.mapToCalendarEvent(
487
+ parseSingleICalEvent(calendarObject.icalData),
488
+ displayName,
489
+ calendarUrl,
490
+ color,
491
+ calendarObject.href,
492
+ );
493
+ if (!event) continue;
494
+ events.push(event);
495
+ }
496
+
497
+ return events;
498
+ }
499
+
500
+ /**
501
+ * Fetch events from the CalDAV server.
502
+ * Fetches events from all available calendars.
503
+ */
504
+ async fetchEvents(): Promise<CalendarEvent[]> {
505
+ if (!this.enabled) return [];
506
+
507
+ const calendars = await this.fetchCalendars();
508
+ const allEvents: CalendarEvent[] = [];
509
+
510
+ for (const calendar of calendars) {
511
+ const events = await this.fetchEventsForCalendar(
512
+ calendar.url,
513
+ calendar.displayName,
514
+ calendar.color,
515
+ );
516
+ allEvents.push(...events);
517
+ }
518
+
519
+ return allEvents;
520
+ }
521
+
522
+ /**
523
+ * Create a new event on the CalDAV server.
524
+ * The event must have calendarId set to the target calendar URL.
525
+ */
526
+ async createEvent(
527
+ event: Omit<CalendarEvent, "calendar" | "color">,
528
+ ): Promise<CalendarEvent> {
529
+ if (!event.calendarId)
530
+ throw new Error("Cannot create event: calendarId is required");
531
+ const calendarUrl = event.calendarId;
532
+
533
+ const fullEvent: CalendarEvent = {
534
+ ...event,
535
+ calendar: this.name,
536
+ color: this.color,
537
+ calendarId: calendarUrl,
538
+ sourceId: this.id,
539
+ };
540
+
541
+ const eventUrl = `${calendarUrl.replace(/\/$/, "")}/${event.id}.ics`;
542
+ const icalData = this.serializeEventToICal(fullEvent);
543
+
544
+ await this.request(eventUrl, "PUT", icalData, {
545
+ "Content-Type": "text/calendar; charset=utf-8",
546
+ });
547
+ fullEvent.resourceUrl = eventUrl;
548
+
549
+ return fullEvent;
550
+ }
551
+
552
+ /**
553
+ * Update an existing event on the CalDAV server.
554
+ * Derives the calendar URL from updates.calendarId, falling back to the event cache.
555
+ */
556
+ async updateEvent(
557
+ event: CalendarEvent,
558
+ updates: Partial<CalendarEvent>,
559
+ ): Promise<CalendarEvent> {
560
+ const id = event.id;
561
+ const calendarUrl = updates.calendarId ?? event.calendarId;
562
+ if (!calendarUrl)
563
+ throw new Error(`Cannot update event ${id}: calendar URL unknown`);
564
+ const eventUrl =
565
+ updates.resourceUrl ??
566
+ event.resourceUrl ??
567
+ `${calendarUrl.replace(/\/$/, "")}/${id}.ics`;
568
+
569
+ // For updates, we need to fetch the existing event first
570
+ // This is a simplified implementation
571
+ const response = await this.request(eventUrl, "GET", undefined, {
572
+ Accept: "text/calendar",
573
+ });
574
+
575
+ const existingIcal = await response.text();
576
+ const existing = parseSingleICalEvent(existingIcal);
577
+
578
+ const updatedEvent: CalendarEvent = {
579
+ id,
580
+ title: updates.title ?? existing.title ?? "Untitled Event",
581
+ start: updates.start ?? existing.start ?? new Date(),
582
+ end: updates.end ?? existing.end ?? new Date(),
583
+ color: this.color,
584
+ calendar: this.name,
585
+ calendarId: updates.calendarId ?? existing.calendarId ?? calendarUrl,
586
+ sourceId: updates.sourceId ?? existing.sourceId ?? this.id,
587
+ description:
588
+ updates.description !== undefined
589
+ ? updates.description
590
+ : existing.description,
591
+ location:
592
+ updates.location !== undefined ? updates.location : existing.location,
593
+ url: updates.url !== undefined ? updates.url : existing.url,
594
+ rrule: updates.rrule !== undefined ? updates.rrule : existing.rrule,
595
+ organizer:
596
+ updates.organizer !== undefined
597
+ ? updates.organizer
598
+ : existing.organizer,
599
+ attendees:
600
+ updates.attendees !== undefined
601
+ ? updates.attendees
602
+ : existing.attendees,
603
+ readOnly: false,
604
+ isAllDay:
605
+ updates.isAllDay !== undefined ? updates.isAllDay : existing.isAllDay,
606
+ reminders:
607
+ updates.reminders !== undefined
608
+ ? updates.reminders
609
+ : existing.reminders,
610
+ resourceUrl: updates.resourceUrl ?? eventUrl,
611
+ };
612
+
613
+ const icalData = this.serializeEventToICal(updatedEvent);
614
+
615
+ await this.request(eventUrl, "PUT", icalData, {
616
+ "Content-Type": "text/calendar; charset=utf-8",
617
+ });
618
+
619
+ return updatedEvent;
620
+ }
621
+
622
+ /**
623
+ * Test the connection to the CalDAV server.
624
+ * Returns true if the credentials are valid.
625
+ */
626
+ async testConnection(): Promise<boolean> {
627
+ try {
628
+ await this.findCalendarHome();
629
+ return true;
630
+ } catch (error) {
631
+ return false;
632
+ }
633
+ }
539
634
  }