@luckydye/calendar 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,507 +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(event: CalendarEvent, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
433
- const id = event.id;
434
- const isRsvpOnly = Object.keys(updates).length === 1 && updates.attendees !== undefined;
435
-
436
- if (isRsvpOnly) {
437
- // PATCH with only attendees — allowed for non-organizers updating their own response status
438
- const response = await this.apiRequest<GoogleCalendarEvent>(
439
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
440
- {
441
- method: 'PATCH',
442
- body: JSON.stringify({
443
- attendees: updates.attendees!.map(att => ({
444
- email: att.email,
445
- displayName: att.name,
446
- responseStatus: this.mapToGoogleResponseStatus(att.status),
447
- optional: att.role === 'OPT-PARTICIPANT',
448
- })),
449
- }),
450
- }
451
- );
452
- const updated = this.mapGoogleEvent(response);
453
- if (!updated) {
454
- throw new Error('Failed to update event: invalid response from Google Calendar');
455
- }
456
- return updated;
457
- }
458
-
459
- const existing = await this.apiRequest<GoogleCalendarEvent>(
460
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`
461
- );
462
-
463
- const updatedEvent: Record<string, unknown> = {
464
- summary: updates.title ?? existing.summary,
465
- description: updates.description !== undefined ? updates.description : existing.description,
466
- location: updates.location !== undefined ? updates.location : existing.location,
467
- start: updates.start
468
- ? { dateTime: this.formatDateTime(updates.start) }
469
- : existing.start,
470
- end: updates.end
471
- ? { dateTime: this.formatDateTime(updates.end) }
472
- : existing.end,
473
- };
474
-
475
- if (updates.attendees) {
476
- updatedEvent.attendees = updates.attendees.map(att => ({
477
- email: att.email,
478
- displayName: att.name,
479
- responseStatus: this.mapToGoogleResponseStatus(att.status),
480
- optional: att.role === 'OPT-PARTICIPANT',
481
- }));
482
- }
483
-
484
- const response = await this.apiRequest<GoogleCalendarEvent>(
485
- `/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
486
- {
487
- method: 'PUT',
488
- body: JSON.stringify(updatedEvent),
489
- }
490
- );
491
-
492
- const updated = this.mapGoogleEvent(response);
493
- if (!updated) {
494
- throw new Error('Failed to update event: invalid response from Google Calendar');
495
- }
496
-
497
- return updated;
498
- }
499
-
500
- /**
501
- * Delete an event from Google Calendar.
502
- */
503
- async deleteEvent(id: string): Promise<void> {
504
- const token = await this.getAccessToken();
505
- const baseUrl = 'https://www.googleapis.com/calendar/v3';
506
- const url = `${baseUrl}/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`;
507
-
508
- const response = await fetch(url, {
509
- method: 'DELETE',
510
- headers: {
511
- Authorization: `Bearer ${token}`,
512
- 'Content-Type': 'application/json',
513
- },
514
- });
515
-
516
- if (!response.ok && response.status !== 410) {
517
- if (response.status === 401) {
518
- throw new Error('Google Calendar authentication failed. Please re-authenticate.');
519
- }
520
- throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
521
- }
522
- }
523
-
524
- /**
525
- * Test the connection to Google Calendar.
526
- * Returns true if the credentials are valid.
527
- */
528
- async testConnection(): Promise<boolean> {
529
- try {
530
- await this.apiRequest<{ summary: string }>(
531
- `/calendars/${encodeURIComponent(this.calendarId)}`
532
- );
533
- return true;
534
- } catch (error) {
535
- return false;
536
- }
537
- }
538
-
539
- /**
540
- * Get available Google Calendar colors.
541
- */
542
- static async getAvailableColors(accessToken: string): Promise<Record<string, string>> {
543
- const response = await fetch('https://www.googleapis.com/calendar/v3/colors', {
544
- headers: {
545
- Authorization: `Bearer ${accessToken}`,
546
- },
547
- });
548
-
549
- if (!response.ok) {
550
- throw new Error('Failed to fetch Google Calendar colors');
551
- }
552
-
553
- const colors: GoogleCalendarColors = await response.json();
554
- const colorMap: Record<string, string> = {};
555
-
556
- for (const [id, colorInfo] of Object.entries(colors.event)) {
557
- colorMap[id] = colorInfo.background;
558
- }
559
-
560
- return colorMap;
561
- }
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
+ }
562
639
  }
563
640
 
564
641
  /**
565
642
  * Generate a random code verifier for PKCE.
566
643
  */
567
644
  function generateCodeVerifier(): string {
568
- const array = new Uint8Array(32);
569
- crypto.getRandomValues(array);
570
- return btoa(String.fromCharCode(...array))
571
- .replace(/\+/g, '-')
572
- .replace(/\//g, '_')
573
- .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, "");
574
651
  }
575
652
 
