@kubun/connector-google-calendar 0.9.0 → 0.11.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.
- package/lib/actions.js +30 -1
- package/lib/api.js +142 -1
- package/lib/connector.js +105 -1
- package/lib/index.js +5 -1
- package/lib/model.js +185 -1
- package/lib/provider.js +282 -1
- package/package.json +7 -7
package/lib/actions.js
CHANGED
|
@@ -1 +1,30 @@
|
|
|
1
|
-
import{createEvent
|
|
1
|
+
import { createEvent, updateEvent } from './api.js';
|
|
2
|
+
export function createGoogleCalendarActionHandler(config) {
|
|
3
|
+
return {
|
|
4
|
+
async create (_model, params) {
|
|
5
|
+
const calendarID = params.calendarID;
|
|
6
|
+
if (calendarID == null) {
|
|
7
|
+
throw new Error('calendarID is required for creating calendar events');
|
|
8
|
+
}
|
|
9
|
+
return createEvent({
|
|
10
|
+
accessToken: config.credential.accessToken,
|
|
11
|
+
calendarID,
|
|
12
|
+
params,
|
|
13
|
+
fetch: config.fetch
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
async update (_model, sourceID, params) {
|
|
17
|
+
const calendarID = params.calendarID;
|
|
18
|
+
if (calendarID == null) {
|
|
19
|
+
throw new Error('calendarID is required for updating calendar events');
|
|
20
|
+
}
|
|
21
|
+
return updateEvent({
|
|
22
|
+
accessToken: config.credential.accessToken,
|
|
23
|
+
calendarID,
|
|
24
|
+
eventID: sourceID,
|
|
25
|
+
params,
|
|
26
|
+
fetch: config.fetch
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1 +1,142 @@
|
|
|
1
|
-
|
|
1
|
+
const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
2
|
+
async function parseGoogleError(response) {
|
|
3
|
+
try {
|
|
4
|
+
const body = await response.json();
|
|
5
|
+
return body.error?.message ?? `${response.status} ${response.statusText}`;
|
|
6
|
+
} catch {
|
|
7
|
+
return `${response.status} ${response.statusText}`;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function buildCalendarListURL(params) {
|
|
11
|
+
const url = new URL(`${CALENDAR_BASE_URL}/users/me/calendarList`);
|
|
12
|
+
url.searchParams.set('showHidden', 'false');
|
|
13
|
+
if (params.syncToken != null) {
|
|
14
|
+
url.searchParams.set('syncToken', params.syncToken);
|
|
15
|
+
}
|
|
16
|
+
if (params.pageToken != null) {
|
|
17
|
+
url.searchParams.set('pageToken', params.pageToken);
|
|
18
|
+
}
|
|
19
|
+
return url.toString();
|
|
20
|
+
}
|
|
21
|
+
export function buildEventsListURL(params) {
|
|
22
|
+
const url = new URL(`${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(params.calendarID)}/events`);
|
|
23
|
+
url.searchParams.set('singleEvents', 'true');
|
|
24
|
+
if (params.syncToken != null) {
|
|
25
|
+
url.searchParams.set('syncToken', params.syncToken);
|
|
26
|
+
} else if (params.timeMin != null) {
|
|
27
|
+
url.searchParams.set('timeMin', params.timeMin);
|
|
28
|
+
}
|
|
29
|
+
if (params.pageToken != null) {
|
|
30
|
+
url.searchParams.set('pageToken', params.pageToken);
|
|
31
|
+
}
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
export function mapCalendarToEntity(calendar) {
|
|
35
|
+
return {
|
|
36
|
+
sourceService: 'google-calendar',
|
|
37
|
+
sourceID: calendar.id,
|
|
38
|
+
sourceURL: 'https://calendar.google.com',
|
|
39
|
+
summary: calendar.summary,
|
|
40
|
+
description: calendar.description,
|
|
41
|
+
timeZone: calendar.timeZone,
|
|
42
|
+
primary: calendar.primary ?? false,
|
|
43
|
+
backgroundColor: calendar.backgroundColor
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function mapEventToEntity(event, calendarID) {
|
|
47
|
+
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;
|
|
50
|
+
return {
|
|
51
|
+
sourceService: 'google-calendar',
|
|
52
|
+
sourceID: event.id,
|
|
53
|
+
sourceURL: event.htmlLink,
|
|
54
|
+
title: event.summary ?? '(No title)',
|
|
55
|
+
description: event.description,
|
|
56
|
+
location: event.location,
|
|
57
|
+
allDay,
|
|
58
|
+
startAt,
|
|
59
|
+
endAt,
|
|
60
|
+
status: event.status.toUpperCase(),
|
|
61
|
+
htmlLink: event.htmlLink,
|
|
62
|
+
recurringEventID: event.recurringEventID,
|
|
63
|
+
timeZone: event.start.timeZone,
|
|
64
|
+
calendarID,
|
|
65
|
+
calendar: {
|
|
66
|
+
sourceService: 'google-calendar',
|
|
67
|
+
sourceID: calendarID
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function buildEventFromParams(params) {
|
|
72
|
+
const event = {};
|
|
73
|
+
const allDay = params.allDay === true;
|
|
74
|
+
if (params.title != null) {
|
|
75
|
+
event.summary = params.title;
|
|
76
|
+
}
|
|
77
|
+
if (params.description != null) {
|
|
78
|
+
event.description = params.description;
|
|
79
|
+
}
|
|
80
|
+
if (params.location != null) {
|
|
81
|
+
event.location = params.location;
|
|
82
|
+
}
|
|
83
|
+
if (params.startAt != null) {
|
|
84
|
+
event.start = allDay ? {
|
|
85
|
+
date: params.startAt
|
|
86
|
+
} : {
|
|
87
|
+
dateTime: params.startAt,
|
|
88
|
+
...params.timeZone != null ? {
|
|
89
|
+
timeZone: params.timeZone
|
|
90
|
+
} : {}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (params.endAt != null) {
|
|
94
|
+
event.end = allDay ? {
|
|
95
|
+
date: params.endAt
|
|
96
|
+
} : {
|
|
97
|
+
dateTime: params.endAt,
|
|
98
|
+
...params.timeZone != null ? {
|
|
99
|
+
timeZone: params.timeZone
|
|
100
|
+
} : {}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return event;
|
|
104
|
+
}
|
|
105
|
+
export async function createEvent(config) {
|
|
106
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
107
|
+
const event = buildEventFromParams(config.params);
|
|
108
|
+
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events`;
|
|
109
|
+
const response = await fetchFn(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
113
|
+
'Content-Type': 'application/json'
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(event)
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
const errorMessage = await parseGoogleError(response);
|
|
119
|
+
throw new Error(`Google Calendar API error: ${errorMessage}`);
|
|
120
|
+
}
|
|
121
|
+
const data = await response.json();
|
|
122
|
+
return mapEventToEntity(data, config.calendarID);
|
|
123
|
+
}
|
|
124
|
+
export async function updateEvent(config) {
|
|
125
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
126
|
+
const event = buildEventFromParams(config.params);
|
|
127
|
+
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
|
|
128
|
+
const response = await fetchFn(url, {
|
|
129
|
+
method: 'PATCH',
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${config.accessToken}`,
|
|
132
|
+
'Content-Type': 'application/json'
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify(event)
|
|
135
|
+
});
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
const errorMessage = await parseGoogleError(response);
|
|
138
|
+
throw new Error(`Google Calendar API error: ${errorMessage}`);
|
|
139
|
+
}
|
|
140
|
+
const data = await response.json();
|
|
141
|
+
return mapEventToEntity(data, config.calendarID);
|
|
142
|
+
}
|
package/lib/connector.js
CHANGED
|
@@ -1 +1,105 @@
|
|
|
1
|
-
import{createGoogleCalendarActionHandler
|
|
1
|
+
import { createGoogleCalendarActionHandler } from './actions.js';
|
|
2
|
+
import { googleCalendarClusters } from './model.js';
|
|
3
|
+
import { createGoogleCalendarProvider } from './provider.js';
|
|
4
|
+
export function createGoogleCalendarConnector(options) {
|
|
5
|
+
return {
|
|
6
|
+
name: 'google-calendar',
|
|
7
|
+
version: '1.0.0',
|
|
8
|
+
clusters: googleCalendarClusters,
|
|
9
|
+
syncStrategies: [
|
|
10
|
+
'on-demand'
|
|
11
|
+
],
|
|
12
|
+
actions: [
|
|
13
|
+
{
|
|
14
|
+
name: 'create',
|
|
15
|
+
description: 'Create a Google Calendar event',
|
|
16
|
+
input: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
calendarID: {
|
|
20
|
+
type: 'string'
|
|
21
|
+
},
|
|
22
|
+
title: {
|
|
23
|
+
type: 'string'
|
|
24
|
+
},
|
|
25
|
+
startAt: {
|
|
26
|
+
type: 'string'
|
|
27
|
+
},
|
|
28
|
+
endAt: {
|
|
29
|
+
type: 'string'
|
|
30
|
+
},
|
|
31
|
+
allDay: {
|
|
32
|
+
type: 'boolean'
|
|
33
|
+
},
|
|
34
|
+
description: {
|
|
35
|
+
type: 'string'
|
|
36
|
+
},
|
|
37
|
+
location: {
|
|
38
|
+
type: 'string'
|
|
39
|
+
},
|
|
40
|
+
timeZone: {
|
|
41
|
+
type: 'string'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
requiresCredential: true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'update',
|
|
49
|
+
description: 'Update a Google Calendar event',
|
|
50
|
+
input: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
calendarID: {
|
|
54
|
+
type: 'string'
|
|
55
|
+
},
|
|
56
|
+
title: {
|
|
57
|
+
type: 'string'
|
|
58
|
+
},
|
|
59
|
+
startAt: {
|
|
60
|
+
type: 'string'
|
|
61
|
+
},
|
|
62
|
+
endAt: {
|
|
63
|
+
type: 'string'
|
|
64
|
+
},
|
|
65
|
+
allDay: {
|
|
66
|
+
type: 'boolean'
|
|
67
|
+
},
|
|
68
|
+
description: {
|
|
69
|
+
type: 'string'
|
|
70
|
+
},
|
|
71
|
+
location: {
|
|
72
|
+
type: 'string'
|
|
73
|
+
},
|
|
74
|
+
timeZone: {
|
|
75
|
+
type: 'string'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
requiresCredential: true
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
actionHandler: (config)=>{
|
|
83
|
+
return createGoogleCalendarActionHandler({
|
|
84
|
+
credential: config.credential,
|
|
85
|
+
fetch: options?.fetch
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
auth: {
|
|
89
|
+
provider: 'google',
|
|
90
|
+
scopes: [
|
|
91
|
+
'https://www.googleapis.com/auth/calendar.readonly'
|
|
92
|
+
],
|
|
93
|
+
writeScopes: [
|
|
94
|
+
'https://www.googleapis.com/auth/calendar'
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
serverProvider: (config)=>{
|
|
98
|
+
return createGoogleCalendarProvider({
|
|
99
|
+
credential: config.credential,
|
|
100
|
+
boundary: config.boundary,
|
|
101
|
+
fetch: options?.fetch
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export{createGoogleCalendarActionHandler}from
|
|
1
|
+
export { createGoogleCalendarActionHandler } from './actions.js';
|
|
2
|
+
export { buildCalendarListURL, buildEventFromParams, buildEventsListURL, createEvent, mapCalendarToEntity, mapEventToEntity, updateEvent } from './api.js';
|
|
3
|
+
export { createGoogleCalendarConnector } from './connector.js';
|
|
4
|
+
export { googleCalendarClusters, googleCalendarDefinition, googleCalendarEventDefinition } from './model.js';
|
|
5
|
+
export { createGoogleCalendarProvider } from './provider.js';
|
package/lib/model.js
CHANGED
|
@@ -1 +1,185 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { connectorClusters, connectorModelIDs } from '@kubun/connector';
|
|
2
|
+
import { modelIDs, modelsClusters } from '@kubun/models';
|
|
3
|
+
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
+
import { dateTimeSchema, docIDSchema, timeZoneSchema, urlSchema } from '@kubun/scalars';
|
|
5
|
+
export const googleCalendarDefinition = {
|
|
6
|
+
name: 'GoogleCalendar',
|
|
7
|
+
behavior: 'default',
|
|
8
|
+
interfaces: [
|
|
9
|
+
connectorModelIDs.ExternalEntity
|
|
10
|
+
],
|
|
11
|
+
schema: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
summary: {
|
|
15
|
+
type: 'string'
|
|
16
|
+
},
|
|
17
|
+
description: {
|
|
18
|
+
type: 'string'
|
|
19
|
+
},
|
|
20
|
+
timeZone: {
|
|
21
|
+
$ref: '#/definitions/TimeZone'
|
|
22
|
+
},
|
|
23
|
+
primary: {
|
|
24
|
+
type: 'boolean'
|
|
25
|
+
},
|
|
26
|
+
backgroundColor: {
|
|
27
|
+
type: 'string'
|
|
28
|
+
},
|
|
29
|
+
sourceService: {
|
|
30
|
+
$ref: '#/definitions/SourceService'
|
|
31
|
+
},
|
|
32
|
+
sourceID: {
|
|
33
|
+
$ref: '#/definitions/SourceID'
|
|
34
|
+
},
|
|
35
|
+
sourceURL: {
|
|
36
|
+
$ref: '#/definitions/SourceURL'
|
|
37
|
+
},
|
|
38
|
+
lastSyncedAt: {
|
|
39
|
+
$ref: '#/definitions/DateTime'
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
required: [
|
|
43
|
+
'summary',
|
|
44
|
+
'sourceService',
|
|
45
|
+
'sourceID',
|
|
46
|
+
'lastSyncedAt'
|
|
47
|
+
],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
definitions: {
|
|
50
|
+
SourceService: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
title: 'SourceService',
|
|
53
|
+
minLength: 1,
|
|
54
|
+
maxLength: 50
|
|
55
|
+
},
|
|
56
|
+
SourceID: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
title: 'SourceID',
|
|
59
|
+
minLength: 1,
|
|
60
|
+
maxLength: 500
|
|
61
|
+
},
|
|
62
|
+
SourceURL: urlSchema,
|
|
63
|
+
DateTime: dateTimeSchema,
|
|
64
|
+
TimeZone: timeZoneSchema
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
fieldsMeta: {}
|
|
68
|
+
};
|
|
69
|
+
export const googleCalendarEventDefinition = {
|
|
70
|
+
name: 'GoogleCalendarEvent',
|
|
71
|
+
behavior: 'default',
|
|
72
|
+
interfaces: [
|
|
73
|
+
connectorModelIDs.ExternalEntity,
|
|
74
|
+
modelIDs.EventKind
|
|
75
|
+
],
|
|
76
|
+
schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
title: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
minLength: 1,
|
|
82
|
+
maxLength: 200
|
|
83
|
+
},
|
|
84
|
+
startAt: {
|
|
85
|
+
$ref: '#/definitions/DateTime'
|
|
86
|
+
},
|
|
87
|
+
endAt: {
|
|
88
|
+
$ref: '#/definitions/DateTime'
|
|
89
|
+
},
|
|
90
|
+
allDay: {
|
|
91
|
+
type: 'boolean'
|
|
92
|
+
},
|
|
93
|
+
timeZone: {
|
|
94
|
+
$ref: '#/definitions/TimeZone'
|
|
95
|
+
},
|
|
96
|
+
description: {
|
|
97
|
+
type: 'string'
|
|
98
|
+
},
|
|
99
|
+
location: {
|
|
100
|
+
type: 'string'
|
|
101
|
+
},
|
|
102
|
+
status: {
|
|
103
|
+
$ref: '#/definitions/EventStatus'
|
|
104
|
+
},
|
|
105
|
+
htmlLink: {
|
|
106
|
+
$ref: '#/definitions/HtmlLink'
|
|
107
|
+
},
|
|
108
|
+
recurringEventID: {
|
|
109
|
+
type: 'string'
|
|
110
|
+
},
|
|
111
|
+
calendarID: {
|
|
112
|
+
type: 'string'
|
|
113
|
+
},
|
|
114
|
+
calendar: {
|
|
115
|
+
$ref: '#/definitions/CalendarDocID'
|
|
116
|
+
},
|
|
117
|
+
sourceService: {
|
|
118
|
+
$ref: '#/definitions/SourceService'
|
|
119
|
+
},
|
|
120
|
+
sourceID: {
|
|
121
|
+
$ref: '#/definitions/SourceID'
|
|
122
|
+
},
|
|
123
|
+
sourceURL: {
|
|
124
|
+
$ref: '#/definitions/SourceURL'
|
|
125
|
+
},
|
|
126
|
+
lastSyncedAt: {
|
|
127
|
+
$ref: '#/definitions/DateTime'
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
required: [
|
|
131
|
+
'title',
|
|
132
|
+
'startAt',
|
|
133
|
+
'allDay',
|
|
134
|
+
'calendarID',
|
|
135
|
+
'calendar',
|
|
136
|
+
'sourceService',
|
|
137
|
+
'sourceID',
|
|
138
|
+
'lastSyncedAt'
|
|
139
|
+
],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
definitions: {
|
|
142
|
+
SourceService: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
title: 'SourceService',
|
|
145
|
+
minLength: 1,
|
|
146
|
+
maxLength: 50
|
|
147
|
+
},
|
|
148
|
+
SourceID: {
|
|
149
|
+
type: 'string',
|
|
150
|
+
title: 'SourceID',
|
|
151
|
+
minLength: 1,
|
|
152
|
+
maxLength: 500
|
|
153
|
+
},
|
|
154
|
+
SourceURL: urlSchema,
|
|
155
|
+
DateTime: dateTimeSchema,
|
|
156
|
+
TimeZone: timeZoneSchema,
|
|
157
|
+
HtmlLink: urlSchema,
|
|
158
|
+
CalendarDocID: docIDSchema,
|
|
159
|
+
EventStatus: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
title: 'EventStatus',
|
|
162
|
+
enum: [
|
|
163
|
+
'CONFIRMED',
|
|
164
|
+
'TENTATIVE',
|
|
165
|
+
'CANCELLED'
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
fieldsMeta: {
|
|
171
|
+
calendar: {
|
|
172
|
+
type: 'document',
|
|
173
|
+
model: '#0'
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const builder = new GraphBuilder({
|
|
178
|
+
externalClusters: {
|
|
179
|
+
...connectorClusters,
|
|
180
|
+
...modelsClusters
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
builder.add(googleCalendarDefinition); // #0
|
|
184
|
+
builder.add(googleCalendarEventDefinition); // #1
|
|
185
|
+
export const googleCalendarClusters = builder.build();
|
package/lib/provider.js
CHANGED
|
@@ -1 +1,282 @@
|
|
|
1
|
-
import{resolveModelID
|
|
1
|
+
import { resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { buildCalendarListURL, buildEventsListURL, mapCalendarToEntity, mapEventToEntity } from './api.js';
|
|
3
|
+
import { googleCalendarClusters } from './model.js';
|
|
4
|
+
const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
|
|
5
|
+
const CALENDAR_EVENT_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendarEvent');
|
|
6
|
+
async function fetchJSON(url, credential, fetchFn, signal) {
|
|
7
|
+
const response = await fetchFn(url, {
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${credential.accessToken}`
|
|
10
|
+
},
|
|
11
|
+
signal
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error(`Google Calendar API error: ${response.status} ${response.statusText}`);
|
|
15
|
+
}
|
|
16
|
+
return await response.json();
|
|
17
|
+
}
|
|
18
|
+
function computeTimeMin(boundary) {
|
|
19
|
+
if (boundary.maxAge == null) return undefined;
|
|
20
|
+
// Parse PnD format (e.g. "P30D" = 30 days)
|
|
21
|
+
const match = /^P(\d+)D$/i.exec(boundary.maxAge);
|
|
22
|
+
if (match == null) return undefined;
|
|
23
|
+
const days = Number.parseInt(match[1], 10);
|
|
24
|
+
const date = new Date();
|
|
25
|
+
date.setDate(date.getDate() - days);
|
|
26
|
+
return date.toISOString();
|
|
27
|
+
}
|
|
28
|
+
async function* fetchAllCalendars(params) {
|
|
29
|
+
let pageToken;
|
|
30
|
+
let isFirstPage = true;
|
|
31
|
+
do {
|
|
32
|
+
const url = buildCalendarListURL({
|
|
33
|
+
syncToken: isFirstPage ? params.syncToken : undefined,
|
|
34
|
+
pageToken
|
|
35
|
+
});
|
|
36
|
+
const data = await fetchJSON(url, params.credential, params.fetchFn, params.signal);
|
|
37
|
+
yield {
|
|
38
|
+
items: data.items ?? [],
|
|
39
|
+
nextSyncToken: data.nextSyncToken,
|
|
40
|
+
isFirstPage
|
|
41
|
+
};
|
|
42
|
+
pageToken = data.nextPageToken;
|
|
43
|
+
isFirstPage = false;
|
|
44
|
+
}while (pageToken != null)
|
|
45
|
+
}
|
|
46
|
+
async function* fetchEventsForCalendar(params) {
|
|
47
|
+
let pageToken;
|
|
48
|
+
let isFirstPage = true;
|
|
49
|
+
do {
|
|
50
|
+
const url = buildEventsListURL({
|
|
51
|
+
calendarID: params.calendarID,
|
|
52
|
+
syncToken: isFirstPage ? params.syncToken : undefined,
|
|
53
|
+
pageToken,
|
|
54
|
+
timeMin: params.timeMin
|
|
55
|
+
});
|
|
56
|
+
const data = await fetchJSON(url, params.credential, params.fetchFn, params.signal);
|
|
57
|
+
yield {
|
|
58
|
+
items: data.items ?? [],
|
|
59
|
+
nextSyncToken: data.nextSyncToken
|
|
60
|
+
};
|
|
61
|
+
pageToken = data.nextPageToken;
|
|
62
|
+
isFirstPage = false;
|
|
63
|
+
}while (pageToken != null)
|
|
64
|
+
}
|
|
65
|
+
export function createGoogleCalendarProvider(config) {
|
|
66
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
67
|
+
const excludeSet = new Set(config.excludeCalendars ?? []);
|
|
68
|
+
async function* fetchAllEntities(params) {
|
|
69
|
+
const timeMin = computeTimeMin(params.boundary);
|
|
70
|
+
// Phase 1: fetch all calendars (collect all pages first, then yield single batch)
|
|
71
|
+
const allCalendars = [];
|
|
72
|
+
let calendarListSyncToken;
|
|
73
|
+
for await (const page of fetchAllCalendars({
|
|
74
|
+
credential: config.credential,
|
|
75
|
+
fetchFn,
|
|
76
|
+
signal: params.signal
|
|
77
|
+
})){
|
|
78
|
+
for (const cal of page.items){
|
|
79
|
+
if (!excludeSet.has(cal.id)) {
|
|
80
|
+
allCalendars.push(cal);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (page.nextSyncToken != null) {
|
|
84
|
+
calendarListSyncToken = page.nextSyncToken;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Yield calendar batch (no checkpoint yet — will be on the last event batch)
|
|
88
|
+
yield {
|
|
89
|
+
modelID: CALENDAR_MODEL_ID,
|
|
90
|
+
entities: allCalendars.map(mapCalendarToEntity),
|
|
91
|
+
hasMore: true
|
|
92
|
+
};
|
|
93
|
+
// Phase 2: fetch events per calendar
|
|
94
|
+
const calendarTokens = {};
|
|
95
|
+
let eventBatchYielded = false;
|
|
96
|
+
for(let i = 0; i < allCalendars.length; i++){
|
|
97
|
+
const calendar = allCalendars[i];
|
|
98
|
+
const isLastCalendar = i === allCalendars.length - 1;
|
|
99
|
+
let eventsSyncToken;
|
|
100
|
+
for await (const page of fetchEventsForCalendar({
|
|
101
|
+
calendarID: calendar.id,
|
|
102
|
+
credential: config.credential,
|
|
103
|
+
fetchFn,
|
|
104
|
+
timeMin,
|
|
105
|
+
signal: params.signal
|
|
106
|
+
})){
|
|
107
|
+
if (page.nextSyncToken != null) {
|
|
108
|
+
eventsSyncToken = page.nextSyncToken;
|
|
109
|
+
}
|
|
110
|
+
const isLastPage = page.nextSyncToken != null // last page has nextSyncToken
|
|
111
|
+
;
|
|
112
|
+
const checkpoint = isLastCalendar && isLastPage ? {
|
|
113
|
+
calendarListSyncToken,
|
|
114
|
+
calendars: {
|
|
115
|
+
...calendarTokens,
|
|
116
|
+
[calendar.id]: {
|
|
117
|
+
eventsSyncToken: page.nextSyncToken
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} : undefined;
|
|
121
|
+
// Split items: cancelled → deleted, others → entities
|
|
122
|
+
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
123
|
+
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
124
|
+
yield {
|
|
125
|
+
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
126
|
+
entities: activeItems.map((event)=>mapEventToEntity(event, calendar.id)),
|
|
127
|
+
deleted: cancelledItems.map((e)=>({
|
|
128
|
+
sourceService: 'google-calendar',
|
|
129
|
+
sourceID: e.id
|
|
130
|
+
})),
|
|
131
|
+
hasMore: !(isLastCalendar && isLastPage),
|
|
132
|
+
checkpoint
|
|
133
|
+
};
|
|
134
|
+
eventBatchYielded = true;
|
|
135
|
+
}
|
|
136
|
+
calendarTokens[calendar.id] = {
|
|
137
|
+
eventsSyncToken
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// If no event batches were yielded (zero calendars), emit a final empty batch
|
|
141
|
+
// with hasMore: false and the checkpoint so the sync can complete.
|
|
142
|
+
if (!eventBatchYielded) {
|
|
143
|
+
yield {
|
|
144
|
+
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
145
|
+
entities: [],
|
|
146
|
+
hasMore: false,
|
|
147
|
+
checkpoint: {
|
|
148
|
+
calendarListSyncToken,
|
|
149
|
+
calendars: calendarTokens
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
supportedStrategies: [
|
|
156
|
+
'on-demand'
|
|
157
|
+
],
|
|
158
|
+
fetchAll: fetchAllEntities,
|
|
159
|
+
async *fetchChanges (params) {
|
|
160
|
+
const checkpoint = params.checkpoint;
|
|
161
|
+
if (checkpoint == null) {
|
|
162
|
+
yield* fetchAllEntities(params);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const timeMin = computeTimeMin(params.boundary);
|
|
166
|
+
// Phase 1: fetch calendar list changes using stored syncToken
|
|
167
|
+
const activeCalendars = [];
|
|
168
|
+
const deletedCalendarIDs = [];
|
|
169
|
+
let newCalendarListSyncToken;
|
|
170
|
+
for await (const page of fetchAllCalendars({
|
|
171
|
+
credential: config.credential,
|
|
172
|
+
fetchFn,
|
|
173
|
+
syncToken: checkpoint.calendarListSyncToken,
|
|
174
|
+
signal: params.signal
|
|
175
|
+
})){
|
|
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
|
+
if (page.nextSyncToken != null) {
|
|
184
|
+
newCalendarListSyncToken = page.nextSyncToken;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Yield calendar batch (active + deletions)
|
|
188
|
+
yield {
|
|
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
|
|
196
|
+
};
|
|
197
|
+
// Phase 2: determine the set of calendars to sync events for
|
|
198
|
+
// Start from the previous calendar set, remove deleted, add newly discovered active ones
|
|
199
|
+
const prevCalendarTokens = {
|
|
200
|
+
...checkpoint.calendars
|
|
201
|
+
};
|
|
202
|
+
for (const deletedID of deletedCalendarIDs){
|
|
203
|
+
delete prevCalendarTokens[deletedID];
|
|
204
|
+
}
|
|
205
|
+
// Collect new calendars that weren't in the previous checkpoint
|
|
206
|
+
const newlyDiscoveredCalendars = activeCalendars.filter((cal)=>!(cal.id in checkpoint.calendars));
|
|
207
|
+
// All calendars to sync: known (from prev checkpoint, minus deleted) + newly discovered
|
|
208
|
+
const knownCalendarIDs = Object.keys(prevCalendarTokens);
|
|
209
|
+
// Combine: known calendar IDs (we don't have resource objects for them, only IDs)
|
|
210
|
+
// plus newly discovered calendar resources
|
|
211
|
+
const allCalendarIDsToSync = [
|
|
212
|
+
...knownCalendarIDs.map((id)=>({
|
|
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
|
+
};
|
|
262
|
+
}
|
|
263
|
+
updatedCalendarTokens[calendarID] = {
|
|
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
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-calendar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"type": "module",
|
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
],
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/
|
|
19
|
-
"@kubun/
|
|
20
|
-
"@kubun/
|
|
21
|
-
"@kubun/
|
|
22
|
-
"@kubun/
|
|
18
|
+
"@kubun/connector": "^0.11.0",
|
|
19
|
+
"@kubun/models": "^0.11.0",
|
|
20
|
+
"@kubun/protocol": "^0.11.0",
|
|
21
|
+
"@kubun/provider-google": "^0.11.0",
|
|
22
|
+
"@kubun/scalars": "^0.11.0"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build:clean": "del lib",
|
|
26
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
26
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
27
27
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
28
28
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
29
29
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|