@kubun/connector-google-calendar 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api.d.ts +1 -0
- package/lib/api.js +16 -4
- package/lib/provider.js +139 -115
- package/package.json +9 -6
package/lib/api.d.ts
CHANGED
package/lib/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
2
|
-
async function parseGoogleError(response) {
|
|
2
|
+
export async function parseGoogleError(response) {
|
|
3
3
|
try {
|
|
4
4
|
const body = await response.json();
|
|
5
5
|
return body.error?.message ?? `${response.status} ${response.statusText}`;
|
|
@@ -9,13 +9,19 @@ async function parseGoogleError(response) {
|
|
|
9
9
|
}
|
|
10
10
|
export function buildCalendarListURL(params) {
|
|
11
11
|
const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
|
|
12
|
-
|
|
12
|
+
// Google rejects `showHidden=false` (and `showDeleted=false`) alongside a
|
|
13
|
+
// syncToken: an incremental list must not narrow the result set the token was
|
|
14
|
+
// issued against. A paged continuation (pageToken) inherits the first page's
|
|
15
|
+
// context, so only the very first, tokenless page constrains visibility.
|
|
13
16
|
if (params.syncToken != null) {
|
|
14
17
|
url.searchParams.set('syncToken', params.syncToken);
|
|
15
18
|
}
|
|
16
19
|
if (params.pageToken != null) {
|
|
17
20
|
url.searchParams.set('pageToken', params.pageToken);
|
|
18
21
|
}
|
|
22
|
+
if (params.syncToken == null && params.pageToken == null) {
|
|
23
|
+
url.searchParams.set('showHidden', 'false');
|
|
24
|
+
}
|
|
19
25
|
return url.toString();
|
|
20
26
|
}
|
|
21
27
|
export function buildEventsListURL(params) {
|
|
@@ -43,10 +49,16 @@ export function mapCalendarToEntity(calendar) {
|
|
|
43
49
|
backgroundColor: calendar.backgroundColor
|
|
44
50
|
};
|
|
45
51
|
}
|
|
52
|
+
// Google gives all-day events a bare calendar date (`YYYY-MM-DD`); the model's
|
|
53
|
+
// startAt/endAt are `date-time`, so a bare date fails schema validation. Anchor
|
|
54
|
+
// it to midnight UTC — the allDay flag tells consumers to ignore the time.
|
|
55
|
+
function allDayDateToDateTime(date) {
|
|
56
|
+
return date == null ? undefined : `${date}T00:00:00.000Z`;
|
|
57
|
+
}
|
|
46
58
|
export function mapEventToEntity(event, calendarID) {
|
|
47
59
|
const allDay = event.start.date != null;
|
|
48
|
-
const startAt = allDay ? event.start.date : event.start.dateTime;
|
|
49
|
-
const endAt = allDay ? event.end.date : event.end.dateTime;
|
|
60
|
+
const startAt = allDay ? allDayDateToDateTime(event.start.date) : event.start.dateTime;
|
|
61
|
+
const endAt = allDay ? allDayDateToDateTime(event.end.date) : event.end.dateTime;
|
|
50
62
|
return {
|
|
51
63
|
sourceService: 'google-calendar',
|
|
52
64
|
sourceID: event.id,
|
package/lib/provider.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity } from './api.js';
|
|
2
|
+
import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity, parseGoogleError } from './api.js';
|
|
3
3
|
import { googleCalendarClusters } from './model.js';
|
|
4
4
|
const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
|
|
5
5
|
const CALENDAR_EVENT_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendarEvent');
|
|
6
|
+
class GoogleCalendarAPIError extends Error {
|
|
7
|
+
#status;
|
|
8
|
+
constructor(status, detail){
|
|
9
|
+
super(`Google Calendar API error: ${detail}`);
|
|
10
|
+
this.name = 'GoogleCalendarAPIError';
|
|
11
|
+
this.#status = status;
|
|
12
|
+
}
|
|
13
|
+
get status() {
|
|
14
|
+
return this.#status;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
6
17
|
async function fetchJSON(url, credential, fetchFn, signal) {
|
|
7
18
|
const response = await fetchFn(url, {
|
|
8
19
|
headers: {
|
|
@@ -11,7 +22,7 @@ async function fetchJSON(url, credential, fetchFn, signal) {
|
|
|
11
22
|
signal
|
|
12
23
|
});
|
|
13
24
|
if (!response.ok) {
|
|
14
|
-
throw new
|
|
25
|
+
throw new GoogleCalendarAPIError(response.status, await parseGoogleError(response));
|
|
15
26
|
}
|
|
16
27
|
return await response.json();
|
|
17
28
|
}
|
|
@@ -151,131 +162,144 @@ export function createGoogleCalendarProvider(config) {
|
|
|
151
162
|
};
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
async function* fetchIncrementalEntities(params, checkpoint) {
|
|
166
|
+
const timeMin = computeTimeMin(params.boundary);
|
|
167
|
+
// Phase 1: fetch calendar list changes using stored syncToken
|
|
168
|
+
const activeCalendars = [];
|
|
169
|
+
const deletedCalendarIDs = [];
|
|
170
|
+
let newCalendarListSyncToken;
|
|
171
|
+
for await (const page of fetchAllCalendars({
|
|
172
|
+
credential: config.credential,
|
|
173
|
+
fetchFn,
|
|
174
|
+
syncToken: checkpoint.calendarListSyncToken,
|
|
175
|
+
signal: params.signal
|
|
176
|
+
})){
|
|
177
|
+
for (const cal of page.items){
|
|
178
|
+
if (cal.deleted === true) {
|
|
179
|
+
deletedCalendarIDs.push(cal.id);
|
|
180
|
+
} else if (!excludeSet.has(cal.id)) {
|
|
181
|
+
activeCalendars.push(cal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (page.nextSyncToken != null) {
|
|
185
|
+
newCalendarListSyncToken = page.nextSyncToken;
|
|
164
186
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
187
|
+
}
|
|
188
|
+
// Yield calendar batch (active + deletions)
|
|
189
|
+
yield {
|
|
190
|
+
modelID: CALENDAR_MODEL_ID,
|
|
191
|
+
entities: activeCalendars.map(mapCalendarToEntity),
|
|
192
|
+
deleted: deletedCalendarIDs.map((id)=>({
|
|
193
|
+
sourceService: 'google-calendar',
|
|
194
|
+
sourceID: id
|
|
195
|
+
})),
|
|
196
|
+
hasMore: true
|
|
197
|
+
};
|
|
198
|
+
// Phase 2: determine the set of calendars to sync events for
|
|
199
|
+
// Start from the previous calendar set, remove deleted, add newly discovered active ones
|
|
200
|
+
const prevCalendarTokens = {
|
|
201
|
+
...checkpoint.calendars
|
|
202
|
+
};
|
|
203
|
+
for (const deletedID of deletedCalendarIDs){
|
|
204
|
+
delete prevCalendarTokens[deletedID];
|
|
205
|
+
}
|
|
206
|
+
// Collect new calendars that weren't in the previous checkpoint
|
|
207
|
+
const newlyDiscoveredCalendars = activeCalendars.filter((cal)=>!(cal.id in checkpoint.calendars));
|
|
208
|
+
// All calendars to sync: known (from prev checkpoint, minus deleted) + newly discovered
|
|
209
|
+
const knownCalendarIDs = Object.keys(prevCalendarTokens);
|
|
210
|
+
// Combine: known calendar IDs (we don't have resource objects for them, only IDs)
|
|
211
|
+
// plus newly discovered calendar resources
|
|
212
|
+
const allCalendarIDsToSync = [
|
|
213
|
+
...knownCalendarIDs.map((id)=>({
|
|
214
|
+
id,
|
|
215
|
+
isNew: false
|
|
216
|
+
})),
|
|
217
|
+
...newlyDiscoveredCalendars.map((cal)=>({
|
|
218
|
+
id: cal.id,
|
|
219
|
+
isNew: true
|
|
220
|
+
}))
|
|
221
|
+
];
|
|
222
|
+
const updatedCalendarTokens = {};
|
|
223
|
+
for(let i = 0; i < allCalendarIDsToSync.length; i++){
|
|
224
|
+
const { id: calendarID, isNew } = allCalendarIDsToSync[i];
|
|
225
|
+
const isLastCalendar = i === allCalendarIDsToSync.length - 1;
|
|
226
|
+
// Use stored syncToken for known calendars; use timeMin for new ones
|
|
227
|
+
const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
|
|
228
|
+
let eventsSyncToken;
|
|
229
|
+
for await (const page of fetchEventsForCalendar({
|
|
230
|
+
calendarID,
|
|
171
231
|
credential: config.credential,
|
|
172
232
|
fetchFn,
|
|
173
|
-
syncToken:
|
|
233
|
+
syncToken: storedToken,
|
|
234
|
+
timeMin: isNew ? timeMin : undefined,
|
|
174
235
|
signal: params.signal
|
|
175
236
|
})){
|
|
176
|
-
for (const cal of page.items){
|
|
177
|
-
if (cal.deleted === true) {
|
|
178
|
-
deletedCalendarIDs.push(cal.id);
|
|
179
|
-
} else if (!excludeSet.has(cal.id)) {
|
|
180
|
-
activeCalendars.push(cal);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
237
|
if (page.nextSyncToken != null) {
|
|
184
|
-
|
|
238
|
+
eventsSyncToken = page.nextSyncToken;
|
|
185
239
|
}
|
|
240
|
+
const isLastPage = page.nextSyncToken != null;
|
|
241
|
+
const newCheckpoint = isLastCalendar && isLastPage ? {
|
|
242
|
+
calendarListSyncToken: newCalendarListSyncToken,
|
|
243
|
+
calendars: {
|
|
244
|
+
...updatedCalendarTokens,
|
|
245
|
+
[calendarID]: {
|
|
246
|
+
eventsSyncToken: page.nextSyncToken
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} : undefined;
|
|
250
|
+
// Split items: cancelled → deleted, others → entities
|
|
251
|
+
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
252
|
+
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
253
|
+
yield {
|
|
254
|
+
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
255
|
+
entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
|
|
256
|
+
deleted: cancelledItems.map((e)=>({
|
|
257
|
+
sourceService: 'google-calendar',
|
|
258
|
+
sourceID: e.id
|
|
259
|
+
})),
|
|
260
|
+
hasMore: !(isLastCalendar && isLastPage),
|
|
261
|
+
checkpoint: newCheckpoint
|
|
262
|
+
};
|
|
186
263
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
modelID: CALENDAR_MODEL_ID,
|
|
190
|
-
entities: activeCalendars.map(mapCalendarToEntity),
|
|
191
|
-
deleted: deletedCalendarIDs.map((id)=>({
|
|
192
|
-
sourceService: 'google-calendar',
|
|
193
|
-
sourceID: id
|
|
194
|
-
})),
|
|
195
|
-
hasMore: true
|
|
264
|
+
updatedCalendarTokens[calendarID] = {
|
|
265
|
+
eventsSyncToken
|
|
196
266
|
};
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
267
|
+
}
|
|
268
|
+
// If no event batches were yielded (zero calendars to sync), emit a final empty
|
|
269
|
+
// batch with hasMore: false and the composite checkpoint so the sync can complete.
|
|
270
|
+
if (allCalendarIDsToSync.length === 0) {
|
|
271
|
+
yield {
|
|
272
|
+
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
273
|
+
entities: [],
|
|
274
|
+
hasMore: false,
|
|
275
|
+
checkpoint: {
|
|
276
|
+
calendarListSyncToken: newCalendarListSyncToken,
|
|
277
|
+
calendars: updatedCalendarTokens
|
|
278
|
+
}
|
|
201
279
|
};
|
|
202
|
-
|
|
203
|
-
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
supportedStrategies: [
|
|
284
|
+
'on-demand'
|
|
285
|
+
],
|
|
286
|
+
fetchAll: fetchAllEntities,
|
|
287
|
+
async *fetchChanges (params) {
|
|
288
|
+
const checkpoint = params.checkpoint;
|
|
289
|
+
if (checkpoint == null) {
|
|
290
|
+
yield* fetchAllEntities(params);
|
|
291
|
+
return;
|
|
204
292
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
id,
|
|
214
|
-
isNew: false
|
|
215
|
-
})),
|
|
216
|
-
...newlyDiscoveredCalendars.map((cal)=>({
|
|
217
|
-
id: cal.id,
|
|
218
|
-
isNew: true
|
|
219
|
-
}))
|
|
220
|
-
];
|
|
221
|
-
const updatedCalendarTokens = {};
|
|
222
|
-
for(let i = 0; i < allCalendarIDsToSync.length; i++){
|
|
223
|
-
const { id: calendarID, isNew } = allCalendarIDsToSync[i];
|
|
224
|
-
const isLastCalendar = i === allCalendarIDsToSync.length - 1;
|
|
225
|
-
// Use stored syncToken for known calendars; use timeMin for new ones
|
|
226
|
-
const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
|
|
227
|
-
let eventsSyncToken;
|
|
228
|
-
for await (const page of fetchEventsForCalendar({
|
|
229
|
-
calendarID,
|
|
230
|
-
credential: config.credential,
|
|
231
|
-
fetchFn,
|
|
232
|
-
syncToken: storedToken,
|
|
233
|
-
timeMin: isNew ? timeMin : undefined,
|
|
234
|
-
signal: params.signal
|
|
235
|
-
})){
|
|
236
|
-
if (page.nextSyncToken != null) {
|
|
237
|
-
eventsSyncToken = page.nextSyncToken;
|
|
238
|
-
}
|
|
239
|
-
const isLastPage = page.nextSyncToken != null;
|
|
240
|
-
const newCheckpoint = isLastCalendar && isLastPage ? {
|
|
241
|
-
calendarListSyncToken: newCalendarListSyncToken,
|
|
242
|
-
calendars: {
|
|
243
|
-
...updatedCalendarTokens,
|
|
244
|
-
[calendarID]: {
|
|
245
|
-
eventsSyncToken: page.nextSyncToken
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
} : undefined;
|
|
249
|
-
// Split items: cancelled → deleted, others → entities
|
|
250
|
-
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
251
|
-
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
252
|
-
yield {
|
|
253
|
-
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
254
|
-
entities: activeItems.map((event)=>mapEventToEntity(event, calendarID)),
|
|
255
|
-
deleted: cancelledItems.map((e)=>({
|
|
256
|
-
sourceService: 'google-calendar',
|
|
257
|
-
sourceID: e.id
|
|
258
|
-
})),
|
|
259
|
-
hasMore: !(isLastCalendar && isLastPage),
|
|
260
|
-
checkpoint: newCheckpoint
|
|
261
|
-
};
|
|
293
|
+
try {
|
|
294
|
+
yield* fetchIncrementalEntities(params, checkpoint);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// 410 Gone = the stored syncToken has expired; Google requires a full
|
|
297
|
+
// resync. Discard the checkpoint by delegating to fetchAll.
|
|
298
|
+
if (err instanceof GoogleCalendarAPIError && err.status === 410) {
|
|
299
|
+
yield* fetchAllEntities(params);
|
|
300
|
+
return;
|
|
262
301
|
}
|
|
263
|
-
|
|
264
|
-
eventsSyncToken
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
// If no event batches were yielded (zero calendars to sync), emit a final empty
|
|
268
|
-
// batch with hasMore: false and the composite checkpoint so the sync can complete.
|
|
269
|
-
if (allCalendarIDsToSync.length === 0) {
|
|
270
|
-
yield {
|
|
271
|
-
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
272
|
-
entities: [],
|
|
273
|
-
hasMore: false,
|
|
274
|
-
checkpoint: {
|
|
275
|
-
calendarListSyncToken: newCalendarListSyncToken,
|
|
276
|
-
calendars: updatedCalendarTokens
|
|
277
|
-
}
|
|
278
|
-
};
|
|
302
|
+
throw err;
|
|
279
303
|
}
|
|
280
304
|
}
|
|
281
305
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-calendar",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,11 +15,14 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/connector": "^0.12.
|
|
19
|
-
"@kubun/
|
|
20
|
-
"@kubun/
|
|
21
|
-
"@kubun/
|
|
22
|
-
"@kubun/
|
|
18
|
+
"@kubun/connector": "^0.12.1",
|
|
19
|
+
"@kubun/models": "^0.12.1",
|
|
20
|
+
"@kubun/protocol": "^0.13.0",
|
|
21
|
+
"@kubun/scalars": "^0.12.0",
|
|
22
|
+
"@kubun/provider-google": "^0.12.0"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
23
26
|
},
|
|
24
27
|
"scripts": {
|
|
25
28
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|