@luckydye/calendar 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar.js +1387 -1301
- package/package.json +4 -3
- package/src/ActiveCalendarStore.ts +96 -0
- package/src/CalDAVConfig.ts +1025 -0
- package/src/CalDAVSource.ts +506 -0
- package/src/CalendarIntegration.ts +67 -0
- package/src/CalendarInternal.ts +628 -0
- package/src/CalendarStorage.ts +54 -0
- package/src/CalendarView.ts +5507 -0
- package/src/Color.ts +64 -0
- package/src/GoogleCalendarSource.ts +710 -0
- package/src/ICal.ts +400 -0
- package/src/InMemorySource.ts +74 -0
- package/src/IndexedDBStorage.ts +393 -0
- package/src/InhouseBookingSource.ts +522 -0
- package/src/Keybinds.ts +46 -0
- package/src/NotificationScheduler.ts +91 -0
- package/src/StatusBar.ts +128 -0
- package/src/StatusMessage.ts +122 -0
- package/src/Theme.ts +228 -0
- package/src/app.css +32 -0
- package/src/app.ts +1042 -0
- package/src/lib.ts +4 -0
- package/src/service-worker.js +177 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import type { CalendarEvent } from "./CalendarInternal.js";
|
|
2
|
+
import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google Calendar API credentials.
|
|
6
|
+
*/
|
|
7
|
+
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;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Google Calendar API event response structure.
|
|
28
|
+
*/
|
|
29
|
+
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;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Google Calendar API colors response.
|
|
61
|
+
*/
|
|
62
|
+
interface GoogleCalendarColors {
|
|
63
|
+
event: Record<string, {
|
|
64
|
+
background: string;
|
|
65
|
+
foreground: string;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Google Calendar source for syncing with Google Calendar API.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* const source = new GoogleCalendarSource(
|
|
74
|
+
* 'google-1',
|
|
75
|
+
* 'My Google Calendar',
|
|
76
|
+
* '#4285F4',
|
|
77
|
+
* {
|
|
78
|
+
* accessToken: 'ya29.a0AfH6SMB...',
|
|
79
|
+
* calendarId: 'primary'
|
|
80
|
+
* }
|
|
81
|
+
* );
|
|
82
|
+
*
|
|
83
|
+
* const events = await source.fetchEvents();
|
|
84
|
+
*/
|
|
85
|
+
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 existing = await this.apiRequest<GoogleCalendarEvent>(
|
|
434
|
+
`/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const updatedEvent: Record<string, unknown> = {
|
|
438
|
+
summary: updates.title ?? existing.summary,
|
|
439
|
+
description: updates.description !== undefined ? updates.description : existing.description,
|
|
440
|
+
location: updates.location !== undefined ? updates.location : existing.location,
|
|
441
|
+
start: updates.start
|
|
442
|
+
? { dateTime: this.formatDateTime(updates.start) }
|
|
443
|
+
: existing.start,
|
|
444
|
+
end: updates.end
|
|
445
|
+
? { dateTime: this.formatDateTime(updates.end) }
|
|
446
|
+
: existing.end,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
if (updates.attendees) {
|
|
450
|
+
updatedEvent.attendees = updates.attendees.map(att => ({
|
|
451
|
+
email: att.email,
|
|
452
|
+
displayName: att.name,
|
|
453
|
+
responseStatus: this.mapToGoogleResponseStatus(att.status),
|
|
454
|
+
optional: att.role === 'OPT-PARTICIPANT',
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const response = await this.apiRequest<GoogleCalendarEvent>(
|
|
459
|
+
`/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`,
|
|
460
|
+
{
|
|
461
|
+
method: 'PUT',
|
|
462
|
+
body: JSON.stringify(updatedEvent),
|
|
463
|
+
}
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const updated = this.mapGoogleEvent(response);
|
|
467
|
+
if (!updated) {
|
|
468
|
+
throw new Error('Failed to update event: invalid response from Google Calendar');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return updated;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Delete an event from Google Calendar.
|
|
476
|
+
*/
|
|
477
|
+
async deleteEvent(id: string): Promise<void> {
|
|
478
|
+
const token = await this.getAccessToken();
|
|
479
|
+
const baseUrl = 'https://www.googleapis.com/calendar/v3';
|
|
480
|
+
const url = `${baseUrl}/calendars/${encodeURIComponent(this.calendarId)}/events/${encodeURIComponent(id)}`;
|
|
481
|
+
|
|
482
|
+
const response = await fetch(url, {
|
|
483
|
+
method: 'DELETE',
|
|
484
|
+
headers: {
|
|
485
|
+
Authorization: `Bearer ${token}`,
|
|
486
|
+
'Content-Type': 'application/json',
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (!response.ok && response.status !== 410) {
|
|
491
|
+
if (response.status === 401) {
|
|
492
|
+
throw new Error('Google Calendar authentication failed. Please re-authenticate.');
|
|
493
|
+
}
|
|
494
|
+
throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Test the connection to Google Calendar.
|
|
500
|
+
* Returns true if the credentials are valid.
|
|
501
|
+
*/
|
|
502
|
+
async testConnection(): Promise<boolean> {
|
|
503
|
+
try {
|
|
504
|
+
await this.apiRequest<{ summary: string }>(
|
|
505
|
+
`/calendars/${encodeURIComponent(this.calendarId)}`
|
|
506
|
+
);
|
|
507
|
+
return true;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Get available Google Calendar colors.
|
|
515
|
+
*/
|
|
516
|
+
static async getAvailableColors(accessToken: string): Promise<Record<string, string>> {
|
|
517
|
+
const response = await fetch('https://www.googleapis.com/calendar/v3/colors', {
|
|
518
|
+
headers: {
|
|
519
|
+
Authorization: `Bearer ${accessToken}`,
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (!response.ok) {
|
|
524
|
+
throw new Error('Failed to fetch Google Calendar colors');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const colors: GoogleCalendarColors = await response.json();
|
|
528
|
+
const colorMap: Record<string, string> = {};
|
|
529
|
+
|
|
530
|
+
for (const [id, colorInfo] of Object.entries(colors.event)) {
|
|
531
|
+
colorMap[id] = colorInfo.background;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return colorMap;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Generate a random code verifier for PKCE.
|
|
540
|
+
*/
|
|
541
|
+
function generateCodeVerifier(): string {
|
|
542
|
+
const array = new Uint8Array(32);
|
|
543
|
+
crypto.getRandomValues(array);
|
|
544
|
+
return btoa(String.fromCharCode(...array))
|
|
545
|
+
.replace(/\+/g, '-')
|
|
546
|
+
.replace(/\//g, '_')
|
|
547
|
+
.replace(/=/g, '');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Generate a code challenge from a code verifier.
|
|
552
|
+
*/
|
|
553
|
+
async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
554
|
+
const encoder = new TextEncoder();
|
|
555
|
+
const data = encoder.encode(verifier);
|
|
556
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
557
|
+
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
558
|
+
.replace(/\+/g, '-')
|
|
559
|
+
.replace(/\//g, '_')
|
|
560
|
+
.replace(/=/g, '');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Helper function to initiate Google OAuth2 authentication using PKCE flow.
|
|
565
|
+
*
|
|
566
|
+
* @param clientId - Google OAuth2 client ID
|
|
567
|
+
* @param clientSecret - Google OAuth2 client secret (for Desktop app credentials)
|
|
568
|
+
* @param scopes - OAuth scopes to request (defaults to calendar read/write)
|
|
569
|
+
* @returns Promise resolving to OAuth tokens
|
|
570
|
+
*
|
|
571
|
+
* @example
|
|
572
|
+
* const tokens = await authenticateWithGoogle(
|
|
573
|
+
* 'your-client-id.apps.googleusercontent.com',
|
|
574
|
+
* 'your-client-secret'
|
|
575
|
+
* );
|
|
576
|
+
* const source = new GoogleCalendarSource('google-1', 'My Calendar', '#4285F4', {
|
|
577
|
+
* accessToken: tokens.accessToken,
|
|
578
|
+
* refreshToken: tokens.refreshToken,
|
|
579
|
+
* tokenExpiry: tokens.expiry,
|
|
580
|
+
* });
|
|
581
|
+
*/
|
|
582
|
+
export async function authenticateWithGoogle(
|
|
583
|
+
clientId: string,
|
|
584
|
+
clientSecret: string,
|
|
585
|
+
scopes: string[] = ['https://www.googleapis.com/auth/calendar']
|
|
586
|
+
): Promise<{
|
|
587
|
+
accessToken: string;
|
|
588
|
+
refreshToken?: string;
|
|
589
|
+
expiry: string;
|
|
590
|
+
}> {
|
|
591
|
+
// Use the redirect URI that matches the Desktop app credential configuration
|
|
592
|
+
const redirectUri = typeof window !== 'undefined'
|
|
593
|
+
? window.location.origin
|
|
594
|
+
: '';
|
|
595
|
+
|
|
596
|
+
const state = crypto.randomUUID();
|
|
597
|
+
const scope = scopes.join(' ');
|
|
598
|
+
|
|
599
|
+
// Generate PKCE code verifier and challenge
|
|
600
|
+
const codeVerifier = generateCodeVerifier();
|
|
601
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
602
|
+
|
|
603
|
+
const params = new URLSearchParams({
|
|
604
|
+
client_id: clientId,
|
|
605
|
+
redirect_uri: redirectUri,
|
|
606
|
+
response_type: 'code',
|
|
607
|
+
scope: scope,
|
|
608
|
+
state: state,
|
|
609
|
+
code_challenge: codeChallenge,
|
|
610
|
+
code_challenge_method: 'S256',
|
|
611
|
+
access_type: 'offline',
|
|
612
|
+
prompt: 'consent',
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
616
|
+
|
|
617
|
+
return new Promise((resolve, reject) => {
|
|
618
|
+
const popup = window.open(authUrl, 'google-oauth', 'width=500,height=600');
|
|
619
|
+
|
|
620
|
+
if (!popup) {
|
|
621
|
+
reject(new Error('Failed to open OAuth popup. Please allow popups for this site.'));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const closePopup = () => {
|
|
626
|
+
try {
|
|
627
|
+
popup.close();
|
|
628
|
+
} catch (e) {
|
|
629
|
+
// Ignore COOP errors when closing popup
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const handleMessage = async (event: MessageEvent) => {
|
|
634
|
+
if (event.origin !== window.location.origin) return;
|
|
635
|
+
|
|
636
|
+
if (event.data?.type === 'google-oauth-callback') {
|
|
637
|
+
clearTimeout(authTimeout);
|
|
638
|
+
window.removeEventListener('message', handleMessage);
|
|
639
|
+
|
|
640
|
+
const { code, error, receivedState } = event.data;
|
|
641
|
+
|
|
642
|
+
if (error) {
|
|
643
|
+
closePopup();
|
|
644
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (receivedState !== state) {
|
|
649
|
+
closePopup();
|
|
650
|
+
reject(new Error('OAuth state mismatch'));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (!code) {
|
|
655
|
+
closePopup();
|
|
656
|
+
reject(new Error('No authorization code received'));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
// Exchange code for tokens
|
|
662
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
663
|
+
method: 'POST',
|
|
664
|
+
headers: {
|
|
665
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
666
|
+
},
|
|
667
|
+
body: new URLSearchParams({
|
|
668
|
+
client_id: clientId,
|
|
669
|
+
client_secret: clientSecret,
|
|
670
|
+
code: code,
|
|
671
|
+
code_verifier: codeVerifier,
|
|
672
|
+
grant_type: 'authorization_code',
|
|
673
|
+
redirect_uri: redirectUri,
|
|
674
|
+
}),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!tokenResponse.ok) {
|
|
678
|
+
const errorData = await tokenResponse.json();
|
|
679
|
+
closePopup();
|
|
680
|
+
reject(new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const tokens = await tokenResponse.json();
|
|
685
|
+
closePopup();
|
|
686
|
+
|
|
687
|
+
const expiry = new Date(Date.now() + tokens.expires_in * 1000);
|
|
688
|
+
|
|
689
|
+
resolve({
|
|
690
|
+
accessToken: tokens.access_token,
|
|
691
|
+
refreshToken: tokens.refresh_token,
|
|
692
|
+
expiry: expiry.toISOString(),
|
|
693
|
+
});
|
|
694
|
+
} catch (err) {
|
|
695
|
+
closePopup();
|
|
696
|
+
reject(err);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
window.addEventListener('message', handleMessage);
|
|
702
|
+
|
|
703
|
+
// Timeout after 5 minutes
|
|
704
|
+
const authTimeout = setTimeout(() => {
|
|
705
|
+
window.removeEventListener('message', handleMessage);
|
|
706
|
+
closePopup();
|
|
707
|
+
reject(new Error('Authentication timeout - window was not closed within 5 minutes'));
|
|
708
|
+
}, 5 * 60 * 1000);
|
|
709
|
+
});
|
|
710
|
+
}
|