zele 0.1.3 → 0.2.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +112 -0
  3. package/dist/api-utils.d.ts +6 -0
  4. package/dist/api-utils.js +52 -0
  5. package/dist/api-utils.js.map +1 -0
  6. package/dist/auth.d.ts +16 -0
  7. package/dist/auth.js +74 -5
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +135 -0
  10. package/dist/calendar-client.js +498 -0
  11. package/dist/calendar-client.js.map +1 -0
  12. package/dist/calendar-time.d.ts +24 -0
  13. package/dist/calendar-time.js +245 -0
  14. package/dist/calendar-time.js.map +1 -0
  15. package/dist/cli.js +5 -3
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/auth-cmd.js +5 -5
  18. package/dist/commands/auth-cmd.js.map +1 -1
  19. package/dist/commands/calendar.d.ts +2 -0
  20. package/dist/commands/calendar.js +563 -0
  21. package/dist/commands/calendar.js.map +1 -0
  22. package/dist/generated/browser.d.ts +10 -0
  23. package/dist/generated/client.d.ts +10 -0
  24. package/dist/generated/internal/class.d.ts +22 -0
  25. package/dist/generated/internal/class.js +2 -2
  26. package/dist/generated/internal/class.js.map +1 -1
  27. package/dist/generated/internal/prismaNamespace.d.ts +174 -1
  28. package/dist/generated/internal/prismaNamespace.js +21 -0
  29. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  30. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +23 -0
  31. package/dist/generated/internal/prismaNamespaceBrowser.js +21 -0
  32. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  33. package/dist/generated/models/accounts.d.ts +281 -0
  34. package/dist/generated/models/calendar_events.d.ts +1433 -0
  35. package/dist/generated/models/calendar_events.js +2 -0
  36. package/dist/generated/models/calendar_events.js.map +1 -0
  37. package/dist/generated/models/calendar_lists.d.ts +1131 -0
  38. package/dist/generated/models/calendar_lists.js +2 -0
  39. package/dist/generated/models/calendar_lists.js.map +1 -0
  40. package/dist/generated/models.d.ts +2 -0
  41. package/dist/gmail-cache.d.ts +22 -0
  42. package/dist/gmail-cache.js +76 -0
  43. package/dist/gmail-cache.js.map +1 -1
  44. package/dist/gmail-client.js +1 -48
  45. package/dist/gmail-client.js.map +1 -1
  46. package/dist/output.d.ts +11 -0
  47. package/dist/output.js +42 -0
  48. package/dist/output.js.map +1 -1
  49. package/package.json +4 -2
  50. package/schema.prisma +39 -6
  51. package/scripts/test-device-code-clients.ts +186 -0
  52. package/scripts/test-micropython-scopes.ts +72 -0
  53. package/scripts/test-oauth-clients.ts +257 -0
  54. package/src/api-utils.ts +60 -0
  55. package/src/auth.ts +92 -5
  56. package/src/calendar-client.ts +758 -0
  57. package/src/calendar-time.ts +299 -0
  58. package/src/cli.ts +5 -3
  59. package/src/commands/auth-cmd.ts +5 -5
  60. package/src/commands/calendar.ts +634 -0
  61. package/src/gmail-cache.ts +96 -0
  62. package/src/gmail-client.ts +1 -57
  63. package/src/output.ts +51 -0
  64. package/src/schema.sql +22 -0
