@luckydye/calendar 1.3.1 → 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,69 +1,75 @@
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
6
 
4
7
  /**
5
8
  * Google Calendar API credentials.
6
9
  */
7
10
  export interface GoogleCredentials extends CalendarCredentials {
8
- /**
9
- * Google OAuth2 access token
10
- */
11
- accessToken: string;
12
- /**
13
- * Google OAuth2 refresh token (optional, for token refresh)
14
- */
15
- refreshToken?: string;
16
- /**
17
- * Token expiry timestamp (ISO string)
18
- */
19
- tokenExpiry?: string;
20
- /**
21
- * Google Calendar ID (default: 'primary')
22
- */
23
- calendarId?: string;
11
+ /**
12
+ * Google OAuth2 access token
13
+ */
14
+ accessToken: string;
15
+ /**
16
+ * Google OAuth2 refresh token (optional, for token refresh)
17
+ */
18
+ refreshToken?: string;
19
+ /**
20
+ * Token expiry timestamp (ISO string)
21
+ */
22
+ tokenExpiry?: string;
23
+ /**
24
+ * Google Calendar ID (default: 'primary')
25
+ */
26
+ calendarId?: string;
24
27
  }
25
28
 
26
29
  /**
27
30
  * Google Calendar API event response structure.
28
31
  */
29
32
  interface GoogleCalendarEvent {
30
- id: string;
31
- summary?: string;
32
- description?: string;
33
- location?: string;
34
- start?: {
35
- dateTime?: string;
36
- date?: string;
37
- };
38
- end?: {
39
- dateTime?: string;
40
- date?: string;
41
- };
42
- colorId?: string;
43
- recurringEventId?: string;
44
- recurrence?: string[];
45
- organizer?: {
46
- email?: string;
47
- displayName?: string;
48
- };
49
- attendees?: Array<{
50
- email?: string;
51
- displayName?: string;
52
- responseStatus?: string;
53
- optional?: boolean;
54
- }>;
55
- htmlLink?: string;
56
- status?: string;
33
+ id: string;
34
+ summary?: string;
35
+ description?: string;
36
+ location?: string;
37
+ start?: {
38
+ dateTime?: string;
39
+ date?: string;
40
+ };
41
+ end?: {
42
+ dateTime?: string;
43
+ date?: string;
44
+ };
45
+ colorId?: string;
46
+ recurringEventId?: string;
47
+ recurrence?: string[];
48
+ organizer?: {
49
+ email?: string;
50
+ displayName?: string;
51
+ };
52
+ attendees?: Array<{
53
+ email?: string;
54
+ displayName?: string;
55
+ responseStatus?: string;
56
+ optional?: boolean;
57
+ }>;
58
+ htmlLink?: string;
59
+ status?: string;
57
60
  }
58
61
 
59
62
  /**
60
63
  * Google Calendar API colors response.
61
64
  */
62
65
  interface GoogleCalendarColors {
63
- event: Record<string, {
64
- background: string;
65
- foreground: string;
66
- }>;
66
+ event: Record<
67
+ string,
68
+ {
69
+ background: string;
70
+ foreground: string;
71
+ }
72
+ >;
67
73
  }
68
74
 
69
75
  /**
@@ -83,506 +89,578 @@ interface GoogleCalendarColors {
83
89
  * const events = await source.fetchEvents();
84
90
  */