576
653
  /**
577
654
  * Generate a code challenge from a code verifier.
578
655
  */
579
656
  async function generateCodeChallenge(verifier: string): Promise<string> {
580
- const encoder = new TextEncoder();
581
- const data = encoder.encode(verifier);
582
- const hash = await crypto.subtle.digest('SHA-256', data);
583
- return btoa(String.fromCharCode(...new Uint8Array(hash)))
584
- .replace(/\+/g, '-')
585
- .replace(/\//g, '_')
586
- .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, "");
587
664
  }
588
665
 
589
666
  /**
@@ -606,131 +683,150 @@ async function generateCodeChallenge(verifier: string): Promise<string> {
606
683
  * });
607
684
  */
608
685
  export async function authenticateWithGoogle(
609
- clientId: string,
610
- clientSecret: string,
611
- scopes: string[] = ['https://www.googleapis.com/auth/calendar']
686
+ clientId: string,
687
+ clientSecret: string,
688
+ scopes: string[] = ["https://www.googleapis.com/auth/calendar"],
612
689
  ): Promise<{
613
- accessToken: string;
614
- refreshToken?: string;
615
- expiry: string;
690
+ accessToken: string;
691
+ refreshToken?: string;
692
+ expiry: string;
616
693
  }> {
617
- // Use the redirect URI that matches the Desktop app credential configuration
618
- const redirectUri = typeof window !== 'undefined'
619
- ? window.location.origin
620
- : '';
621
-
622
- const state = crypto.randomUUID();
623
- const scope = scopes.join(' ');
624
-
625
- // Generate PKCE code verifier and challenge
626
- const codeVerifier = generateCodeVerifier();
627
- const codeChallenge = await generateCodeChallenge(codeVerifier);
628
-
629
- const params = new URLSearchParams({
630
- client_id: clientId,
631
- redirect_uri: redirectUri,
632
- response_type: 'code',
633
- scope: scope,
634
- state: state,
635
- code_challenge: codeChallenge,
636
- code_challenge_method: 'S256',
637
- access_type: 'offline',
638
- prompt: 'consent',
639
- });
640
-
641
- const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
642
-
643
- return new Promise((resolve, reject) => {
644
- const popup = window.open(authUrl, 'google-oauth', 'width=500,height=600');
645
-
646
- if (!popup) {
647
- reject(new Error('Failed to open OAuth popup. Please allow popups for this site.'));
648
- return;
649
- }
650
-
651
- const closePopup = () => {
652
- try {
653
- popup.close();
654
- } catch (e) {
655
- // Ignore COOP errors when closing popup
656
- }
657
- };
658
-
659
- const handleMessage = async (event: MessageEvent) => {
660
- if (event.origin !== window.location.origin) return;
661
-
662
- if (event.data?.type === 'google-oauth-callback') {
663
- clearTimeout(authTimeout);
664
- window.removeEventListener('message', handleMessage);
665
-
666
- const { code, error, receivedState } = event.data;
667
-
668
- if (error) {
669
- closePopup();
670
- reject(new Error(`OAuth error: ${error}`));
671
- return;
672
- }
673
-
674
- if (receivedState !== state) {
675
- closePopup();
676
- reject(new Error('OAuth state mismatch'));
677
- return;
678
- }
679
-
680
- if (!code) {
681
- closePopup();
682
- reject(new Error('No authorization code received'));
683
- return;
684
- }
685
-
686
- try {
687
- // Exchange code for tokens
688
- const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
689
- method: 'POST',
690
- headers: {
691
- 'Content-Type': 'application/x-www-form-urlencoded',
692
- },
693
- body: new URLSearchParams({
694
- client_id: clientId,
695
- client_secret: clientSecret,
696
- code: code,
697
- code_verifier: codeVerifier,
698
- grant_type: 'authorization_code',
699
- redirect_uri: redirectUri,
700
- }),
701
- });
702
-
703
- if (!tokenResponse.ok) {
704
- const errorData = await tokenResponse.json();
705
- closePopup();
706
- reject(new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`));
707
- return;
708
- }
709
-
710
- const tokens = await tokenResponse.json();
711
- closePopup();
712
-
713
- const expiry = new Date(Date.now() + tokens.expires_in * 1000);
714
-
715
- resolve({
716
- accessToken: tokens.access_token,
717
- refreshToken: tokens.refresh_token,
718
- expiry: expiry.toISOString(),
719
- });
720
- } catch (err) {
721
- closePopup();
722
- reject(err);
723
- }
724
- }
725
- };
726
-
727
- window.addEventListener('message', handleMessage);
728
-
729
- // Timeout after 5 minutes
730
- const authTimeout = setTimeout(() => {
731
- window.removeEventListener('message', handleMessage);
732
- closePopup();
733
- reject(new Error('Authentication timeout - window was not closed within 5 minutes'));
734
- }, 5 * 60 * 1000);
735
- });
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
+ });
736
832
  }