@@ -0,0 +1,758 @@
1
+ // CalDAV-based calendar client for CLI use.
2
+ // Uses tsdav for CalDAV protocol and ts-ics for typed iCalendar parse/generate.
3
+ // Auth: passes a Bearer token via headers (reuses existing google-auth-library OAuth2).
4
+ // Google CalDAV endpoint: https://apidata.googleusercontent.com/caldav/v2/
5
+
6
+ import {
7
+ fetchCalendars,
8
+ fetchCalendarObjects,
9
+ createCalendarObject,
10
+ updateCalendarObject,
11
+ deleteCalendarObject,
12
+ type DAVCalendar,
13
+ type DAVCalendarObject,
14
+ } from 'tsdav'
15
+ import {
16
+ convertIcsCalendar,
17
+ generateIcsCalendar,
18
+ type IcsCalendar,
19
+ type IcsEvent,
20
+ type IcsAttendee,
21
+ type IcsDateObject,
22
+ } from 'ts-ics'
23
+ import crypto from 'node:crypto'
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types (kept identical to previous API so commands layer is unchanged)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface CalendarListItem {
30
+ id: string
31
+ summary: string
32
+ primary: boolean
33
+ role: string
34
+ timezone: string
35
+ backgroundColor: string
36
+ }
37
+
38
+ export interface CalendarEvent {
39
+ id: string
40
+ summary: string
41
+ start: string // RFC3339 or date
42
+ end: string
43
+ startDate?: string // date-only for all-day events
44
+ endDate?: string
45
+ allDay: boolean
46
+ description: string
47
+ location: string
48
+ status: string
49
+ htmlLink: string
50
+ meetLink: string | null
51
+ attendees: CalendarAttendee[]
52
+ recurrence: string[]
53
+ reminders: CalendarReminder[]
54
+ colorId: string | null
55
+ visibility: string
56
+ transparency: string
57
+ calendarId?: string // set when merging across calendars
58
+ // CalDAV-specific: needed for update/delete
59
+ url?: string
60
+ etag?: string
61
+ uid?: string
62
+ }
63
+
64
+ export interface CalendarAttendee {
65
+ email: string
66
+ name: string | null
67
+ status: string
68
+ self: boolean
69
+ organizer: boolean
70
+ }
71
+
72
+ export interface CalendarReminder {
73
+ method: string
74
+ minutes: number
75
+ }
76
+
77
+ export interface EventListResult {
78
+ events: CalendarEvent[]
79
+ nextPageToken: string | null
80
+ timezone: string
81
+ }
82
+
83
+ export interface FreeBusyBlock {
84
+ start: string
85
+ end: string
86
+ }
87
+
88
+ export interface FreeBusyResult {
89
+ calendar: string
90
+ busy: FreeBusyBlock[]
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // ts-ics conversion helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /** Convert an IcsDateObject to an RFC3339 string or YYYY-MM-DD date string */
98
+ function icsDateToString(d: IcsDateObject): string {
99
+ if (d.type === 'DATE') {
100
+ // All-day: return YYYY-MM-DD
101
+ return d.date.toISOString().split('T')[0]!
102
+ }
103
+ return d.date.toISOString()
104
+ }
105
+
106
+ /** Map ts-ics PARTSTAT to our lowercase status */
107
+ function mapPartstat(partstat?: string): string {
108
+ if (!partstat) return 'needsAction'
109
+ switch (partstat) {
110
+ case 'ACCEPTED': return 'accepted'
111
+ case 'DECLINED': return 'declined'
112
+ case 'TENTATIVE': return 'tentative'
113
+ case 'NEEDS-ACTION': return 'needsAction'
114
+ default: return partstat.toLowerCase()
115
+ }
116
+ }
117
+
118
+ /** Create an IcsDateObject from an RFC3339 string or YYYY-MM-DD */
119
+ function toIcsDate(dateStr: string, allDay = false): IcsDateObject {
120
+ if (allDay || /^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
121
+ return { date: new Date(dateStr + 'T00:00:00Z'), type: 'DATE' }
122
+ }
123
+ return { date: new Date(dateStr), type: 'DATE-TIME' }
124
+ }
125
+
126
+ /** Generate a new UID for calendar events */
127
+ function generateUID(): string {
128
+ return `${crypto.randomUUID()}@zele`
129
+ }
130
+
131
+ /** Convert a single IcsEvent to our CalendarEvent type */
132
+ function icsEventToCalendarEvent(event: IcsEvent, calObj?: DAVCalendarObject): CalendarEvent {
133
+ const allDay = event.start.type === 'DATE'
134
+
135
+ const startStr = icsDateToString(event.start)
136
+ // ts-ics events have either `end` or `duration`
137
+ const endDate = event.end
138
+ ? icsDateToString(event.end)
139
+ : startStr // fallback if no end
140
+
141
+ // Attendees
142
+ const attendees: CalendarAttendee[] = (event.attendees ?? []).map((a) => ({
143
+ email: a.email,
144
+ name: a.name ?? null,
145
+ status: mapPartstat(a.partstat),
146
+ self: false,
147
+ organizer: false,
148
+ }))
149
+
150
+ // Mark organizer in attendees
151
+ if (event.organizer) {
152
+ for (const a of attendees) {
153
+ if (a.email.toLowerCase() === event.organizer!.email.toLowerCase()) {
154
+ a.organizer = true
155
+ }
156
+ }
157
+ }
158
+
159
+ // Extract Meet link from description
160
+ let meetLink: string | null = null
161
+ const desc = event.description ?? ''
162
+ const meetMatch = desc.match(/https:\/\/meet\.google\.com\/[\w-]+/)
163
+ if (meetMatch) meetLink = meetMatch[0]
164
+
165
+ // Recurrence rules — convert back to RRULE string if present
166
+ const recurrence: string[] = []
167
+ if (event.recurrenceRule) {
168
+ // ts-ics stores parsed rule; we'd need to re-serialize — store as-is for display
169
+ const r = event.recurrenceRule
170
+ const parts: string[] = [`FREQ=${r.frequency}`]
171
+ if (r.interval) parts.push(`INTERVAL=${r.interval}`)
172
+ if (r.count) parts.push(`COUNT=${r.count}`)
173
+ if (r.until) parts.push(`UNTIL=${icsDateToString(r.until).replace(/[-:]/g, '')}`)
174
+ if (r.byDay) parts.push(`BYDAY=${r.byDay.map((d) => (d.occurrence ?? '') + d.day).join(',')}`)
175
+ if (r.byMonth) parts.push(`BYMONTH=${r.byMonth.join(',')}`)
176
+ if (r.byMonthday) parts.push(`BYMONTHDAY=${r.byMonthday.join(',')}`)
177
+ recurrence.push(`RRULE:${parts.join(';')}`)
178
+ }
179
+
180
+ // Reminders from alarms
181
+ const reminders: CalendarReminder[] = (event.alarms ?? []).map((alarm) => {
182
+ let minutes = 0
183
+ if (alarm.trigger.type === 'relative') {
184
+ const d = alarm.trigger.value
185
+ minutes = (d.weeks ?? 0) * 10080 + (d.days ?? 0) * 1440 + (d.hours ?? 0) * 60 + (d.minutes ?? 0)
186
+ }
187
+ return { method: alarm.action ?? 'popup', minutes }
188
+ })
189
+
190
+ return {
191
+ id: event.uid,
192
+ summary: event.summary || '(no title)',
193
+ start: startStr,
194
+ end: endDate,
195
+ startDate: allDay ? startStr : undefined,
196
+ endDate: allDay ? endDate : undefined,
197
+ allDay,
198
+ description: desc,
199
+ location: event.location ?? '',
200
+ status: (event.status ?? 'CONFIRMED').toLowerCase(),
201
+ htmlLink: '',
202
+ meetLink,
203
+ attendees,
204
+ recurrence,
205
+ reminders,
206
+ colorId: null,
207
+ visibility: event.class === 'PRIVATE' ? 'private' : 'default',
208
+ transparency: event.timeTransparent === 'TRANSPARENT' ? 'transparent' : 'opaque',
209
+ url: calObj?.url,
210
+ etag: calObj?.etag,
211
+ uid: event.uid,
212
+ }
213
+ }
214
+
215
+ /** Parse all events from a raw iCal data string using ts-ics */
216
+ function parseICalData(data: string, calObj?: DAVCalendarObject): CalendarEvent[] {
217
+ try {
218
+ const calendar = convertIcsCalendar(undefined, data)
219
+ return (calendar.events ?? []).map((ev) => icsEventToCalendarEvent(ev, calObj))
220
+ } catch (err) {
221
+ console.error('[calendar] failed to parse iCal data:', err instanceof Error ? err.stack ?? err.message : err)
222
+ return []
223
+ }
224
+ }
225
+
226
+ /** Build an iCal string from event properties using ts-ics */
227
+ function buildICalString(props: {
228
+ uid?: string
229
+ summary: string
230
+ start: string
231
+ end: string
232
+ allDay?: boolean
233
+ description?: string
234
+ location?: string
235
+ attendees?: Array<{ email: string; name?: string; partstat?: string }>
236
+ recurrence?: string[]
237
+ transparency?: string
238
+ visibility?: string
239
+ status?: string
240
+ sequence?: number
241
+ organizer?: { email: string; name?: string }
242
+ }): string {
243
+ const uid = props.uid ?? generateUID()
244
+ const now: IcsDateObject = { date: new Date(), type: 'DATE-TIME' }
245
+
246
+ const event: IcsEvent = {
247
+ uid,
248
+ summary: props.summary,
249
+ stamp: now,
250
+ start: toIcsDate(props.start, props.allDay),
251
+ end: toIcsDate(props.end, props.allDay),
252
+ }
253
+
254
+ if (props.description) event.description = props.description
255
+ if (props.location) event.location = props.location
256
+ if (props.status) event.status = props.status.toUpperCase() as any
257
+ if (props.transparency) event.timeTransparent = props.transparency.toUpperCase() as any
258
+ if (props.visibility === 'private') event.class = 'PRIVATE'
259
+ if (props.sequence !== undefined) event.sequence = props.sequence
260
+
261
+ if (props.organizer) {
262
+ event.organizer = { email: props.organizer.email, name: props.organizer.name }
263
+ }
264
+
265
+ if (props.attendees && props.attendees.length > 0) {
266
+ event.attendees = props.attendees.map((a): IcsAttendee => ({
267
+ email: a.email,
268
+ name: a.name,
269
+ partstat: (a.partstat as any) ?? 'NEEDS-ACTION',
270
+ rsvp: true,
271
+ }))
272
+ }
273
+
274
+ const calendar: IcsCalendar = {
275
+ version: '2.0',
276
+ prodId: '-//zele//zele CLI//EN',
277
+ events: [event],
278
+ }
279
+
280
+ return generateIcsCalendar(calendar)
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // CalDAV timezone extraction
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /** Extract IANA timezone name from CalDAV timezone data.
288
+ * The timezone field may contain raw VTIMEZONE iCal data like:
289
+ * BEGIN:VCALENDAR\n...TZID:Europe/Rome\n...END:VCALENDAR
290
+ * We extract the TZID value from it. */
291
+ function extractTimezone(tz?: string): string {
292
+ if (!tz) return 'UTC'
293
+ // If it's already an IANA timezone name (no whitespace/newlines), return as-is
294
+ if (!tz.includes('\n') && !tz.includes('BEGIN:')) return tz
295
+ // Extract TZID from VTIMEZONE data
296
+ const match = tz.match(/TZID:(.+)/m)
297
+ if (match) return match[1]!.trim()
298
+ // Try X-WR-TIMEZONE
299
+ const wrMatch = tz.match(/X-WR-TIMEZONE:(.+)/m)
300
+ if (wrMatch) return wrMatch[1]!.trim()
301
+ return 'UTC'
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // CalendarClient
306
+ // ---------------------------------------------------------------------------
307
+
308
+ const GOOGLE_CALDAV_URL = 'https://apidata.googleusercontent.com/caldav/v2/'
309
+
310
+ export class CalendarClient {
311
+ private headers: Record<string, string>
312
+ private email: string
313
+ private calendarCache: DAVCalendar[] | null = null
314
+ private timezoneCache: Record<string, string> = {}
315
+
316
+ constructor({ accessToken, email }: { accessToken: string; email: string }) {
317
+ this.headers = { Authorization: `Bearer ${accessToken}` }
318
+ this.email = email
319
+ }
320
+
321
+ /** Update the access token (e.g. after refresh) */
322
+ updateAccessToken(token: string) {
323
+ this.headers = { Authorization: `Bearer ${token}` }
324
+ }
325
+
326
+ // =========================================================================
327
+ // Internal: fetch DAVCalendar list (cached per instance)
328
+ // =========================================================================
329
+
330
+ private async fetchDAVCalendars(): Promise<DAVCalendar[]> {
331
+ if (this.calendarCache) return this.calendarCache
332
+
333
+ const calendars = await fetchCalendars({
334
+ account: {
335
+ serverUrl: GOOGLE_CALDAV_URL,
336
+ rootUrl: GOOGLE_CALDAV_URL,
337
+ accountType: 'caldav',
338
+ homeUrl: `${GOOGLE_CALDAV_URL}${this.email}/`,
339
+ },
340
+ headers: this.headers,
341
+ })
342
+
343
+ this.calendarCache = calendars
344
+ return calendars
345
+ }
346
+
347
+ /** Resolve a calendarId to a DAVCalendar. 'primary' maps to the user's email. */
348
+ private async resolveCalendar(calendarId: string): Promise<DAVCalendar> {
349
+ const calendars = await this.fetchDAVCalendars()
350
+
351
+ // 'primary' = the user's own calendar (URL contains their email)
352
+ const targetId = calendarId === 'primary' ? this.email : calendarId
353
+
354
+ const match = calendars.find((c) => {
355
+ const urlLower = c.url.toLowerCase()
356
+ return urlLower.includes(`/${encodeURIComponent(targetId).toLowerCase()}/`) ||
357
+ urlLower.includes(`/${targetId.toLowerCase()}/`)
358
+ })
359
+
360
+ if (!match) {
361
+ throw new Error(`Calendar not found: ${calendarId}. Available: ${calendars.map((c) => c.displayName || c.url).join(', ')}`)
362
+ }
363
+
364
+ return match
365
+ }
366
+
367
+ // =========================================================================
368
+ // Calendar list
369
+ // =========================================================================
370
+
371
+ async listCalendars(): Promise<CalendarListItem[]> {
372
+ const calendars = await this.fetchDAVCalendars()
373
+
374
+ return calendars.map((cal) => {
375
+ // Extract calendar ID from URL
376
+ // URL looks like: https://apidata.googleusercontent.com/caldav/v2/user%40gmail.com/events/
377
+ const urlParts = cal.url.replace(/\/$/, '').split('/')
378
+ const eventsIdx = urlParts.indexOf('events')
379
+ const idEncoded = eventsIdx > 0 ? urlParts[eventsIdx - 1]! : urlParts[urlParts.length - 1]!
380
+ const id = decodeURIComponent(idEncoded)
381
+
382
+ const isPrimary = id.toLowerCase() === this.email.toLowerCase()
383
+
384
+ return {
385
+ id,
386
+ summary: typeof cal.displayName === 'string'
387
+ ? cal.displayName
388
+ : (cal.displayName as any)?._text ?? id,
389
+ primary: isPrimary,
390
+ role: 'owner',
391
+ timezone: extractTimezone(cal.timezone),
392
+ backgroundColor: cal.calendarColor ?? '',
393
+ }
394
+ })
395
+ }
396
+
397
+ // =========================================================================
398
+ // Timezone
399
+ // =========================================================================
400
+
401
+ async getTimezone(calendarId = 'primary'): Promise<string> {
402
+ if (this.timezoneCache[calendarId]) return this.timezoneCache[calendarId]!
403
+
404
+ try {
405
+ const cal = await this.resolveCalendar(calendarId)
406
+ const tz = extractTimezone(cal.timezone)
407
+ this.timezoneCache[calendarId] = tz
408
+ return tz
409
+ } catch (err) {
410
+ console.error('[calendar] failed to resolve timezone for', calendarId, '-', err instanceof Error ? err.stack ?? err.message : err)
411
+ const calendars = await this.listCalendars()
412
+ const target = calendarId === 'primary' ? this.email : calendarId
413
+ const match = calendars.find((c) => c.id.toLowerCase() === target.toLowerCase())
414
+ const tz = match?.timezone || 'UTC'
415
+ this.timezoneCache[calendarId] = tz
416
+ return tz
417
+ }
418
+ }
419
+
420
+ // =========================================================================
421
+ // Events
422
+ // =========================================================================
423
+
424
+ async listEvents({
425
+ calendarId = 'primary',
426
+ timeMin,
427
+ timeMax,
428
+ query,
429
+ maxResults = 20,
430
+ pageToken,
431
+ }: {
432
+ calendarId?: string
433
+ timeMin?: string
434
+ timeMax?: string
435
+ query?: string
436
+ maxResults?: number
437
+ pageToken?: string
438
+ } = {}): Promise<EventListResult> {
439
+ const cal = await this.resolveCalendar(calendarId)
440
+ const tz = await this.getTimezone(calendarId)
441
+
442
+ const fetchOpts: Parameters<typeof fetchCalendarObjects>[0] = {
443
+ calendar: cal,
444
+ headers: this.headers,
445
+ urlFilter: () => true,
446
+ }
447
+
448
+ if (timeMin && timeMax) {
449
+ fetchOpts.timeRange = { start: timeMin, end: timeMax }
450
+ }
451
+
452
+ const calObjects = await fetchCalendarObjects(fetchOpts)
453
+
454
+ let events: CalendarEvent[] = []
455
+ for (const obj of calObjects) {
456
+ if (!obj.data) continue
457
+ events.push(...parseICalData(obj.data, obj))
458
+ }
459
+
460
+ if (query) {
461
+ const q = query.toLowerCase()
462
+ events = events.filter((e) =>
463
+ e.summary.toLowerCase().includes(q) ||
464
+ e.description.toLowerCase().includes(q) ||
465
+ e.location.toLowerCase().includes(q),
466
+ )
467
+ }
468
+
469
+ events.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
470
+
471
+ return {
472
+ events: events.slice(0, maxResults),
473
+ nextPageToken: null,
474
+ timezone: tz,
475
+ }
476
+ }
477
+
478
+ async getEvent({
479
+ calendarId = 'primary',
480
+ eventId,
481
+ }: {
482
+ calendarId?: string
483
+ eventId: string
484
+ }): Promise<CalendarEvent> {
485
+ const cal = await this.resolveCalendar(calendarId)
486
+
487
+ const calObjects = await fetchCalendarObjects({
488
+ calendar: cal,
489
+ headers: this.headers,
490
+ urlFilter: () => true,
491
+ })
492
+
493
+ for (const obj of calObjects) {
494
+ if (!obj.data) continue
495
+ const events = parseICalData(obj.data, obj)
496
+ const match = events.find((e) => e.id === eventId || e.uid === eventId)
497
+ if (match) return match
498
+ }
499
+
500
+ throw new Error(`Event not found: ${eventId}`)
501
+ }
502
+
503
+ async createEvent({
504
+ calendarId = 'primary',
505
+ summary,
506
+ start,
507
+ end,
508
+ allDay = false,
509
+ description,
510
+ location,
511
+ attendees,
512
+ withMeet = false,
513
+ recurrence,
514
+ reminders,
515
+ colorId,
516
+ visibility,
517
+ transparency,
518
+ }: {
519
+ calendarId?: string
520
+ summary: string
521
+ start: string
522
+ end: string
523
+ allDay?: boolean
524
+ description?: string
525
+ location?: string
526
+ attendees?: string[]
527
+ withMeet?: boolean
528
+ recurrence?: string[]
529
+ reminders?: Array<{ method: string; minutes: number }>
530
+ colorId?: string
531
+ visibility?: string
532
+ transparency?: string
533
+ }): Promise<CalendarEvent> {
534
+ const cal = await this.resolveCalendar(calendarId)
535
+ const uid = generateUID()
536
+
537
+ const iCalString = buildICalString({
538
+ uid,
539
+ summary,
540
+ start,
541
+ end,
542
+ allDay,
543
+ description,
544
+ location,
545
+ attendees: attendees?.map((email) => ({ email })),
546
+ transparency,
547
+ visibility,
548
+ organizer: { email: this.email },
549
+ })
550
+
551
+ const filename = `${uid.split('@')[0]}.ics`
552
+
553
+ await createCalendarObject({
554
+ calendar: cal,
555
+ filename,
556
+ iCalString,
557
+ headers: this.headers,
558
+ })
559
+
560
+ const events = parseICalData(iCalString)
561
+ const event = events[0]
562
+ if (!event) throw new Error('Failed to parse created event')
563
+ event.url = `${cal.url}${filename}`
564
+
565
+ return event
566
+ }
567
+
568
+ async updateEvent({
569
+ calendarId = 'primary',
570
+ eventId,
571
+ summary,
572
+ start,
573
+ end,
574
+ allDay,
575
+ description,
576
+ location,
577
+ addAttendees,
578
+ removeAttendees,
579
+ withMeet,
580
+ colorId,
581
+ visibility,
582
+ transparency,
583
+ }: {
584
+ calendarId?: string
585
+ eventId: string
586
+ summary?: string
587
+ start?: string
588
+ end?: string
589
+ allDay?: boolean
590
+ description?: string
591
+ location?: string
592
+ addAttendees?: string[]
593
+ removeAttendees?: string[]
594
+ withMeet?: boolean
595
+ colorId?: string
596
+ visibility?: string
597
+ transparency?: string
598
+ }): Promise<CalendarEvent> {
599
+ const existing = await this.getEvent({ calendarId, eventId })
600
+ if (!existing.url || !existing.etag) {
601
+ throw new Error(`Cannot update event: missing CalDAV URL or etag for event ${eventId}`)
602
+ }
603
+
604
+ // Handle attendee add/remove
605
+ let mergedAttendees = existing.attendees.map((a) => ({
606
+ email: a.email,
607
+ name: a.name ?? undefined,
608
+ partstat: a.status === 'needsAction' ? 'NEEDS-ACTION' : a.status.toUpperCase(),
609
+ }))
610
+ if (removeAttendees) {
611
+ const removeSet = new Set(removeAttendees.map((e) => e.toLowerCase()))
612
+ mergedAttendees = mergedAttendees.filter((a) => !removeSet.has(a.email.toLowerCase()))
613
+ }
614
+ if (addAttendees) {
615
+ const existingSet = new Set(mergedAttendees.map((a) => a.email.toLowerCase()))
616
+ for (const email of addAttendees) {
617
+ if (!existingSet.has(email.toLowerCase())) {
618
+ mergedAttendees.push({ email, name: undefined, partstat: 'NEEDS-ACTION' })
619
+ }
620
+ }
621
+ }
622
+
623
+ const iCalString = buildICalString({
624
+ uid: existing.uid ?? eventId,
625
+ summary: summary ?? existing.summary,
626
+ start: start ?? existing.start,
627
+ end: end ?? existing.end,
628
+ allDay: allDay ?? existing.allDay,
629
+ description: description !== undefined ? description : existing.description || undefined,
630
+ location: location !== undefined ? location : existing.location || undefined,
631
+ attendees: mergedAttendees.length > 0 ? mergedAttendees : undefined,
632
+ transparency: transparency ?? existing.transparency,
633
+ visibility: visibility ?? existing.visibility,
634
+ sequence: 1,
635
+ organizer: { email: this.email },
636
+ })
637
+
638
+ await updateCalendarObject({
639
+ calendarObject: { url: existing.url, data: iCalString, etag: existing.etag },
640
+ headers: this.headers,
641
+ })
642
+
643
+ const events = parseICalData(iCalString)
644
+ const event = events[0]
645
+ if (!event) throw new Error('Failed to parse updated event')
646
+ event.url = existing.url
647
+ event.etag = existing.etag
648
+
649
+ return event
650
+ }
651
+
652
+ async deleteEvent({
653
+ calendarId = 'primary',
654
+ eventId,
655
+ }: {
656
+ calendarId?: string
657
+ eventId: string
658
+ }): Promise<void> {
659
+ const existing = await this.getEvent({ calendarId, eventId })
660
+ if (!existing.url) {
661
+ throw new Error(`Cannot delete event: missing CalDAV URL for event ${eventId}`)
662
+ }
663
+
664
+ await deleteCalendarObject({
665
+ calendarObject: { url: existing.url, etag: existing.etag },
666
+ headers: this.headers,
667
+ })
668
+ }
669
+
670
+ async respondToEvent({
671
+ calendarId = 'primary',
672
+ eventId,
673
+ status,
674
+ comment,
675
+ }: {
676
+ calendarId?: string
677
+ eventId: string
678
+ status: 'accepted' | 'declined' | 'tentative'
679
+ comment?: string
680
+ }): Promise<CalendarEvent> {
681
+ const existing = await this.getEvent({ calendarId, eventId })
682
+ if (!existing.url || !existing.etag) {
683
+ throw new Error(`Cannot respond to event: missing CalDAV URL or etag for event ${eventId}`)
684
+ }
685
+
686
+ // Update our PARTSTAT in attendees
687
+ const attendees = existing.attendees.map((a) => ({
688
+ email: a.email,
689
+ name: a.name ?? undefined,
690
+ partstat: a.email.toLowerCase() === this.email.toLowerCase()
691
+ ? status.toUpperCase()
692
+ : (a.status === 'needsAction' ? 'NEEDS-ACTION' : a.status.toUpperCase()),
693
+ }))
694
+
695
+ const organizer = existing.attendees.find((a) => a.organizer)
696
+
697
+ const iCalString = buildICalString({
698
+ uid: existing.uid ?? eventId,
699
+ summary: existing.summary,
700
+ start: existing.start,
701
+ end: existing.end,
702
+ allDay: existing.allDay,
703
+ description: existing.description || undefined,
704
+ location: existing.location || undefined,
705
+ attendees,
706
+ organizer: organizer
707
+ ? { email: organizer.email, name: organizer.name ?? undefined }
708
+ : { email: this.email },
709
+ })
710
+
711
+ await updateCalendarObject({
712
+ calendarObject: { url: existing.url, data: iCalString, etag: existing.etag },
713
+ headers: this.headers,
714
+ })
715
+
716
+ const events = parseICalData(iCalString)
717
+ const event = events[0]
718
+ if (!event) throw new Error('Failed to parse response event')
719
+ event.url = existing.url
720
+
721
+ return event
722
+ }
723
+
724
+ async getFreeBusy({
725
+ calendarIds,
726
+ timeMin,
727
+ timeMax,
728
+ }: {
729
+ calendarIds: string[]
730
+ timeMin: string
731
+ timeMax: string
732
+ }): Promise<FreeBusyResult[]> {
733
+ const results: FreeBusyResult[] = []
734
+
735
+ for (const calId of calendarIds) {
736
+ try {
737
+ const { events } = await this.listEvents({
738
+ calendarId: calId,
739
+ timeMin,
740
+ timeMax,
741
+ maxResults: 200,
742
+ })
743
+
744
+ const busy: FreeBusyBlock[] = events
745
+ .filter((e) => e.transparency !== 'transparent' && !e.allDay)
746
+ .map((e) => ({ start: e.start, end: e.end }))
747
+
748
+ results.push({ calendar: calId, busy })
749
+ } catch (err) {
750
+ console.error('[calendar] freebusy failed for', calId, '-', err instanceof Error ? err.stack ?? err.message : err)
751
+ results.push({ calendar: calId, busy: [] })
752
+ }
753
+ }
754
+
755
+ return results
756
+ }
757
+
758
+ }