85
91
  export class GoogleCalendarSource implements CalendarSource {
86
- readonly type = 'google';
87
- credentials: GoogleCredentials;
88
- enabled: boolean;
89
-
90
- /**
91
- * Google Calendar color mapping to hex colors
92
- */
93
- private static readonly GOOGLE_COLORS: Record<string, string> = {
94
- '1': '#7986cb', // Lavender
95
- '2': '#33b679', // Sage
96
- '3': '#8e24aa', // Grape
97
- '4': '#e67c73', // Flamingo
98
- '5': '#f6c026', // Banana
99
- '6': '#f5511d', // Tangerine
100
- '7': '#039be5', // Peacock
101
- '8': '#616161', // Graphite
102
- '9': '#3f51b5', // Blueberry
103
- '10': '#0b8043', // Basil
104
- '11': '#d60000', // Tomato
105
- };
106
-
107
- constructor(
108
- public id: string,
109
- public name: string,
110
- public color: string,
111
- credentials: GoogleCredentials,
112
- enabled = true
113
- ) {
114
- this.credentials = credentials;
115
- this.enabled = enabled;
116
- }
117
-
118
- /**
119
- * Get the Google Calendar ID (defaults to 'primary').
120
- */
121
- private get calendarId(): string {
122
- return this.credentials.calendarId || 'primary';
123
- }
124
-
125
- /**
126
- * Check if the access token needs refresh.
127
- */
128
- private needsTokenRefresh(): boolean {
129
- if (!this.credentials.tokenExpiry) return false;
130
- const expiry = new Date(this.credentials.tokenExpiry);
131
- return expiry.getTime() - Date.now() < 5 * 60 * 1000; // Refresh if < 5 min
132
- }
133
-
134
- /**
135
- * Cached client credentials from public/credentials_google.json
136
- */
137
- private static clientCredentials: { clientId: string; clientSecret: string } | null = null;
138
-
139
- /**
140
- * Fetch client credentials from public directory.
141
- */
142
- private static async fetchClientCredentials(): Promise<{ clientId: string; clientSecret: string }> {
143
- if (GoogleCalendarSource.clientCredentials) {
144
- return GoogleCalendarSource.clientCredentials;
145
- }
146
-
147
- const response = await fetch('/credentials_google.json');
148
- if (!response.ok) {
149
- throw new Error('Failed to load Google client credentials from public/credentials_google.json');
150
- }
151
-
152
- const data = await response.json();
153
- const installed = data.installed || data.web;
154
-
155
- if (!installed?.client_id || !installed?.client_secret) {
156
- throw new Error('Invalid credentials file: missing client_id or client_secret');
157
- }
158
-
159
- GoogleCalendarSource.clientCredentials = {
160
- clientId: installed.client_id,
161
- clientSecret: installed.client_secret,
162
- };
163
-
164
- return GoogleCalendarSource.clientCredentials;
165
- }
166
-
167
- /**
168
- * Refresh the access token using the refresh token.
169
- * Throws if no refresh token is available.
170
- */
171
- private async refreshAccessToken(): Promise<void> {
172
- if (!this.credentials.refreshToken) {
173
- throw new Error('No refresh token available for Google Calendar');
174
- }
175
-
176
- const { clientId, clientSecret } = await GoogleCalendarSource.fetchClientCredentials();
177
-
178
- const response = await fetch('https://oauth2.googleapis.com/token', {
179
- method: 'POST',
180
- headers: {
181
- 'Content-Type': 'application/x-www-form-urlencoded',
182
- },
183
- body: new URLSearchParams({
184
- client_id: clientId,
185
- client_secret: clientSecret,
186
- refresh_token: this.credentials.refreshToken,
187
- grant_type: 'refresh_token',
188
- }),
189
- });
190
-
191
- if (!response.ok) {
192
- const errorData = await response.json();
193
- throw new Error(`Token refresh failed: ${errorData.error_description || errorData.error}`);
194
- }
195
-
196
- const tokens = await response.json();
197
- this.credentials.accessToken = tokens.access_token;
198
- const expiry = new Date(Date.now() + tokens.expires_in * 1000);
199
- this.credentials.tokenExpiry = expiry.toISOString();
200
- }
201
-
202
- /**
203
- * Get a valid access token, refreshing if necessary.
204
- */
205
- private async getAccessToken(): Promise<string> {
206
- if (this.needsTokenRefresh() && this.credentials.refreshToken) {
207
- await this.refreshAccessToken();
208
- }
209
- return this.credentials.accessToken;
210
- }
211
-
212
- /**
213
- * Make an authenticated request to the Google Calendar API.
214
- */
215
- private async apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
216
- const token = await this.getAccessToken();
217
- const baseUrl = 'https://www.googleapis.com/calendar/v3';
218
- const url = `${baseUrl}${endpoint}`;
219
-
220
- const response = await fetch(url, {
221
- ...options,
222
- headers: {
223
- Authorization: `Bearer ${token}`,
224
- 'Content-Type': 'application/json',
225
- ...options.headers,
226
- },
227
- });
228
-
229
- if (!response.ok) {
230
- if (response.status === 401) {
231
- throw new Error('Google Calendar authentication failed. Please re-authenticate.');
232
- }
233
- throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
234
- }
235
-
236
- return response.json() as Promise<T>;
237
- }
238
-
239
- /**
240
- * Convert a Google Calendar event to internal CalendarEvent format.
241
- */
242
- private mapGoogleEvent(event: GoogleCalendarEvent): CalendarEvent | null {
243
- if (!event.start || !event.end) return null;
244
-
245
- // Parse start date - for all-day events, parse as local date to avoid UTC shifts
246
- let start: Date | null = null;
247
- if (event.start.dateTime) {
248
- start = new Date(event.start.dateTime);
249
- } else if (event.start.date) {
250
- const parts = event.start.date.split('-').map(Number);
251
- if (parts.length === 3 && parts.every(p => !Number.isNaN(p))) {
252
- const [year, month, day] = parts as [number, number, number];
253
- start = new Date(year, month - 1, day);
254
- }
255
- }
256
-
257
- // Parse end date - for all-day events, parse as local date to avoid UTC shifts
258
- let end: Date | null = null;
259
- if (event.end.dateTime) {
260
- end = new Date(event.end.dateTime);
261
- } else if (event.end.date) {
262
- const parts = event.end.date.split('-').map(Number);
263
- if (parts.length === 3 && parts.every(p => !Number.isNaN(p))) {
264
- const [year, month, day] = parts as [number, number, number];
265
- end = new Date(year, month - 1, day);
266
- }
267
- }
268
-
269
- if (!start || !end) return null;
270
-
271
- // Detect all-day events: Google uses 'date' instead of 'dateTime' for all-day events
272
- const isAllDay = !!event.start?.date && !event.start?.dateTime;
273
-
274
- const eventColor = event.colorId
275
- ? GoogleCalendarSource.GOOGLE_COLORS[event.colorId]
276
- : undefined;
277
-
278
- const organizer: CalendarEvent['organizer'] = event.organizer?.email
279
- ? {
280
- email: event.organizer.email,
281
- name: event.organizer.displayName,
282
- }
283
- : undefined;
284
-
285
- const attendees: CalendarEvent['attendees'] = event.attendees?.map(att => ({
286
- email: att.email || '',
287
- name: att.displayName,
288
- role: att.optional ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT',
289
- status: this.mapGoogleResponseStatus(att.responseStatus),
290
- })).filter(att => att.email);
291
-
292
- return {
293
- id: event.id,
294
- title: event.summary || 'Untitled Event',
295
- start,
296
- end,
297
- isAllDay,
298
- color: eventColor || this.color,
299
- calendar: this.name,
300
- calendarId: this.id,
301
- sourceId: this.id,
302
- description: event.description,
303
- location: event.location,
304
- url: event.htmlLink,
305
- rrule: event.recurrence?.[0] || undefined,
306
- readOnly: false,
307
- organizer,
308
- attendees,
309
- };
310
- }
311
-
312
- /**
313
- * Map Google Calendar response status to internal status.
314
- */
315
- private mapGoogleResponseStatus(status?: string): CalendarEvent['attendees'][number]['status'] {
316
- switch (status) {
317
- case 'accepted': return 'ACCEPTED';
318
- case 'declined': return 'DECLINED';
319
- case 'tentative': return 'TENTATIVE';
320
- default: return 'NEEDS-ACTION';
321
- }
322
- }
323
-
324
- /**
325
- * Map internal response status to Google Calendar status.
326
- */
327
- private mapToGoogleResponseStatus(status?: CalendarEvent['attendees'][number]['status']): string {
328
- switch (status) {
329
- case 'ACCEPTED': return 'accepted';
330
- case 'DECLINED': return 'declined';
331
- case 'TENTATIVE': return 'tentative';
332
- default: return 'needsAction';
333
- }
334
- }
335
-
336
- /**
337
- * Format a Date for Google Calendar API (ISO 8601).
338
- */
339
- private formatDateTime(date: Date): string {
340
- return date.toISOString();
341
- }
342
-
343
- /**
344
- * Convert internal CalendarEvent to Google Calendar event format.
345
- */
346
- private mapToGoogleEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): object {
347
- const googleEvent: Record<string, unknown> = {
348
- summary: event.title,
349
- start: {
350
- dateTime: this.formatDateTime(event.start),
351
- },
352
- end: {
353
- dateTime: this.formatDateTime(event.end),
354
- },
355
- };
356
-
357
- if (event.description) {
358
- googleEvent.description = event.description;
359
- }
360
-
361
- if (event.location) {
362
- googleEvent.location = event.location;
363
- }
364
-
365
- if (event.attendees && event.attendees.length > 0) {
366
- googleEvent.attendees = event.attendees.map(att => ({
367
- email: att.email,
368
- displayName: att.name,
369
- responseStatus: this.mapToGoogleResponseStatus(att.status),
370
- optional: att.role === 'OPT-PARTICIPANT',
371
- }));
372
- }
373
-
374
- return googleEvent;
375
- }
376
-
377
- /**
378
- * Fetch events from Google Calendar.
379
- * Fetches events from 1 year ago to 1 year in the future.
380
- */
381
- async fetchEvents(): Promise<CalendarEvent[]> {
382
- if (!this.enabled) return [];
383
-
384
- const now = new Date();
385
- const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
386
- const oneYearFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
387
-
388
- const params = new URLSearchParams({
389
- timeMin: oneYearAgo.toISOString(),
390
- timeMax: oneYearFuture.toISOString(),
391
- singleEvents: 'true',
392
- orderBy: 'startTime',
393
- maxResults: '2500',
394
- });
395
-
396
- const response = await this.apiRequest<{
397
- items: GoogleCalendarEvent[];
398
- }>(`/calendars/${encodeURIComponent(this.calendarId)}/events?${params}`);
399
-
400
- if (!response.items) return [];
401
-
402
- return response.items
403
- .map(event => this.mapGoogleEvent(event))
404
- .filter((event): event is CalendarEvent => event !== null);
405
- }
406
-
407
- /**
408
- * Create a new event in Google Calendar.
409
- */
410
- async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
411
- const googleEvent = this.mapToGoogleEvent(event);
412
-
413
- const response = await this.apiRequest<GoogleCalendarEvent>(
414
- `/calendars/${encodeURIComponent(this.calendarId)}/events`,
415
- {
416
- method: 'POST',
417
- body: JSON.stringify(googleEvent),
418
- }
419
- );
420
-
421
- const created = this.mapGoogleEvent(response);
422
- if (!created) {
423
- throw new Error('Failed to create event: invalid response from Google Calendar');
424
- }
425
-
426
- return created;
427
- }
428
-
429
- /**
430
- * Update an existing event in Google Calendar.
431
- */
432
- async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
433
- const isRsvpOnly = Object.keys(updates).length === 1 && updates.attendees !== undefined;
434
-
435
- if (isRsvpOnly) {
436
- // PATCH with only attendees — allowed for non-organizers updating their own response status
437
- const response = await this.apiRequest<GoogleCalendarEvent>(
438
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
439
- {
440
- method: 'PATCH',
441
- body: JSON.stringify({
442
- attendees: updates.attendees!.map(att => ({
443
- email: att.email,
444
- displayName: att.name,
445
- responseStatus: this.mapToGoogleResponseStatus(att.status),
446
- optional: att.role === 'OPT-PARTICIPANT',
447
- })),
448
- }),
449
- }
450
- );
451
- const updated = this.mapGoogleEvent(response);
452
- if (!updated) {
453
- throw new Error('Failed to update event: invalid response from Google Calendar');
454
- }
455
- return updated;
456
- }
457
-
458
- const existing = await this.apiRequest<GoogleCalendarEvent>(
459
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`
460
- );
461
-
462
- const updatedEvent: Record<string, unknown> = {
463
- summary: updates.title ?? existing.summary,
464
- description: updates.description !== undefined ? updates.description : existing.description,
465
- location: updates.location !== undefined ? updates.location : existing.location,
466
- start: updates.start
467
- ? { dateTime: this.formatDateTime(updates.start) }
468
- : existing.start,
469
- end: updates.end
470
- ? { dateTime: this.formatDateTime(updates.end) }
471
- : existing.end,
472
- };
473
-
474
- if (updates.attendees) {
475
- updatedEvent.attendees = updates.attendees.map(att => ({
476
- email: att.email,
477
- displayName: att.name,
478
- responseStatus: this.mapToGoogleResponseStatus(att.status),
479
- optional: att.role === 'OPT-PARTICIPANT',
480
- }));
481
- }
482
-
483
- const response = await this.apiRequest<GoogleCalendarEvent>(
484
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
485
- {
486
- method: 'PUT',
487
- body: JSON.stringify(updatedEvent),
488
- }
489
- );
490
-
491
- const updated = this.mapGoogleEvent(response);
492
- if (!updated) {
493
- throw new Error('Failed to update event: invalid response from Google Calendar');
494
- }
495
-
496
- return updated;
497
- }
498
-
499
- /**
500
- * Delete an event from Google Calendar.
501
- */
502
- async deleteEvent(id: string): Promise<void> {
503
- const token = await this.getAccessToken();
504
- const baseUrl = 'https://www.googleapis.com/calendar/v3';
505
- const url = `${baseUrl}/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`;
506
-
507
- const response = await fetch(url, {
508
- method: 'DELETE',
509
- headers: {
510
- Authorization: `Bearer ${token}`,
511
- 'Content-Type': 'application/json',
512
- },
513
- });
514
-
515
- if (!response.ok && response.status !== 410) {
516
- if (response.status === 401) {
517
- throw new Error('Google Calendar authentication failed. Please re-authenticate.');
518
- }
519
- throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
520
- }
521
- }
522
-
523
- /**
524
- * Test the connection to Google Calendar.
525
- * Returns true if the credentials are valid.
526
- */
527
- async testConnection(): Promise<boolean> {
528
- try {
529
- await this.apiRequest<{ summary: string }>(
530
- `/calendars/${encodeURIComponent(this.calendarId)}`
531
- );
532
- return true;
533
- } catch (error) {
534
- return false;
535
- }
536
- }
537
-
538
- /**
539
- * Get available Google Calendar colors.
540
- */
541
- static async getAvailableColors(accessToken: string): Promise<Record<string, string>> {
542
- const response = await fetch('https://www.googleapis.com/calendar/v3/colors', {
543
- headers: {
544
- Authorization: `Bearer ${accessToken}`,
545
- },
546
- });
547
-
548
- if (!response.ok) {
549
- throw new Error('Failed to fetch Google Calendar colors');
550
- }
551
-
552
- const colors: GoogleCalendarColors = await response.json();
553
- const colorMap: Record<string, string> = {};
554
-
555
- for (const [id, colorInfo] of Object.entries(colors.event)) {
556
- colorMap[id] = colorInfo.background;
557
- }
558
-
559
- return colorMap;
560
- }
92
+ readonly type = "google";
93
+ credentials: GoogleCredentials;
94
+ enabled: boolean;
95
+
96
+ /**
97
+ * Google Calendar color mapping to hex colors
98
+ */
99
+ private static readonly GOOGLE_COLORS: Record<string, string> = {
100
+ "1": "#7986cb", // Lavender
101
+ "2": "#33b679", // Sage
102
+ "3": "#8e24aa", // Grape
103
+ "4": "#e67c73", // Flamingo
104
+ "5": "#f6c026", // Banana
105
+ "6": "#f5511d", // Tangerine
106
+ "7": "#039be5", // Peacock
107
+ "8": "#616161", // Graphite
108
+ "9": "#3f51b5", // Blueberry
109
+ "10": "#0b8043", // Basil
110
+ "11": "#d60000", // Tomato
111
+ };
112
+
113
+ constructor(
114
+ public id: string,
115
+ public name: string,
116
+ public color: string,
117
+ credentials: GoogleCredentials,
118
+ enabled = true,
119
+ ) {
120
+ this.credentials = credentials;
121
+ this.enabled = enabled;
122
+ }
123
+
124
+ /**
125
+ * Get the Google Calendar ID (defaults to 'primary').
126
+ */
127
+ private get calendarId(): string {
128
+ return this.credentials.calendarId || "primary";
129
+ }
130
+
131
+ /**
132
+ * Check if the access token needs refresh.
133
+ */
134
+ private needsTokenRefresh(): boolean {
135
+ if (!this.credentials.tokenExpiry) return false;
136
+ const expiry = new Date(this.credentials.tokenExpiry);
137
+ return expiry.getTime() - Date.now() < 5 * 60 * 1000; // Refresh if < 5 min
138
+ }
139
+
140
+ /**
141
+ * Cached client credentials from public/credentials_google.json
142
+ */
143
+ private static clientCredentials: {
144
+ clientId: string;
145
+ clientSecret: string;
146
+ } | null = null;
147
+
148
+ /**
149
+ * Fetch client credentials from public directory.
150
+ */
151
+ private static async fetchClientCredentials(): Promise<{
152
+ clientId: string;
153
+ clientSecret: string;
154
+ }> {
155
+ if (GoogleCalendarSource.clientCredentials) {
156
+ return GoogleCalendarSource.clientCredentials;
157
+ }
158
+
159
+ const response = await fetch("/credentials_google.json");
160
+ if (!response.ok) {
161
+ throw new Error(
162
+ "Failed to load Google client credentials from public/credentials_google.json",
163
+ );
164
+ }
165
+
166
+ const data = await response.json();
167
+ const installed = data.installed || data.web;
168
+
169
+ if (!installed?.client_id || !installed?.client_secret) {
170
+ throw new Error(
171
+ "Invalid credentials file: missing client_id or client_secret",
172
+ );
173
+ }
174
+
175
+ GoogleCalendarSource.clientCredentials = {
176
+ clientId: installed.client_id,
177
+ clientSecret: installed.client_secret,
178
+ };
179
+
180
+ return GoogleCalendarSource.clientCredentials;
181
+ }
182
+
183
+ /**
184
+ * Refresh the access token using the refresh token.
185
+ * Throws if no refresh token is available.
186
+ */
187
+ private async refreshAccessToken(): Promise<void> {
188
+ if (!this.credentials.refreshToken) {
189
+ throw new Error("No refresh token available for Google Calendar");
190
+ }
191
+
192
+ const { clientId, clientSecret } =
193
+ await GoogleCalendarSource.fetchClientCredentials();
194
+
195
+ const response = await fetch("https://oauth2.googleapis.com/token", {
196
+ method: "POST",
197
+ headers: {
198
+ "Content-Type": "application/x-www-form-urlencoded",
199
+ },
200
+ body: new URLSearchParams({
201
+ client_id: clientId,
202
+ client_secret: clientSecret,
203
+ refresh_token: this.credentials.refreshToken,
204
+ grant_type: "refresh_token",
205
+ }),
206
+ });
207
+
208
+ if (!response.ok) {
209
+ const errorData = await response.json();
210
+ throw new Error(
211
+ `Token refresh failed: ${
212
+ errorData.error_description || errorData.error
213
+ }`,
214
+ );
215
+ }
216
+
217
+ const tokens = await response.json();
218
+ this.credentials.accessToken = tokens.access_token;
219
+ const expiry = new Date(Date.now() + tokens.expires_in * 1000);
220
+ this.credentials.tokenExpiry = expiry.toISOString();
221
+ }
222
+
223
+ /**
224
+ * Get a valid access token, refreshing if necessary.
225
+ */
226
+ private async getAccessToken(): Promise<string> {
227
+ if (this.needsTokenRefresh() && this.credentials.refreshToken) {
228
+ await this.refreshAccessToken();
229
+ }
230
+ return this.credentials.accessToken;
231
+ }
232
+
233
+ /**
234
+ * Make an authenticated request to the Google Calendar API.
235
+ */
236
+ private async apiRequest<T>(
237
+ endpoint: string,
238
+ options: RequestInit = {},
239
+ ): Promise<T> {
240
+ const token = await this.getAccessToken();
241
+ const baseUrl = "https://www.googleapis.com/calendar/v3";
242
+ const url = `${baseUrl}${endpoint}`;
243
+
244
+ const response = await fetch(url, {
245
+ ...options,
246
+ headers: {
247
+ Authorization: `Bearer ${token}`,
248
+ "Content-Type": "application/json",
249
+ ...options.headers,
250
+ },
251
+ });
252
+
253
+ if (!response.ok) {
254
+ if (response.status === 401) {
255
+ throw new Error(
256
+ "Google Calendar authentication failed. Please re-authenticate.",
257
+ );
258
+ }
259
+ throw new Error(
260
+ `Google Calendar API error: ${response.status} ${response.statusText}`,
261
+ );
262
+ }
263
+
264
+ return response.json() as Promise<T>;
265
+ }
266
+
267
+ /**
268
+ * Convert a Google Calendar event to internal CalendarEvent format.
269
+ */
270
+ private mapGoogleEvent(event: GoogleCalendarEvent): CalendarEvent | null {
271
+ if (!event.start || !event.end) return null;
272
+
273
+ // Parse start date - for all-day events, parse as local date to avoid UTC shifts
274
+ let start: Date | null = null;
275
+ if (event.start.dateTime) {
276
+ start = new Date(event.start.dateTime);
277
+ } else if (event.start.date) {
278
+ const parts = event.start.date.split("-").map(Number);
279
+ if (parts.length === 3 && parts.every((p) => !Number.isNaN(p))) {
280
+ const [year, month, day] = parts as [number, number, number];
281
+ start = new Date(year, month - 1, day);
282
+ }
283
+ }
284
+
285
+ // Parse end date - for all-day events, parse as local date to avoid UTC shifts
286
+ let end: Date | null = null;
287
+ if (event.end.dateTime) {
288
+ end = new Date(event.end.dateTime);
289
+ } else if (event.end.date) {
290
+ const parts = event.end.date.split("-").map(Number);
291
+ if (parts.length === 3 && parts.every((p) => !Number.isNaN(p))) {
292
+ const [year, month, day] = parts as [number, number, number];
293
+ end = new Date(year, month - 1, day);
294
+ }
295
+ }
296
+
297
+ if (!start || !end) return null;
298
+
299
+ // Detect all-day events: Google uses 'date' instead of 'dateTime' for all-day events
300
+ const isAllDay = !!event.start?.date && !event.start?.dateTime;
301
+
302
+ const eventColor = event.colorId
303
+ ? GoogleCalendarSource.GOOGLE_COLORS[event.colorId]
304
+ : undefined;
305
+
306
+ const organizer: CalendarEvent["organizer"] = event.organizer?.email
307
+ ? {
308
+ email: event.organizer.email,
309
+ name: event.organizer.displayName,
310
+ }
311
+ : undefined;
312
+
313
+ const attendees: CalendarEvent["attendees"] = event.attendees
314
+ ?.map((att) => ({
315
+ email: att.email || "",
316
+ name: att.displayName,
317
+ role: att.optional ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT",
318
+ status: this.mapGoogleResponseStatus(att.responseStatus),
319
+ }))
320
+ .filter((att) => att.email);
321
+
322
+ return {
323
+ id: event.id,
324
+ title: event.summary || "Untitled Event",
325
+ start,
326
+ end,
327
+ isAllDay,
328
+ color: eventColor || this.color,
329
+ calendar: this.name,
330
+ calendarId: this.id,
331
+ sourceId: this.id,
332
+ description: event.description,
333
+ location: event.location,
334
+ url: event.htmlLink,
335
+ rrule: event.recurrence?.[0] || undefined,
336
+ readOnly: false,
337
+ organizer,
338
+ attendees,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Map Google Calendar response status to internal status.
344
+ */
345
+ private mapGoogleResponseStatus(
346
+ status?: string,
347
+ ): CalendarEvent["attendees"][number]["status"] {
348
+ switch (status) {
349
+ case "accepted":
350
+ return "ACCEPTED";
351
+ case "declined":
352
+ return "DECLINED";
353
+ case "tentative":
354
+ return "TENTATIVE";
355
+ default:
356
+ return "NEEDS-ACTION";
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Map internal response status to Google Calendar status.
362
+ */
363
+ private mapToGoogleResponseStatus(
364
+ status?: CalendarEvent["attendees"][number]["status"],
365
+ ): string {
366
+ switch (status) {
367
+ case "ACCEPTED":
368
+ return "accepted";
369
+ case "DECLINED":
370
+ return "declined";
371
+ case "TENTATIVE":
372
+ return "tentative";
373
+ default:
374
+ return "needsAction";
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Format a Date for Google Calendar API (ISO 8601).
380
+ */
381
+ private formatDateTime(date: Date): string {
382
+ return date.toISOString();
383
+ }
384
+
385
+ /**
386
+ * Convert internal CalendarEvent to Google Calendar event format.
387
+ */
388
+ private mapToGoogleEvent(
389
+ event: Omit<CalendarEvent, "calendar" | "color">,
390
+ ): object {
391
+ const googleEvent: Record<string, unknown> = {
392
+ summary: event.title,
393
+ start: {
394
+ dateTime: this.formatDateTime(event.start),
395
+ },
396
+ end: {
397
+ dateTime: this.formatDateTime(event.end),
398
+ },
399
+ };
400
+
401
+ if (event.description) {
402
+ googleEvent.description = event.description;
403
+ }
404
+
405
+ if (event.location) {
406
+ googleEvent.location = event.location;
407
+ }
408
+
409
+ if (event.attendees && event.attendees.length > 0) {
410
+ googleEvent.attendees = event.attendees.map((att) => ({
411
+ email: att.email,
412
+ displayName: att.name,
413
+ responseStatus: this.mapToGoogleResponseStatus(att.status),
414
+ optional: att.role === "OPT-PARTICIPANT",
415
+ }));
416
+ }
417
+
418
+ return googleEvent;
419
+ }
420
+
421
+ /**
422
+ * Fetch events from Google Calendar.
423
+ * Fetches events from 1 year ago to 1 year in the future.
424
+ */
425
+ async fetchEvents(): Promise<CalendarEvent[]> {
426
+ if (!this.enabled) return [];
427
+
428
+ const now = new Date();
429
+ const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
430
+ const oneYearFuture = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
431
+
432
+ const params = new URLSearchParams({
433
+ timeMin: oneYearAgo.toISOString(),
434
+ timeMax: oneYearFuture.toISOString(),
435
+ singleEvents: "true",
436
+ orderBy: "startTime",
437
+ maxResults: "2500",
438
+ });
439
+
440
+ const response = await this.apiRequest<{
441
+ items: GoogleCalendarEvent[];
442
+ }>(`/calendars/${encodeURIComponent(this.calendarId)}/events?${params}`);
443
+
444
+ if (!response.items) return [];
445
+
446
+ return response.items
447
+ .map((event) => this.mapGoogleEvent(event))
448
+ .filter((event): event is CalendarEvent => event !== null);
449
+ }
450
+
451
+ /**
452
+ * Create a new event in Google Calendar.
453
+ */
454
+ async createEvent(
455
+ event: Omit<CalendarEvent, "calendar" | "color">,
456
+ ): Promise<CalendarEvent> {
457
+ const googleEvent = this.mapToGoogleEvent(event);
458
+
459
+ const response = await this.apiRequest<GoogleCalendarEvent>(
460
+ `/calendars/${encodeURIComponent(this.calendarId)}/events`,
461
+ {
462
+ method: "POST",
463
+ body: JSON.stringify(googleEvent),
464
+ },
465
+ );
466
+
467
+ const created = this.mapGoogleEvent(response);
468
+ if (!created) {
469
+ throw new Error(
470
+ "Failed to create event: invalid response from Google Calendar",
471
+ );
472
+ }
473
+
474
+ return created;
475
+ }
476
+
477
+ /**
478
+ * Update an existing event in Google Calendar.
479
+ */
480
+ async updateEvent(
481
+ event: CalendarEvent,
482
+ updates: Partial<CalendarEvent>,
483
+ ): Promise<CalendarEvent> {
484
+ const id = event.id;
485
+ const isRsvpOnly =
486
+ Object.keys(updates).length === 1 && updates.attendees !== undefined;
487
+
488
+ if (isRsvpOnly) {
489
+ // PATCH with only attendees — allowed for non-organizers updating their own response status
490
+ const response = await this.apiRequest<GoogleCalendarEvent>(
491
+ `/calendars/${encodeURIComponent(
492
+ this.calendarId,
493
+ )}/events/${encodeURIComponent(id)}`,
494
+ {
495
+ method: "PATCH",
496
+ body: JSON.stringify({
497
+ attendees: updates.attendees?.map((att) => ({
498
+ email: att.email,
499
+ displayName: att.name,
500
+ responseStatus: this.mapToGoogleResponseStatus(att.status),
501
+ optional: att.role === "OPT-PARTICIPANT",
502
+ })),
503
+ }),
504
+ },
505
+ );
506
+ const updated = this.mapGoogleEvent(response);
507
+ if (!updated) {
508
+ throw new Error(
509
+ "Failed to update event: invalid response from Google Calendar",
510
+ );
511
+ }
512
+ return updated;
513
+ }
514
+
515
+ const existing = await this.apiRequest<GoogleCalendarEvent>(
516
+ `/calendars/${encodeURIComponent(
517
+ this.calendarId,
518
+ )}/events/${encodeURIComponent(id)}`,
519
+ );
520
+
521
+ const updatedEvent: Record<string, unknown> = {
522
+ summary: updates.title ?? existing.summary,
523
+ description:
524
+ updates.description !== undefined
525
+ ? updates.description
526
+ : existing.description,
527
+ location:
528
+ updates.location !== undefined ? updates.location : existing.location,
529
+ start: updates.start
530
+ ? { dateTime: this.formatDateTime(updates.start) }
531
+ : existing.start,
532
+ end: updates.end
533
+ ? { dateTime: this.formatDateTime(updates.end) }
534
+ : existing.end,
535
+ };
536
+
537
+ if (updates.attendees) {
538
+ updatedEvent.attendees = updates.attendees.map((att) => ({
539
+ email: att.email,
540
+ displayName: att.name,
541
+ responseStatus: this.mapToGoogleResponseStatus(att.status),
542
+ optional: att.role === "OPT-PARTICIPANT",
543
+ }));
544
+ }
545
+
546
+ const response = await this.apiRequest<GoogleCalendarEvent>(
547
+ `/calendars/${encodeURIComponent(
548
+ this.calendarId,
549
+ )}/events/${encodeURIComponent(id)}`,
550
+ {
551
+ method: "PUT",
552
+ body: JSON.stringify(updatedEvent),
553
+ },
554
+ );
555
+
556
+ const updated = this.mapGoogleEvent(response);
557
+ if (!updated) {
558
+ throw new Error(
559
+ "Failed to update event: invalid response from Google Calendar",
560
+ );
561
+ }
562
+
563
+ return updated;
564
+ }
565
+
566
+ /**
567
+ * Delete an event from Google Calendar.
568
+ */
569
+ async deleteEvent(id: string): Promise<void> {
570
+ const token = await this.getAccessToken();
571
+ const baseUrl = "https://www.googleapis.com/calendar/v3";
572
+ const url = `${baseUrl}/calendars/${encodeURIComponent(
573
+ this.calendarId,
574
+ )}/events/${encodeURIComponent(id)}`;
575
+
576
+ const response = await fetch(url, {
577
+ method: "DELETE",
578
+ headers: {
579
+ Authorization: `Bearer ${token}`,
580
+ "Content-Type": "application/json",
581
+ },
582
+ });
583
+
584
+ if (!response.ok && response.status !== 410) {
585
+ if (response.status === 401) {
586
+ throw new Error(
587
+ "Google Calendar authentication failed. Please re-authenticate.",
588
+ );
589
+ }
590
+ throw new Error(
591
+ `Google Calendar API error: ${response.status} ${response.statusText}`,
592
+ );
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Test the connection to Google Calendar.
598
+ * Returns true if the credentials are valid.
599
+ */
600
+ async testConnection(): Promise<boolean> {
601
+ try {
602
+ await this.apiRequest<{ summary: string }>(
603
+ `/calendars/${encodeURIComponent(this.calendarId)}`,
604
+ );
605
+ return true;
606
+ } catch (error) {
607
+ return false;
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Get available Google Calendar colors.
613
+ */
614
+ static async getAvailableColors(
615
+ accessToken: string,
616
+ ): Promise<Record<string, string>> {
617
+ const response = await fetch(
618
+ "https://www.googleapis.com/calendar/v3/colors",
619
+ {
620
+ headers: {
621
+ Authorization: `Bearer ${accessToken}`,
622
+ },
623
+ },
624
+ );
625
+
626
+ if (!response.ok) {
627
+ throw new Error("Failed to fetch Google Calendar colors");
628
+ }
629
+
630
+ const colors: GoogleCalendarColors = await response.json();
631
+ const colorMap: Record<string, string> = {};
632
+
633
+ for (const [id, colorInfo] of Object.entries(colors.event)) {
634
+ colorMap[id] = colorInfo.background;
635
+ }
636
+
637
+ return colorMap;
638
+ }
561
639
  }
562
640
 
563
641
  /**
564
642
  * Generate a random code verifier for PKCE.
565
643
  */
566
644
  function generateCodeVerifier(): string {
567
- const array = new Uint8Array(32);
568
- crypto.getRandomValues(array);
569
- return btoa(String.fromCharCode(...array))
570
- .replace(/\+/g, '-')
571
- .replace(/\//g, '_')
572
- .replace(/=/g, '');
645
+ const array = new Uint8Array(32);
646
+ crypto.getRandomValues(array);
647
+ return btoa(String.fromCharCode(...array))
648
+ .replace(/\+/g, "-")
649
+ .replace(/\//g, "_")
650
+ .replace(/=/g, "");
573
651
  }
574
652
 
575
653
  /**
576
654
  * Generate a code challenge from a code verifier.
577
655
  */
578
656
  async function generateCodeChallenge(verifier: string): Promise<string> {
579
- const encoder = new TextEncoder();
580
- const data = encoder.encode(verifier);
581
- const hash = await crypto.subtle.digest('SHA-256', data);
582
- return btoa(String.fromCharCode(...new Uint8Array(hash)))
583
- .replace(/\+/g, '-')
584
- .replace(/\//g, '_')
585
- .replace(/=/g, '');
657
+ const encoder = new TextEncoder();
658
+ const data = encoder.encode(verifier);
659
+ const hash = await crypto.subtle.digest("SHA-256", data);
660
+ return btoa(String.fromCharCode(...new Uint8Array(hash)))
661
+ .replace(/\+/g, "-")
662
+ .replace(/\//g, "_")
663
+ .replace(/=/g, "");
586
664
  }
587
665
 
588
666
  /**
@@ -605,131 +683,150 @@ async function generateCodeChallenge(verifier: string): Promise<string> {
605
683
  * });
606
684
  */
607
685
  export async function authenticateWithGoogle(
608
- clientId: string,
609
- clientSecret: string,
610
- scopes: string[] = ['https://www.googleapis.com/auth/calendar']
686
+ clientId: string,
687
+ clientSecret: string,
688
+ scopes: string[] = ["https://www.googleapis.com/auth/calendar"],
611
689
  ): Promise<{
612
- accessToken: string;
613
- refreshToken?: string;
614
- expiry: string;
690
+ accessToken: string;
691
+ refreshToken?: string;
692
+ expiry: string;
615
693
  }> {
616
- // Use the redirect URI that matches the Desktop app credential configuration
617
- const redirectUri = typeof window !== 'undefined'
618
- ? window.location.origin
619
- : '';
620
-
621
- const state = crypto.randomUUID();
622
- const scope = scopes.join(' ');
623
-
624
- // Generate PKCE code verifier and challenge
625
- const codeVerifier = generateCodeVerifier();
626
- const codeChallenge = await generateCodeChallenge(codeVerifier);
627
-
628
- const params = new URLSearchParams({
629
- client_id: clientId,
630
- redirect_uri: redirectUri,
631
- response_type: 'code',
632
- scope: scope,
633
- state: state,
634
- code_challenge: codeChallenge,
635
- code_challenge_method: 'S256',
636
- access_type: 'offline',
637
- prompt: 'consent',
638
- });
639
-
640
- const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
641
-
642
- return new Promise((resolve, reject) => {
643
- const popup = window.open(authUrl, 'google-oauth', 'width=500,height=600');
644
-
645
- if (!popup) {
646
- reject(new Error('Failed to open OAuth popup. Please allow popups for this site.'));
647
- return;
648
- }
649
-
650
- const closePopup = () => {
651
- try {
652
- popup.close();
653
- } catch (e) {
654
- // Ignore COOP errors when closing popup
655
- }
656
- };
657
-
658
- const handleMessage = async (event: MessageEvent) => {
659
- if (event.origin !== window.location.origin) return;
660
-
661
- if (event.data?.type === 'google-oauth-callback') {
662
- clearTimeout(authTimeout);
663
- window.removeEventListener('message', handleMessage);
664
-
665
- const { code, error, receivedState } = event.data;
666
-
667
- if (error) {
668
- closePopup();
669
- reject(new Error(`OAuth error: ${error}`));
670
- return;
671
- }
672
-
673
- if (receivedState !== state) {
674
- closePopup();
675
- reject(new Error('OAuth state mismatch'));
676
- return;
677
- }
678
-
679
- if (!code) {
680
- closePopup();
681
- reject(new Error('No authorization code received'));
682
- return;
683
- }
684
-
685
- try {
686
- // Exchange code for tokens
687
- const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
688
- method: 'POST',
689
- headers: {
690
- 'Content-Type': 'application/x-www-form-urlencoded',
691
- },
692
- body: new URLSearchParams({
693
- client_id: clientId,
694
- client_secret: clientSecret,
695
- code: code,
696
- code_verifier: codeVerifier,
697
- grant_type: 'authorization_code',
698
- redirect_uri: redirectUri,
699
- }),
700
- });
701
-
702
- if (!tokenResponse.ok) {
703
- const errorData = await tokenResponse.json();
704
- closePopup();
705
- reject(new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`));
706
- return;
707
- }
708
-
709
- const tokens = await tokenResponse.json();
710
- closePopup();
711
-
712
- const expiry = new Date(Date.now() + tokens.expires_in * 1000);
713
-
714
- resolve({
715
- accessToken: tokens.access_token,
716
- refreshToken: tokens.refresh_token,
717
- expiry: expiry.toISOString(),
718
- });
719
- } catch (err) {
720
- closePopup();
721
- reject(err);
722
- }
723
- }
724
- };
725
-
726
- window.addEventListener('message', handleMessage);
727
-
728
- // Timeout after 5 minutes
729
- const authTimeout = setTimeout(() => {
730
- window.removeEventListener('message', handleMessage);
731
- closePopup();
732
- reject(new Error('Authentication timeout - window was not closed within 5 minutes'));
733
- }, 5 * 60 * 1000);
734
- });
694
+ // Use the redirect URI that matches the Desktop app credential configuration
695
+ const redirectUri =
696
+ typeof window !== "undefined" ? window.location.origin : "";
697
+
698
+ const state = crypto.randomUUID();
699
+ const scope = scopes.join(" ");
700
+
701
+ // Generate PKCE code verifier and challenge
702
+ const codeVerifier = generateCodeVerifier();
703
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
704
+
705
+ const params = new URLSearchParams({
706
+ client_id: clientId,
707
+ redirect_uri: redirectUri,
708
+ response_type: "code",
709
+ scope: scope,
710
+ state: state,
711
+ code_challenge: codeChallenge,
712
+ code_challenge_method: "S256",
713
+ access_type: "offline",
714
+ prompt: "consent",
715
+ });
716
+
717
+ const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
718
+
719
+ return new Promise((resolve, reject) => {
720
+ const popup = window.open(authUrl, "google-oauth", "width=500,height=600");
721
+
722
+ if (!popup) {
723
+ reject(
724
+ new Error(
725
+ "Failed to open OAuth popup. Please allow popups for this site.",
726
+ ),
727
+ );
728
+ return;
729
+ }
730
+
731
+ const closePopup = () => {
732
+ try {
733
+ popup.close();
734
+ } catch (e) {
735
+ // Ignore COOP errors when closing popup
736
+ }
737
+ };
738
+
739
+ const handleMessage = async (event: MessageEvent) => {
740
+ if (event.origin !== window.location.origin) return;
741
+
742
+ if (event.data?.type === "google-oauth-callback") {
743
+ clearTimeout(authTimeout);
744
+ window.removeEventListener("message", handleMessage);
745
+
746
+ const { code, error, receivedState } = event.data;
747
+
748
+ if (error) {
749
+ closePopup();
750
+ reject(new Error(`OAuth error: ${error}`));
751
+ return;
752
+ }
753
+
754
+ if (receivedState !== state) {
755
+ closePopup();
756
+ reject(new Error("OAuth state mismatch"));
757
+ return;
758
+ }
759
+
760
+ if (!code) {
761
+ closePopup();
762
+ reject(new Error("No authorization code received"));
763
+ return;
764
+ }
765
+
766
+ try {
767
+ // Exchange code for tokens
768
+ const tokenResponse = await fetch(
769
+ "https://oauth2.googleapis.com/token",
770
+ {
771
+ method: "POST",
772
+ headers: {
773
+ "Content-Type": "application/x-www-form-urlencoded",
774
+ },
775
+ body: new URLSearchParams({
776
+ client_id: clientId,
777
+ client_secret: clientSecret,
778
+ code: code,
779
+ code_verifier: codeVerifier,
780
+ grant_type: "authorization_code",
781
+ redirect_uri: redirectUri,
782
+ }),
783
+ },
784
+ );
785
+
786
+ if (!tokenResponse.ok) {
787
+ const errorData = await tokenResponse.json();
788
+ closePopup();
789
+ reject(
790
+ new Error(
791
+ `Token exchange failed: ${
792
+ errorData.error_description || errorData.error
793
+ }`,
794
+ ),
795
+ );
796
+ return;
797
+ }
798
+
799
+ const tokens = await tokenResponse.json();
800
+ closePopup();
801
+
802
+ const expiry = new Date(Date.now() + tokens.expires_in * 1000);
803
+
804
+ resolve({
805
+ accessToken: tokens.access_token,
806
+ refreshToken: tokens.refresh_token,
807
+ expiry: expiry.toISOString(),
808
+ });
809
+ } catch (err) {
810
+ closePopup();
811
+ reject(err);
812
+ }
813
+ }
814
+ };
815
+
816
+ window.addEventListener("message", handleMessage);
817
+
818
+ // Timeout after 5 minutes
819
+ const authTimeout = setTimeout(
820
+ () => {
821
+ window.removeEventListener("message", handleMessage);
822
+ closePopup();
823
+ reject(
824
+ new Error(
825
+ "Authentication timeout - window was not closed within 5 minutes",
826
+ ),
827
+ );
828
+ },
829
+ 5 * 60 * 1000,
830
+ );
831
+ });
735
832
  }