@luckydye/calendar 1.1.0 → 1.1.2
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.
- package/dist/calendar.js +992 -1056
- package/package.json +4 -3
- package/src/ActiveCalendarStore.ts +96 -0
- package/src/CalDAVConfig.ts +1000 -0
- package/src/CalDAVSource.ts +506 -0
- package/src/CalendarIntegration.ts +68 -0
- package/src/CalendarInternal.ts +609 -0
- package/src/CalendarStorage.ts +54 -0
- package/src/CalendarView.ts +5290 -0
- package/src/Color.ts +64 -0
- package/src/GoogleCalendarSource.ts +717 -0
- package/src/ICal.ts +400 -0
- package/src/InMemorySource.ts +89 -0
- package/src/IndexedDBStorage.ts +393 -0
- package/src/InhouseBookingSource.ts +237 -0
- package/src/NotificationScheduler.ts +91 -0
- package/src/StatusBar.ts +128 -0
- package/src/StatusMessage.ts +122 -0
- package/src/Theme.ts +228 -0
- package/src/app.css +4 -0
- package/src/app.ts +932 -0
- package/src/lib.ts +4 -0
- package/src/service-worker.js +177 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import type { CalendarEvent } from "./CalendarInternal.js";
|
|
2
|
+
import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
|
|
3
|
+
import { parseSingleICalEvent, formatICalDate } from "./ICal.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CalDAV credentials.
|
|
7
|
+
*/
|
|
8
|
+
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;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CalDAV calendar information.
|
|
25
|
+
*/
|
|
26
|
+
interface CalDAVCalendarInfo {
|
|
27
|
+
displayName: string;
|
|
28
|
+
url: string;
|
|
29
|
+
ctag: string;
|
|
30
|
+
color?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* CalDAV source for syncing with CalDAV servers.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* const source = new CalDAVSource(
|
|
38
|
+
* 'caldav-1',
|
|
39
|
+
* 'My CalDAV Calendar',
|
|
40
|
+
* '#FF6E68',
|
|
41
|
+
* {
|
|
42
|
+
* serverUrl: 'https://mail.example.com/caldav/users/username/',
|
|
43
|
+
* username: 'user@example.com',
|
|
44
|
+
* password: 'password123'
|
|
45
|
+
* }
|
|
46
|
+
* );
|
|
47
|
+
*
|
|
48
|
+
* const events = await source.fetchEvents();
|
|
49
|
+
*/
|
|
50
|
+
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 response = await fetch(url, {
|
|
78
|
+
method,
|
|
79
|
+
headers: {
|
|
80
|
+
'Authorization': this.getAuthHeader(),
|
|
81
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
82
|
+
...headers,
|
|
83
|
+
},
|
|
84
|
+
body,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`CalDAV request failed: ${response.status} ${response.statusText}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Find the calendar home URL using the well-known CalDAV endpoint.
|
|
96
|
+
*/
|
|
97
|
+
private async findCalendarHome(): Promise<string> {
|
|
98
|
+
const wellKnownUrl = new URL('/.well-known/caldav', this.credentials.serverUrl).toString();
|
|
99
|
+
|
|
100
|
+
const response = await this.request(
|
|
101
|
+
wellKnownUrl,
|
|
102
|
+
'PROPFIND',
|
|
103
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
104
|
+
<d:propfind xmlns:d="DAV:">
|
|
105
|
+
<d:prop>
|
|
106
|
+
<d:current-user-principal/>
|
|
107
|
+
</d:prop>
|
|
108
|
+
</d:propfind>`,
|
|
109
|
+
{ 'Depth': '0' }
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const text = await response.text();
|
|
113
|
+
|
|
114
|
+
const principalMatch = text.match(/<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
|
|
115
|
+
if (!principalMatch) {
|
|
116
|
+
throw new Error('Could not find current-user-principal');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const principalPath = principalMatch[1];
|
|
120
|
+
const principalUrl = new URL(principalPath, this.credentials.serverUrl).toString();
|
|
121
|
+
|
|
122
|
+
const principalResponse = await this.request(
|
|
123
|
+
principalUrl,
|
|
124
|
+
'PROPFIND',
|
|
125
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
126
|
+
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
127
|
+
<d:prop>
|
|
128
|
+
<c:calendar-home-set/>
|
|
129
|
+
</d:prop>
|
|
130
|
+
</d:propfind>`,
|
|
131
|
+
{ 'Depth': '0' }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const principalText = await principalResponse.text();
|
|
135
|
+
|
|
136
|
+
const homeMatch = principalText.match(/<[^:]+:calendar-home-set[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
|
|
137
|
+
if (!homeMatch) {
|
|
138
|
+
throw new Error('Could not find calendar-home-set');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return new URL(homeMatch[1], this.credentials.serverUrl).toString();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Fetch the current user's email address via the CalDAV principal's
|
|
146
|
+
* calendar-user-address-set property (RFC 4791).
|
|
147
|
+
* Falls back to the configured username if the server doesn't support it.
|
|
148
|
+
*/
|
|
149
|
+
async fetchCurrentUserEmail(): Promise<string> {
|
|
150
|
+
const wellKnownUrl = new URL('/.well-known/caldav', this.credentials.serverUrl).toString();
|
|
151
|
+
|
|
152
|
+
const discoveryResponse = await this.request(
|
|
153
|
+
wellKnownUrl,
|
|
154
|
+
'PROPFIND',
|
|
155
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
156
|
+
<d:propfind xmlns:d="DAV:">
|
|
157
|
+
<d:prop>
|
|
158
|
+
<d:current-user-principal/>
|
|
159
|
+
</d:prop>
|
|
160
|
+
</d:propfind>`,
|
|
161
|
+
{ 'Depth': '0' }
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const discoveryText = await discoveryResponse.text();
|
|
165
|
+
const principalMatch = discoveryText.match(/<[^:]+:current-user-principal[^>]*>.*?<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/s);
|
|
166
|
+
if (!principalMatch) throw new Error('Could not find current-user-principal');
|
|
167
|
+
|
|
168
|
+
const principalUrl = new URL(principalMatch[1], this.credentials.serverUrl).toString();
|
|
169
|
+
|
|
170
|
+
const principalResponse = await this.request(
|
|
171
|
+
principalUrl,
|
|
172
|
+
'PROPFIND',
|
|
173
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
174
|
+
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
175
|
+
<d:prop>
|
|
176
|
+
<c:calendar-user-address-set/>
|
|
177
|
+
</d:prop>
|
|
178
|
+
</d:propfind>`,
|
|
179
|
+
{ 'Depth': '0' }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const principalText = await principalResponse.text();
|
|
183
|
+
const emailMatch = principalText.match(/mailto:([^\s<"]+)/i);
|
|
184
|
+
if (emailMatch) return emailMatch[1];
|
|
185
|
+
|
|
186
|
+
throw new Error('Could not determine current user email from CalDAV principal');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fetch available calendars from the CalDAV server.
|
|
191
|
+
*/
|
|
192
|
+
async fetchCalendars(): Promise<CalDAVCalendarInfo[]> {
|
|
193
|
+
const calendarHome = await this.findCalendarHome();
|
|
194
|
+
|
|
195
|
+
const response = await this.request(
|
|
196
|
+
calendarHome,
|
|
197
|
+
'PROPFIND',
|
|
198
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
199
|
+
<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/">
|
|
200
|
+
<d:prop>
|
|
201
|
+
<d:resourcetype/>
|
|
202
|
+
<d:displayname/>
|
|
203
|
+
<c:supported-calendar-component-set/>
|
|
204
|
+
<cs:getctag/>
|
|
205
|
+
<apple:calendar-color/>
|
|
206
|
+
</d:prop>
|
|
207
|
+
</d:propfind>`,
|
|
208
|
+
{ 'Depth': '1' }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const text = await response.text();
|
|
212
|
+
|
|
213
|
+
const calendars: CalDAVCalendarInfo[] = [];
|
|
214
|
+
const responseRegex = /<[^:]+:response[^>]*>(.*?)<\/[^:]+:response>/gs;
|
|
215
|
+
|
|
216
|
+
for (const match of text.matchAll(responseRegex)) {
|
|
217
|
+
const responseBlock = match[1];
|
|
218
|
+
|
|
219
|
+
if (!responseBlock.includes('calendar') && !responseBlock.includes('CALENDAR')) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const hrefMatch = responseBlock.match(/<[^:]+:href[^>]*>(.*?)<\/[^:]+:href>/);
|
|
224
|
+
const displayNameMatch = responseBlock.match(/<[^:]+:displayname[^>]*>(.*?)<\/[^:]+:displayname>/i);
|
|
225
|
+
const ctagMatch = responseBlock.match(/<[^:]+:getctag[^>]*>(.*?)<\/[^:]+:getctag>/i);
|
|
226
|
+
const colorMatch = responseBlock.match(/<[^:]+:calendar-color[^>]*>(.*?)<\/[^:]+:calendar-color>/i);
|
|
227
|
+
|
|
228
|
+
if (hrefMatch) {
|
|
229
|
+
const url = new URL(hrefMatch[1], this.credentials.serverUrl).toString();
|
|
230
|
+
calendars.push({
|
|
231
|
+
displayName: displayNameMatch?.[1] || 'Unnamed Calendar',
|
|
232
|
+
url,
|
|
233
|
+
ctag: ctagMatch?.[1] || '',
|
|
234
|
+
color: colorMatch?.[1] || undefined,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return calendars;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch calendar objects (events) from a specific calendar.
|
|
244
|
+
*/
|
|
245
|
+
private async fetchCalendarObjects(calendarUrl: string): Promise<string[]> {
|
|
246
|
+
const response = await this.request(
|
|
247
|
+
calendarUrl,
|
|
248
|
+
'REPORT',
|
|
249
|
+
`<?xml version="1.0" encoding="utf-8" ?>
|
|
250
|
+
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
251
|
+
<d:prop>
|
|
252
|
+
<d:getetag/>
|
|
253
|
+
<c:calendar-data/>
|
|
254
|
+
</d:prop>
|
|
255
|
+
<c:filter>
|
|
256
|
+
<c:comp-filter name="VCALENDAR">
|
|
257
|
+
<c:comp-filter name="VEVENT"/>
|
|
258
|
+
</c:comp-filter>
|
|
259
|
+
</c:filter>
|
|
260
|
+
</c:calendar-query>`,
|
|
261
|
+
{ 'Depth': '1' }
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const text = await response.text();
|
|
265
|
+
|
|
266
|
+
const calendarData: string[] = [];
|
|
267
|
+
const calendarDataRegex = /<[^:]+:calendar-data[^>]*>(.*?)<\/[^:]+:calendar-data>/gs;
|
|
268
|
+
|
|
269
|
+
for (const match of text.matchAll(calendarDataRegex)) {
|
|
270
|
+
let icalData = match[1].trim();
|
|
271
|
+
// Unescape XML entities
|
|
272
|
+
icalData = icalData
|
|
273
|
+
.replace(/</g, '<')
|
|
274
|
+
.replace(/>/g, '>')
|
|
275
|
+
.replace(/&/g, '&')
|
|
276
|
+
.replace(/"/g, '"')
|
|
277
|
+
.replace(/'/g, "'");
|
|
278
|
+
|
|
279
|
+
if (icalData) {
|
|
280
|
+
calendarData.push(icalData);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return calendarData;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Serialize a CalendarEvent to iCal format.
|
|
290
|
+
*/
|
|
291
|
+
private serializeEventToICal(event: CalendarEvent): string {
|
|
292
|
+
const now = formatICalDate(new Date());
|
|
293
|
+
const lines = [
|
|
294
|
+
'BEGIN:VCALENDAR',
|
|
295
|
+
'VERSION:2.0',
|
|
296
|
+
'PRODID:-//Calendar//EN',
|
|
297
|
+
'CALSCALE:GREGORIAN',
|
|
298
|
+
'BEGIN:VEVENT',
|
|
299
|
+
`UID:${event.id}`,
|
|
300
|
+
`DTSTAMP:${now}`,
|
|
301
|
+
`SUMMARY:${event.title}`,
|
|
302
|
+
`CREATED:${now}`,
|
|
303
|
+
`LAST-MODIFIED:${now}`,
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
if (event.isAllDay) {
|
|
307
|
+
const startStr = formatICalDate(event.start).slice(0, 8);
|
|
308
|
+
const endStr = formatICalDate(event.end).slice(0, 8);
|
|
309
|
+
lines.push(`DTSTART;VALUE=DATE:${startStr}`);
|
|
310
|
+
lines.push(`DTEND;VALUE=DATE:${endStr}`);
|
|
311
|
+
} else {
|
|
312
|
+
lines.push(`DTSTART:${formatICalDate(event.start)}`);
|
|
313
|
+
lines.push(`DTEND:${formatICalDate(event.end)}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (event.description) {
|
|
317
|
+
const escaped = event.description.replace(/\n/g, '\\n');
|
|
318
|
+
lines.push(`DESCRIPTION:${escaped}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (event.location) {
|
|
322
|
+
lines.push(`LOCATION:${event.location}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (event.url) {
|
|
326
|
+
lines.push(`URL:${event.url}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (event.rrule) {
|
|
330
|
+
lines.push(`RRULE:${event.rrule}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (event.organizer) {
|
|
334
|
+
const cn = event.organizer.name ? `CN="${event.organizer.name}"` : '';
|
|
335
|
+
lines.push(`ORGANIZER${cn ? ';' + cn : ''}:mailto:${event.organizer.email}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (event.attendees && event.attendees.length > 0) {
|
|
339
|
+
for (const attendee of event.attendees) {
|
|
340
|
+
const params: string[] = [];
|
|
341
|
+
if (attendee.name) params.push(`CN="${attendee.name}"`);
|
|
342
|
+
if (attendee.role) params.push(`ROLE=${attendee.role}`);
|
|
343
|
+
if (attendee.status) params.push(`PARTSTAT=${attendee.status}`);
|
|
344
|
+
const paramString = params.join(';');
|
|
345
|
+
lines.push(`ATTENDEE${paramString ? ';' + paramString : ''}:mailto:${attendee.email}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (event.reminders?.length) {
|
|
350
|
+
for (const reminder of event.reminders) {
|
|
351
|
+
const minutes = reminder.triggerOffset;
|
|
352
|
+
lines.push('BEGIN:VALARM');
|
|
353
|
+
lines.push('ACTION:DISPLAY');
|
|
354
|
+
lines.push(`DESCRIPTION:Reminder`);
|
|
355
|
+
lines.push(`TRIGGER:-PT${minutes}M`);
|
|
356
|
+
lines.push('END:VALARM');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
lines.push('END:VEVENT');
|
|
361
|
+
lines.push('END:VCALENDAR');
|
|
362
|
+
|
|
363
|
+
return lines.join('\r\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Map a parsed VCALENDAR event to CalendarEvent.
|
|
368
|
+
*/
|
|
369
|
+
private mapToCalendarEvent(parsed: Partial<CalendarEvent>, calendarDisplayName: string, calendarUrl: string, calendarColor?: string): CalendarEvent | null {
|
|
370
|
+
if (!parsed.start || !parsed.id) return null;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
id: parsed.id,
|
|
374
|
+
title: parsed.title || 'Untitled Event',
|
|
375
|
+
start: parsed.start,
|
|
376
|
+
end: parsed.end || parsed.start,
|
|
377
|
+
color: calendarColor || this.color,
|
|
378
|
+
calendar: calendarDisplayName,
|
|
379
|
+
calendarId: calendarUrl,
|
|
380
|
+
sourceId: this.id,
|
|
381
|
+
description: parsed.description,
|
|
382
|
+
location: parsed.location,
|
|
383
|
+
url: parsed.url,
|
|
384
|
+
rrule: parsed.rrule,
|
|
385
|
+
organizer: parsed.organizer,
|
|
386
|
+
attendees: parsed.attendees,
|
|
387
|
+
readOnly: false,
|
|
388
|
+
isAllDay: parsed.isAllDay,
|
|
389
|
+
reminders: parsed.reminders,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Fetch events from a single CalDAV calendar URL.
|
|
395
|
+
*/
|
|
396
|
+
async fetchEventsForCalendar(calendarUrl: string, displayName: string, color?: string): Promise<CalendarEvent[]> {
|
|
397
|
+
const calendarObjects = await this.fetchCalendarObjects(calendarUrl);
|
|
398
|
+
return calendarObjects
|
|
399
|
+
.map(ical => this.mapToCalendarEvent(parseSingleICalEvent(ical), displayName, calendarUrl, color))
|
|
400
|
+
.filter((e): e is CalendarEvent => e !== null);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Fetch events from the CalDAV server.
|
|
405
|
+
* Fetches events from all available calendars.
|
|
406
|
+
*/
|
|
407
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
408
|
+
if (!this.enabled) return [];
|
|
409
|
+
|
|
410
|
+
const calendars = await this.fetchCalendars();
|
|
411
|
+
const allEvents: CalendarEvent[] = [];
|
|
412
|
+
|
|
413
|
+
for (const calendar of calendars) {
|
|
414
|
+
const events = await this.fetchEventsForCalendar(calendar.url, calendar.displayName, calendar.color);
|
|
415
|
+
allEvents.push(...events);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return allEvents;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Create a new event on the CalDAV server.
|
|
423
|
+
* The event must have calendarId set to the target calendar URL.
|
|
424
|
+
*/
|
|
425
|
+
async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
|
|
426
|
+
if (!event.calendarId) throw new Error('Cannot create event: calendarId is required');
|
|
427
|
+
const calendarUrl = event.calendarId;
|
|
428
|
+
|
|
429
|
+
const fullEvent: CalendarEvent = {
|
|
430
|
+
...event,
|
|
431
|
+
calendar: this.name,
|
|
432
|
+
color: this.color,
|
|
433
|
+
calendarId: calendarUrl,
|
|
434
|
+
sourceId: this.id,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const eventUrl = `${calendarUrl.replace(/\/$/, '')}/${event.id}.ics`;
|
|
438
|
+
const icalData = this.serializeEventToICal(fullEvent);
|
|
439
|
+
|
|
440
|
+
await this.request(eventUrl, 'PUT', icalData, {
|
|
441
|
+
'Content-Type': 'text/calendar; charset=utf-8',
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
return fullEvent;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Update an existing event on the CalDAV server.
|
|
449
|
+
* Derives the calendar URL from updates.calendarId, falling back to the event cache.
|
|
450
|
+
*/
|
|
451
|
+
async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
|
452
|
+
const calendarUrl = updates.calendarId ?? this.eventCalendarMap.get(id);
|
|
453
|
+
if (!calendarUrl) throw new Error(`Cannot update event ${id}: calendar URL unknown`);
|
|
454
|
+
const eventUrl = `${calendarUrl.replace(/\/$/, '')}/${id}.ics`;
|
|
455
|
+
|
|
456
|
+
// For updates, we need to fetch the existing event first
|
|
457
|
+
// This is a simplified implementation
|
|
458
|
+
const response = await this.request(eventUrl, 'GET', undefined, {
|
|
459
|
+
'Accept': 'text/calendar',
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const existingIcal = await response.text();
|
|
463
|
+
const existing = parseSingleICalEvent(existingIcal);
|
|
464
|
+
|
|
465
|
+
const updatedEvent: CalendarEvent = {
|
|
466
|
+
id,
|
|
467
|
+
title: updates.title ?? existing.title ?? 'Untitled Event',
|
|
468
|
+
start: updates.start ?? existing.start ?? new Date(),
|
|
469
|
+
end: updates.end ?? existing.end ?? new Date(),
|
|
470
|
+
color: this.color,
|
|
471
|
+
calendar: this.name,
|
|
472
|
+
calendarId: updates.calendarId ?? existing.calendarId ?? calendarUrl,
|
|
473
|
+
sourceId: updates.sourceId ?? existing.sourceId ?? this.id,
|
|
474
|
+
description: updates.description !== undefined ? updates.description : existing.description,
|
|
475
|
+
location: updates.location !== undefined ? updates.location : existing.location,
|
|
476
|
+
url: updates.url !== undefined ? updates.url : existing.url,
|
|
477
|
+
rrule: updates.rrule !== undefined ? updates.rrule : existing.rrule,
|
|
478
|
+
organizer: updates.organizer !== undefined ? updates.organizer : existing.organizer,
|
|
479
|
+
attendees: updates.attendees !== undefined ? updates.attendees : existing.attendees,
|
|
480
|
+
readOnly: false,
|
|
481
|
+
isAllDay: updates.isAllDay !== undefined ? updates.isAllDay : existing.isAllDay,
|
|
482
|
+
reminders: updates.reminders !== undefined ? updates.reminders : existing.reminders,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const icalData = this.serializeEventToICal(updatedEvent);
|
|
486
|
+
|
|
487
|
+
await this.request(eventUrl, 'PUT', icalData, {
|
|
488
|
+
'Content-Type': 'text/calendar; charset=utf-8',
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
return updatedEvent;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Test the connection to the CalDAV server.
|
|
496
|
+
* Returns true if the credentials are valid.
|
|
497
|
+
*/
|
|
498
|
+
async testConnection(): Promise<boolean> {
|
|
499
|
+
try {
|
|
500
|
+
await this.findCalendarHome();
|
|
501
|
+
return true;
|
|
502
|
+
} catch (error) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CalendarInternal, CalendarEvent } from "./CalendarInternal.js";
|
|
2
|
+
|
|
3
|
+
export interface Calendar {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
color: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
sourceId: string;
|
|
9
|
+
sourceType: string;
|
|
10
|
+
calendarUrl?: string;
|
|
11
|
+
fetchEvents(): Promise<CalendarEvent[]>;
|
|
12
|
+
createEvent?(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent>;
|
|
13
|
+
updateEvent?(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
|
|
14
|
+
moveEvent?(id: string, newStart: Date, newEnd: Date): Promise<CalendarEvent>;
|
|
15
|
+
deleteEvent?(id: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CalendarCredentials {
|
|
19
|
+
[key: string]: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CalendarSource {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
credentials: CalendarCredentials;
|
|
27
|
+
color: string;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
fetchEvents(): Promise<CalendarEvent[]>;
|
|
30
|
+
createEvent?(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent>;
|
|
31
|
+
updateEvent?(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent>;
|
|
32
|
+
moveEvent?(id: string, newStart: Date, newEnd: Date): Promise<CalendarEvent>;
|
|
33
|
+
deleteEvent?(id: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class CalendarIntegration {
|
|
37
|
+
static SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
38
|
+
|
|
39
|
+
constructor(protected internals: CalendarInternal) {}
|
|
40
|
+
|
|
41
|
+
async shouldSync(calendar: Calendar): Promise<boolean> {
|
|
42
|
+
const metadata = await this.internals.storage.getSyncMetadata(calendar.id);
|
|
43
|
+
if (!metadata) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timeSinceLastSync = Date.now() - metadata.lastSync.getTime();
|
|
48
|
+
return timeSinceLastSync >= CalendarIntegration.SYNC_INTERVAL_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async sync(calendar: Calendar, options?: { force?: boolean }): Promise<CalendarEvent[]> {
|
|
52
|
+
if (!calendar.enabled) return [];
|
|
53
|
+
|
|
54
|
+
if (!options?.force && !(await this.shouldSync(calendar))) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const events = await calendar.fetchEvents();
|
|
59
|
+
const syncTimestamp = new Date();
|
|
60
|
+
const updatedEvents = this.internals.sync(calendar.name, syncTimestamp, events);
|
|
61
|
+
|
|
62
|
+
await this.internals.storage.setSyncMetadata(calendar.id, {
|
|
63
|
+
lastSync: new Date(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return updatedEvents;
|
|
67
|
+
}
|
|
68
|
+
}
|