@kubun/connector-google-calendar 0.17.4 → 0.18.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.d.ts +1 -1
- package/lib/actions.js +15 -6
- package/lib/api.d.ts +43 -1
- package/lib/api.js +166 -16
- package/lib/connector.js +22 -2
- package/lib/intent.d.ts +4 -0
- package/lib/intent.js +23 -0
- package/lib/model.d.ts +8 -53
- package/lib/model.js +37 -74
- package/lib/provider.d.ts +14 -0
- package/lib/provider.js +399 -135
- package/package.json +12 -11
package/lib/actions.d.ts
CHANGED
package/lib/actions.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { carryField, refuseCutLists } from '@kubun/connector';
|
|
1
2
|
import { createEvent, updateEvent } from './api.js';
|
|
2
3
|
import { GoogleCalendarError } from './errors.js';
|
|
3
4
|
import { deriveEventID } from './event-id.js';
|
|
@@ -6,8 +7,9 @@ import { deriveEventID } from './event-id.js';
|
|
|
6
7
|
* of the (possibly stale) local document. The one exception is how a changed
|
|
7
8
|
* start/end is written: it keeps the event's all-day form and time zone from
|
|
8
9
|
* the target document, and a changed time zone or all-day flag rewrites the
|
|
9
|
-
* start and end it applies to.
|
|
10
|
-
|
|
10
|
+
* start and end it applies to. A value carried from the target document that
|
|
11
|
+
* was trimmed at sync is refused (`carryField`), never sent as the prefix.
|
|
12
|
+
*/ function sparsePatchParams(params, existing, context) {
|
|
11
13
|
const patch = {
|
|
12
14
|
...params
|
|
13
15
|
};
|
|
@@ -21,13 +23,19 @@ import { deriveEventID } from './event-id.js';
|
|
|
21
23
|
'endAt'
|
|
22
24
|
]){
|
|
23
25
|
if (patch[field] == null && reshapesTimes && existing?.[field] != null) {
|
|
24
|
-
patch[field] = existing
|
|
26
|
+
patch[field] = carryField(existing, field, context);
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
const writesTimes = patch.startAt != null || patch.endAt != null;
|
|
28
30
|
if (writesTimes) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
for (const field of [
|
|
32
|
+
'allDay',
|
|
33
|
+
'timeZone'
|
|
34
|
+
]){
|
|
35
|
+
if (!(field in patch) && existing?.[field] != null) {
|
|
36
|
+
patch[field] = carryField(existing, field, context);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
31
39
|
}
|
|
32
40
|
return patch;
|
|
33
41
|
}
|
|
@@ -67,6 +75,7 @@ export function createGoogleCalendarActionHandler(config) {
|
|
|
67
75
|
};
|
|
68
76
|
},
|
|
69
77
|
async update (_model, sourceID, params, existing, context) {
|
|
78
|
+
refuseCutLists(params, context);
|
|
70
79
|
// sourceID is the composite calendarID/eventID. The target document
|
|
71
80
|
// (`existing`) names the event; without it, recover both ids from the
|
|
72
81
|
// composite — an eventID never contains '/', a calendarID may, so split
|
|
@@ -97,7 +106,7 @@ export function createGoogleCalendarActionHandler(config) {
|
|
|
97
106
|
accessToken: config.credential.accessToken,
|
|
98
107
|
calendarID,
|
|
99
108
|
eventID,
|
|
100
|
-
params: sparsePatchParams(params, existing),
|
|
109
|
+
params: sparsePatchParams(params, existing, context),
|
|
101
110
|
etag: context?.providerETag,
|
|
102
111
|
fetch: config.fetch
|
|
103
112
|
});
|
package/lib/api.d.ts
CHANGED
|
@@ -23,6 +23,25 @@ export type GoogleEventDateTime = {
|
|
|
23
23
|
date?: string;
|
|
24
24
|
timeZone?: string;
|
|
25
25
|
};
|
|
26
|
+
export type GoogleResponseStatus = 'needsAction' | 'declined' | 'tentative' | 'accepted';
|
|
27
|
+
export type GoogleEventAttendee = {
|
|
28
|
+
email?: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
optional?: boolean;
|
|
31
|
+
resource?: boolean;
|
|
32
|
+
organizer?: boolean;
|
|
33
|
+
responseStatus?: GoogleResponseStatus;
|
|
34
|
+
self?: boolean;
|
|
35
|
+
};
|
|
36
|
+
export type GoogleEventOrganizer = {
|
|
37
|
+
email?: string;
|
|
38
|
+
displayName?: string;
|
|
39
|
+
self?: boolean;
|
|
40
|
+
};
|
|
41
|
+
export type GoogleEventReminder = {
|
|
42
|
+
method: 'popup' | 'email';
|
|
43
|
+
minutes: number;
|
|
44
|
+
};
|
|
26
45
|
export type GoogleEventResource = {
|
|
27
46
|
id: string;
|
|
28
47
|
etag?: string;
|
|
@@ -33,7 +52,22 @@ export type GoogleEventResource = {
|
|
|
33
52
|
end: GoogleEventDateTime;
|
|
34
53
|
status: 'confirmed' | 'tentative' | 'cancelled';
|
|
35
54
|
htmlLink?: string;
|
|
36
|
-
|
|
55
|
+
recurringEventId?: string;
|
|
56
|
+
originalStartTime?: GoogleEventDateTime;
|
|
57
|
+
recurrence?: Array<string>;
|
|
58
|
+
organizer?: GoogleEventOrganizer;
|
|
59
|
+
attendees?: Array<GoogleEventAttendee>;
|
|
60
|
+
reminders?: {
|
|
61
|
+
useDefault?: boolean;
|
|
62
|
+
overrides?: Array<GoogleEventReminder>;
|
|
63
|
+
};
|
|
64
|
+
hangoutLink?: string;
|
|
65
|
+
conferenceData?: {
|
|
66
|
+
entryPoints?: Array<{
|
|
67
|
+
entryPointType?: string;
|
|
68
|
+
uri?: string;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
37
71
|
};
|
|
38
72
|
export type GoogleEventsListResponse = {
|
|
39
73
|
items?: Array<GoogleEventResource>;
|
|
@@ -49,6 +83,8 @@ export type BuildEventsListURLParams = {
|
|
|
49
83
|
syncToken?: string;
|
|
50
84
|
pageToken?: string;
|
|
51
85
|
timeMin?: string;
|
|
86
|
+
timeMax?: string;
|
|
87
|
+
showDeleted?: boolean;
|
|
52
88
|
};
|
|
53
89
|
export declare function buildEventsListURL(params: BuildEventsListURLParams): string;
|
|
54
90
|
export declare function mapCalendarToEntity(calendar: GoogleCalendarResource, accountKey?: string): EntityRecord;
|
|
@@ -66,6 +102,12 @@ export declare function splitEventSourceID(sourceID: string): {
|
|
|
66
102
|
calendarID: string;
|
|
67
103
|
eventID: string;
|
|
68
104
|
};
|
|
105
|
+
export declare function fetchEventResource(config: {
|
|
106
|
+
accessToken: string;
|
|
107
|
+
calendarID: string;
|
|
108
|
+
eventID: string;
|
|
109
|
+
fetch?: typeof globalThis.fetch;
|
|
110
|
+
}): Promise<GoogleEventResource>;
|
|
69
111
|
export declare function getEvent(config: {
|
|
70
112
|
accessToken: string;
|
|
71
113
|
calendarID: string;
|
package/lib/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { connectorHTTPError } from '@kubun/connector';
|
|
2
2
|
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
3
|
-
import { GoogleCalendarEventConflict } from './errors.js';
|
|
3
|
+
import { GoogleCalendarError, GoogleCalendarEventConflict } from './errors.js';
|
|
4
4
|
import { eventHoldsIntent } from './intent.js';
|
|
5
5
|
export { classifyHTTPStatus } from '@kubun/connector';
|
|
6
6
|
const CALENDAR_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
@@ -44,24 +44,46 @@ export function buildEventsListURL(params) {
|
|
|
44
44
|
url.searchParams.set('singleEvents', 'true');
|
|
45
45
|
if (params.syncToken != null) {
|
|
46
46
|
url.searchParams.set('syncToken', params.syncToken);
|
|
47
|
-
} else
|
|
48
|
-
|
|
47
|
+
} else {
|
|
48
|
+
if (params.timeMin != null) {
|
|
49
|
+
url.searchParams.set('timeMin', params.timeMin);
|
|
50
|
+
}
|
|
51
|
+
if (params.timeMax != null) {
|
|
52
|
+
url.searchParams.set('timeMax', params.timeMax);
|
|
53
|
+
}
|
|
54
|
+
if (params.showDeleted === true) {
|
|
55
|
+
url.searchParams.set('showDeleted', 'true');
|
|
56
|
+
}
|
|
49
57
|
}
|
|
50
58
|
if (params.pageToken != null) {
|
|
51
59
|
url.searchParams.set('pageToken', params.pageToken);
|
|
52
60
|
}
|
|
53
61
|
return url.toString();
|
|
54
62
|
}
|
|
63
|
+
const CALENDAR_ACCESS_ROLES = {
|
|
64
|
+
owner: 'OWNER',
|
|
65
|
+
writer: 'WRITER',
|
|
66
|
+
reader: 'READER',
|
|
67
|
+
freeBusyReader: 'FREE_BUSY'
|
|
68
|
+
};
|
|
69
|
+
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
|
70
|
+
// An empty provider string is no value: the model's text fields hold at least
|
|
71
|
+
// one character.
|
|
72
|
+
function text(value) {
|
|
73
|
+
return value == null || value === '' ? undefined : value;
|
|
74
|
+
}
|
|
55
75
|
export function mapCalendarToEntity(calendar, accountKey) {
|
|
76
|
+
const color = calendar.backgroundColor;
|
|
56
77
|
return {
|
|
57
78
|
sourceService: 'google-calendar',
|
|
58
79
|
sourceID: calendar.id,
|
|
59
80
|
sourceURL: 'https://calendar.google.com',
|
|
60
|
-
|
|
61
|
-
description: calendar.description,
|
|
81
|
+
name: text(calendar.summary) ?? '(No title)',
|
|
82
|
+
description: text(calendar.description),
|
|
62
83
|
timeZone: calendar.timeZone,
|
|
63
84
|
primary: calendar.primary ?? false,
|
|
64
|
-
|
|
85
|
+
color: color != null && HEX_COLOR.test(color) ? color : undefined,
|
|
86
|
+
accessRole: calendar.accessRole == null ? undefined : CALENDAR_ACCESS_ROLES[calendar.accessRole],
|
|
65
87
|
...accountKey != null ? {
|
|
66
88
|
accountKey
|
|
67
89
|
} : {}
|
|
@@ -73,10 +95,73 @@ export function mapCalendarToEntity(calendar, accountKey) {
|
|
|
73
95
|
function allDayDateToDateTime(date) {
|
|
74
96
|
return date == null ? undefined : `${date}T00:00:00.000Z`;
|
|
75
97
|
}
|
|
98
|
+
function toDateTime(value) {
|
|
99
|
+
if (value == null) return undefined;
|
|
100
|
+
return value.date != null ? allDayDateToDateTime(value.date) : value.dateTime;
|
|
101
|
+
}
|
|
102
|
+
const RESPONSE_STATUS_TO_PARTY = {
|
|
103
|
+
needsAction: 'NEEDS_ACTION',
|
|
104
|
+
accepted: 'ACCEPTED',
|
|
105
|
+
declined: 'DECLINED',
|
|
106
|
+
tentative: 'TENTATIVE'
|
|
107
|
+
};
|
|
108
|
+
const PARTY_STATUS_TO_RESPONSE = {
|
|
109
|
+
NEEDS_ACTION: 'needsAction',
|
|
110
|
+
ACCEPTED: 'accepted',
|
|
111
|
+
DECLINED: 'declined',
|
|
112
|
+
TENTATIVE: 'tentative'
|
|
113
|
+
};
|
|
114
|
+
const REMINDER_TO_METHOD = {
|
|
115
|
+
popup: 'DISPLAY',
|
|
116
|
+
email: 'EMAIL'
|
|
117
|
+
};
|
|
118
|
+
const METHOD_TO_REMINDER = {
|
|
119
|
+
DISPLAY: 'popup',
|
|
120
|
+
EMAIL: 'email'
|
|
121
|
+
};
|
|
122
|
+
function mapAttendee(attendee) {
|
|
123
|
+
const party = {};
|
|
124
|
+
if (attendee.email != null) party.email = attendee.email;
|
|
125
|
+
const name = text(attendee.displayName);
|
|
126
|
+
if (name != null) party.name = name;
|
|
127
|
+
party.role = attendee.organizer === true ? 'CHAIR' : attendee.resource === true ? 'RESOURCE' : attendee.optional === true ? 'OPTIONAL' : 'REQUIRED';
|
|
128
|
+
const status = attendee.responseStatus == null ? undefined : RESPONSE_STATUS_TO_PARTY[attendee.responseStatus];
|
|
129
|
+
if (status != null) party.status = status;
|
|
130
|
+
if (attendee.self != null) party.self = attendee.self;
|
|
131
|
+
return party;
|
|
132
|
+
}
|
|
133
|
+
function mapOrganizer(organizer) {
|
|
134
|
+
if (organizer == null) return undefined;
|
|
135
|
+
const party = {};
|
|
136
|
+
if (organizer.email != null) party.email = organizer.email;
|
|
137
|
+
const name = text(organizer.displayName);
|
|
138
|
+
if (name != null) party.name = name;
|
|
139
|
+
party.role = 'CHAIR';
|
|
140
|
+
if (organizer.self != null) party.self = organizer.self;
|
|
141
|
+
return party;
|
|
142
|
+
}
|
|
143
|
+
// Calendar defaults (`useDefault`) are the calendar's, not the event's, so
|
|
144
|
+
// they leave the event's reminders unset. An event that turns them off with no
|
|
145
|
+
// overrides has no reminders: an empty list, distinct from the defaults.
|
|
146
|
+
function mapReminders(event) {
|
|
147
|
+
const reminders = event.reminders;
|
|
148
|
+
if (reminders == null || reminders.useDefault === true) return undefined;
|
|
149
|
+
return (reminders.overrides ?? []).map((reminder)=>({
|
|
150
|
+
method: REMINDER_TO_METHOD[reminder.method],
|
|
151
|
+
minutesBefore: reminder.minutes
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
// The Meet link Google keeps on the event, or else the first video entry
|
|
155
|
+
// point of any other conference (Zoom, Teams...).
|
|
156
|
+
function mapConferenceURL(event) {
|
|
157
|
+
if (event.hangoutLink != null) return event.hangoutLink;
|
|
158
|
+
return event.conferenceData?.entryPoints?.find((entry)=>entry.entryPointType === 'video')?.uri;
|
|
159
|
+
}
|
|
160
|
+
function nonEmpty(items) {
|
|
161
|
+
return items == null || items.length === 0 ? undefined : items;
|
|
162
|
+
}
|
|
76
163
|
export function mapEventToEntity(event, calendarID) {
|
|
77
164
|
const allDay = event.start.date != null;
|
|
78
|
-
const startAt = allDay ? allDayDateToDateTime(event.start.date) : event.start.dateTime;
|
|
79
|
-
const endAt = allDay ? allDayDateToDateTime(event.end.date) : event.end.dateTime;
|
|
80
165
|
return {
|
|
81
166
|
sourceService: 'google-calendar',
|
|
82
167
|
// Google event ids are unique per calendar, not per account; namespace the
|
|
@@ -85,16 +170,22 @@ export function mapEventToEntity(event, calendarID) {
|
|
|
85
170
|
sourceID: `${calendarID}/${event.id}`,
|
|
86
171
|
eventID: event.id,
|
|
87
172
|
sourceURL: event.htmlLink,
|
|
88
|
-
title: event.summary ?? '(No title)',
|
|
89
|
-
description: event.description,
|
|
90
|
-
location: event.location,
|
|
173
|
+
title: text(event.summary) ?? '(No title)',
|
|
174
|
+
description: text(event.description),
|
|
175
|
+
location: text(event.location),
|
|
91
176
|
allDay,
|
|
92
|
-
startAt,
|
|
93
|
-
endAt,
|
|
177
|
+
startAt: toDateTime(event.start),
|
|
178
|
+
endAt: toDateTime(event.end),
|
|
94
179
|
status: event.status.toUpperCase(),
|
|
95
180
|
htmlLink: event.htmlLink,
|
|
96
|
-
recurringEventID: event.recurringEventID,
|
|
97
181
|
timeZone: event.start.timeZone,
|
|
182
|
+
organizer: mapOrganizer(event.organizer),
|
|
183
|
+
participants: nonEmpty(event.attendees)?.map(mapAttendee),
|
|
184
|
+
recurrence: nonEmpty(event.recurrence),
|
|
185
|
+
seriesID: event.recurringEventId,
|
|
186
|
+
originalStartAt: toDateTime(event.originalStartTime),
|
|
187
|
+
reminders: mapReminders(event),
|
|
188
|
+
conferenceURL: mapConferenceURL(event),
|
|
98
189
|
calendarID,
|
|
99
190
|
calendar: {
|
|
100
191
|
sourceService: 'google-calendar',
|
|
@@ -103,6 +194,52 @@ export function mapEventToEntity(event, calendarID) {
|
|
|
103
194
|
etag: event.etag
|
|
104
195
|
};
|
|
105
196
|
}
|
|
197
|
+
function participantToAttendee(participant) {
|
|
198
|
+
const party = participant ?? {};
|
|
199
|
+
if (typeof party.email !== 'string' || party.email === '') {
|
|
200
|
+
throw new GoogleCalendarError('a Google Calendar participant needs an email', {
|
|
201
|
+
kind: 'rejected'
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// A chair is the event's organizer attending it. Google's attendee
|
|
205
|
+
// `organizer` flag is read-only and the organizer is fixed by the event
|
|
206
|
+
// itself, so a chair is sent as a plain required attendee: keeping the entry
|
|
207
|
+
// keeps the organizer's attendance. Google has no attendee role for a
|
|
208
|
+
// non-participant, so that one is refused, never sent as a plain attendee.
|
|
209
|
+
if (party.role === 'NON_PARTICIPANT') {
|
|
210
|
+
throw new GoogleCalendarError(`a Google Calendar participant cannot have the ${party.role} role`, {
|
|
211
|
+
kind: 'rejected'
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const attendee = {
|
|
215
|
+
email: party.email
|
|
216
|
+
};
|
|
217
|
+
if (typeof party.name === 'string') attendee.displayName = party.name;
|
|
218
|
+
if (party.role === 'OPTIONAL') attendee.optional = true;
|
|
219
|
+
if (party.role === 'RESOURCE') attendee.resource = true;
|
|
220
|
+
const responseStatus = typeof party.status === 'string' ? PARTY_STATUS_TO_RESPONSE[party.status] : undefined;
|
|
221
|
+
if (responseStatus != null) attendee.responseStatus = responseStatus;
|
|
222
|
+
return attendee;
|
|
223
|
+
}
|
|
224
|
+
function remindersToGoogle(reminders) {
|
|
225
|
+
return {
|
|
226
|
+
// An explicit list, even an empty one, replaces the calendar defaults.
|
|
227
|
+
useDefault: false,
|
|
228
|
+
overrides: reminders.map((value)=>{
|
|
229
|
+
const reminder = value ?? {};
|
|
230
|
+
const method = METHOD_TO_REMINDER[reminder.method];
|
|
231
|
+
if (method == null || typeof reminder.minutesBefore !== 'number') {
|
|
232
|
+
throw new GoogleCalendarError('a Google Calendar reminder needs a DISPLAY or EMAIL method and minutesBefore', {
|
|
233
|
+
kind: 'rejected'
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
method,
|
|
238
|
+
minutes: reminder.minutesBefore
|
|
239
|
+
};
|
|
240
|
+
})
|
|
241
|
+
};
|
|
242
|
+
}
|
|
106
243
|
export function buildEventFromParams(params, options) {
|
|
107
244
|
const event = {};
|
|
108
245
|
const allDay = params.allDay === true;
|
|
@@ -145,6 +282,17 @@ export function buildEventFromParams(params, options) {
|
|
|
145
282
|
} : {}
|
|
146
283
|
};
|
|
147
284
|
}
|
|
285
|
+
// Each list replaces the event's whole list at Google: a sparse PATCH that
|
|
286
|
+
// carries one sends it complete.
|
|
287
|
+
if (Array.isArray(params.participants)) {
|
|
288
|
+
event.attendees = params.participants.map(participantToAttendee);
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(params.recurrence)) {
|
|
291
|
+
event.recurrence = params.recurrence;
|
|
292
|
+
}
|
|
293
|
+
if (Array.isArray(params.reminders)) {
|
|
294
|
+
event.reminders = remindersToGoogle(params.reminders);
|
|
295
|
+
}
|
|
148
296
|
return event;
|
|
149
297
|
}
|
|
150
298
|
// One calendar as the account's calendar list holds it (calendarList.get) —
|
|
@@ -167,7 +315,7 @@ export function splitEventSourceID(sourceID) {
|
|
|
167
315
|
eventID: sourceID.slice(slash + 1)
|
|
168
316
|
};
|
|
169
317
|
}
|
|
170
|
-
async function fetchEventResource(config) {
|
|
318
|
+
export async function fetchEventResource(config) {
|
|
171
319
|
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}`;
|
|
172
320
|
return calendarAPIClient(config.accessToken, config.fetch).get(url);
|
|
173
321
|
}
|
|
@@ -231,7 +379,9 @@ export async function createEvent(config) {
|
|
|
231
379
|
}
|
|
232
380
|
export async function updateEvent(config) {
|
|
233
381
|
const event = buildEventFromParams(config.params);
|
|
234
|
-
|
|
382
|
+
// Guest notification is not part of a write-back: an edit synced from a
|
|
383
|
+
// device never emails the event's guests.
|
|
384
|
+
const url = `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(config.calendarID)}/events/${encodeURIComponent(config.eventID)}?sendUpdates=none`;
|
|
235
385
|
const data = await calendarAPIClient(config.accessToken, config.fetch).send(url, {
|
|
236
386
|
method: 'PATCH',
|
|
237
387
|
body: event,
|
package/lib/connector.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
import { calendarModelDefinitions } from '@kubun/models-calendar';
|
|
1
2
|
import { createGoogleCalendarActionHandler } from './actions.js';
|
|
2
3
|
import { canonicalizeEventIntent } from './intent.js';
|
|
3
4
|
import { googleCalendarClusters } from './model.js';
|
|
4
5
|
import { predictCreatedEventSourceID } from './predict.js';
|
|
5
6
|
import { createGoogleCalendarProvider } from './provider.js';
|
|
6
7
|
import { resolveCalendarTarget } from './target.js';
|
|
8
|
+
const eventKindProperties = calendarModelDefinitions.find((definition)=>definition.name === 'EventKind')?.schema?.properties ?? {};
|
|
9
|
+
function eventKindProperty(name) {
|
|
10
|
+
const property = eventKindProperties[name];
|
|
11
|
+
if (property == null) throw new Error(`EventKind declares no "${name}" field`);
|
|
12
|
+
return property;
|
|
13
|
+
}
|
|
14
|
+
// The event lists a write can set, in the EventKind shape. Each one replaces
|
|
15
|
+
// the event's whole list at Google.
|
|
16
|
+
const eventListInputProperties = {
|
|
17
|
+
participants: eventKindProperty('participants'),
|
|
18
|
+
recurrence: eventKindProperty('recurrence'),
|
|
19
|
+
reminders: eventKindProperty('reminders')
|
|
20
|
+
};
|
|
7
21
|
export function createGoogleCalendarConnector(options) {
|
|
8
22
|
return {
|
|
9
23
|
name: 'google-calendar',
|
|
@@ -15,6 +29,8 @@ export function createGoogleCalendarConnector(options) {
|
|
|
15
29
|
actions: [
|
|
16
30
|
{
|
|
17
31
|
name: 'create',
|
|
32
|
+
// Events only: no mutation is generated for GoogleCalendar.
|
|
33
|
+
entityModel: 'GoogleCalendarEvent',
|
|
18
34
|
description: 'Create a Google Calendar event',
|
|
19
35
|
input: {
|
|
20
36
|
type: 'object',
|
|
@@ -42,7 +58,8 @@ export function createGoogleCalendarConnector(options) {
|
|
|
42
58
|
},
|
|
43
59
|
timeZone: {
|
|
44
60
|
type: 'string'
|
|
45
|
-
}
|
|
61
|
+
},
|
|
62
|
+
...eventListInputProperties
|
|
46
63
|
}
|
|
47
64
|
},
|
|
48
65
|
requiresCredential: true,
|
|
@@ -54,6 +71,8 @@ export function createGoogleCalendarConnector(options) {
|
|
|
54
71
|
},
|
|
55
72
|
{
|
|
56
73
|
name: 'update',
|
|
74
|
+
// Events only: no mutation is generated for GoogleCalendar.
|
|
75
|
+
entityModel: 'GoogleCalendarEvent',
|
|
57
76
|
description: 'Update a Google Calendar event',
|
|
58
77
|
input: {
|
|
59
78
|
type: 'object',
|
|
@@ -81,7 +100,8 @@ export function createGoogleCalendarConnector(options) {
|
|
|
81
100
|
},
|
|
82
101
|
timeZone: {
|
|
83
102
|
type: 'string'
|
|
84
|
-
}
|
|
103
|
+
},
|
|
104
|
+
...eventListInputProperties
|
|
85
105
|
}
|
|
86
106
|
},
|
|
87
107
|
requiresCredential: true,
|
package/lib/intent.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ import type { EntityRecord, IntentCanonicalizer } from '@kubun/connector';
|
|
|
7
7
|
* value never matches a timed one;
|
|
8
8
|
* - `calendarID` is compared exactly: an operation's intent already carries
|
|
9
9
|
* the calendar the `'primary'` alias was resolved to before the claim;
|
|
10
|
+
* - participants are compared by email and role only, in any order: Google
|
|
11
|
+
* sets each attendee's response status and display name itself;
|
|
12
|
+
* - reminders are compared exactly: an empty list (the event's reminders
|
|
13
|
+
* turned off) is not the calendar defaults (unset);
|
|
10
14
|
* - a cancelled (deleted) event holds no create or update.
|
|
11
15
|
*/
|
|
12
16
|
export declare const canonicalizeEventIntent: IntentCanonicalizer;
|
package/lib/intent.js
CHANGED
|
@@ -25,6 +25,10 @@ function canonicalDateTime(value, allDay) {
|
|
|
25
25
|
* value never matches a timed one;
|
|
26
26
|
* - `calendarID` is compared exactly: an operation's intent already carries
|
|
27
27
|
* the calendar the `'primary'` alias was resolved to before the claim;
|
|
28
|
+
* - participants are compared by email and role only, in any order: Google
|
|
29
|
+
* sets each attendee's response status and display name itself;
|
|
30
|
+
* - reminders are compared exactly: an empty list (the event's reminders
|
|
31
|
+
* turned off) is not the calendar defaults (unset);
|
|
28
32
|
* - a cancelled (deleted) event holds no create or update.
|
|
29
33
|
*/ export const canonicalizeEventIntent = ({ model, intent, record })=>{
|
|
30
34
|
if (model !== googleCalendarEventDefinition.name) return {
|
|
@@ -54,11 +58,30 @@ function canonicalDateTime(value, allDay) {
|
|
|
54
58
|
}
|
|
55
59
|
canonicalRecord[field] = canonicalDateTime(canonicalRecord[field], recordAllDay);
|
|
56
60
|
}
|
|
61
|
+
if ('participants' in canonicalIntent) {
|
|
62
|
+
canonicalIntent.participants = canonicalParticipants(canonicalIntent.participants);
|
|
63
|
+
canonicalRecord.participants = canonicalParticipants(canonicalRecord.participants);
|
|
64
|
+
}
|
|
57
65
|
return {
|
|
58
66
|
intent: canonicalIntent,
|
|
59
67
|
record: canonicalRecord
|
|
60
68
|
};
|
|
61
69
|
};
|
|
70
|
+
// A participant list as Google keeps it: each attendee's email (compared
|
|
71
|
+
// case-insensitively) and role, in no particular order. Google sets the
|
|
72
|
+
// response status, the reader's own entry and display names itself, so they
|
|
73
|
+
// are not part of what a write intends.
|
|
74
|
+
function canonicalParticipants(value) {
|
|
75
|
+
if (value == null) return [];
|
|
76
|
+
if (!Array.isArray(value)) return value;
|
|
77
|
+
return value.map((item)=>{
|
|
78
|
+
const party = item ?? {};
|
|
79
|
+
return {
|
|
80
|
+
email: typeof party.email === 'string' ? party.email.toLowerCase() : party.email,
|
|
81
|
+
role: party.role ?? 'REQUIRED'
|
|
82
|
+
};
|
|
83
|
+
}).sort((a, b)=>String(a.email).localeCompare(String(b.email)));
|
|
84
|
+
}
|
|
62
85
|
function canonicalOf(value) {
|
|
63
86
|
return toB64U(digestJSON(value === undefined ? null : value));
|
|
64
87
|
}
|
package/lib/model.d.ts
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
export declare const googleCalendarDefinition: {
|
|
2
2
|
readonly name: "GoogleCalendar";
|
|
3
3
|
readonly behavior: "default";
|
|
4
|
-
readonly interfaces: [string];
|
|
4
|
+
readonly interfaces: [string, string];
|
|
5
5
|
readonly schema: {
|
|
6
6
|
readonly type: "object";
|
|
7
7
|
readonly properties: {
|
|
8
|
-
readonly summary: {
|
|
9
|
-
readonly type: "string";
|
|
10
|
-
};
|
|
11
|
-
readonly description: {
|
|
12
|
-
readonly type: "string";
|
|
13
|
-
};
|
|
14
|
-
readonly timeZone: {
|
|
15
|
-
readonly $ref: "#/definitions/TimeZone";
|
|
16
|
-
};
|
|
17
8
|
readonly primary: {
|
|
18
9
|
readonly type: "boolean";
|
|
19
10
|
};
|
|
20
|
-
readonly backgroundColor: {
|
|
21
|
-
readonly type: "string";
|
|
22
|
-
};
|
|
23
11
|
readonly accountKey: {
|
|
24
12
|
readonly type: "string";
|
|
25
13
|
readonly minLength: 43;
|
|
@@ -38,10 +26,9 @@ export declare const googleCalendarDefinition: {
|
|
|
38
26
|
readonly $ref: "#/definitions/DateTime";
|
|
39
27
|
};
|
|
40
28
|
};
|
|
41
|
-
readonly required: ["
|
|
29
|
+
readonly required: ["sourceService", "sourceID", "lastSyncedAt"];
|
|
42
30
|
readonly additionalProperties: false;
|
|
43
31
|
readonly definitions: {
|
|
44
|
-
readonly TimeZone: import("json-schema-typed").JSONSchema.String;
|
|
45
32
|
readonly SourceService: {
|
|
46
33
|
readonly type: "string";
|
|
47
34
|
readonly title: "SourceService";
|
|
@@ -63,46 +50,20 @@ export declare const googleCalendarDefinition: {
|
|
|
63
50
|
};
|
|
64
51
|
};
|
|
65
52
|
readonly fieldsMeta: {};
|
|
53
|
+
readonly providerMergePolicy: {
|
|
54
|
+
readonly group: [...string[], "primary", "sourceService", "sourceID", "sourceURL"];
|
|
55
|
+
};
|
|
66
56
|
};
|
|
67
57
|
export declare const googleCalendarEventDefinition: {
|
|
68
58
|
readonly name: "GoogleCalendarEvent";
|
|
69
59
|
readonly behavior: "default";
|
|
70
|
-
readonly interfaces: [string, string];
|
|
60
|
+
readonly interfaces: [string, string, string];
|
|
71
61
|
readonly schema: {
|
|
72
62
|
readonly type: "object";
|
|
73
63
|
readonly properties: {
|
|
74
|
-
readonly title: {
|
|
75
|
-
readonly type: "string";
|
|
76
|
-
readonly minLength: 1;
|
|
77
|
-
readonly maxLength: 200;
|
|
78
|
-
};
|
|
79
|
-
readonly startAt: {
|
|
80
|
-
readonly $ref: "#/definitions/DateTime";
|
|
81
|
-
};
|
|
82
|
-
readonly endAt: {
|
|
83
|
-
readonly $ref: "#/definitions/DateTime";
|
|
84
|
-
};
|
|
85
|
-
readonly allDay: {
|
|
86
|
-
readonly type: "boolean";
|
|
87
|
-
};
|
|
88
|
-
readonly timeZone: {
|
|
89
|
-
readonly $ref: "#/definitions/TimeZone";
|
|
90
|
-
};
|
|
91
|
-
readonly description: {
|
|
92
|
-
readonly type: "string";
|
|
93
|
-
};
|
|
94
|
-
readonly location: {
|
|
95
|
-
readonly type: "string";
|
|
96
|
-
};
|
|
97
|
-
readonly status: {
|
|
98
|
-
readonly $ref: "#/definitions/EventStatus";
|
|
99
|
-
};
|
|
100
64
|
readonly htmlLink: {
|
|
101
65
|
readonly $ref: "#/definitions/HtmlLink";
|
|
102
66
|
};
|
|
103
|
-
readonly recurringEventID: {
|
|
104
|
-
readonly type: "string";
|
|
105
|
-
};
|
|
106
67
|
readonly eventID: {
|
|
107
68
|
readonly type: "string";
|
|
108
69
|
};
|
|
@@ -128,7 +89,7 @@ export declare const googleCalendarEventDefinition: {
|
|
|
128
89
|
readonly $ref: "#/definitions/DateTime";
|
|
129
90
|
};
|
|
130
91
|
};
|
|
131
|
-
readonly required: ["
|
|
92
|
+
readonly required: ["calendarID", "calendar", "sourceService", "sourceID", "lastSyncedAt"];
|
|
132
93
|
readonly additionalProperties: false;
|
|
133
94
|
readonly definitions: {
|
|
134
95
|
readonly SourceID: {
|
|
@@ -137,7 +98,6 @@ export declare const googleCalendarEventDefinition: {
|
|
|
137
98
|
readonly minLength: 1;
|
|
138
99
|
readonly maxLength: 1400;
|
|
139
100
|
};
|
|
140
|
-
readonly TimeZone: import("json-schema-typed").JSONSchema.String;
|
|
141
101
|
readonly HtmlLink: {
|
|
142
102
|
readonly type: "string";
|
|
143
103
|
readonly format: "uri";
|
|
@@ -148,11 +108,6 @@ export declare const googleCalendarEventDefinition: {
|
|
|
148
108
|
readonly title: "DocID";
|
|
149
109
|
readonly pattern: "^k[0-9a-z]{10,120}$";
|
|
150
110
|
};
|
|
151
|
-
readonly EventStatus: {
|
|
152
|
-
readonly type: "string";
|
|
153
|
-
readonly title: "EventStatus";
|
|
154
|
-
readonly enum: ["CONFIRMED", "TENTATIVE", "CANCELLED"];
|
|
155
|
-
};
|
|
156
111
|
readonly SourceService: {
|
|
157
112
|
readonly type: "string";
|
|
158
113
|
readonly title: "SourceService";
|
|
@@ -174,7 +129,7 @@ export declare const googleCalendarEventDefinition: {
|
|
|
174
129
|
};
|
|
175
130
|
};
|
|
176
131
|
readonly providerMergePolicy: {
|
|
177
|
-
readonly group: ["title",
|
|
132
|
+
readonly group: ["title", ...string[], "htmlLink", "eventID", "calendarID", "calendar", "sourceService", "sourceID", "sourceURL", "etag"];
|
|
178
133
|
};
|
|
179
134
|
};
|
|
180
135
|
export declare const googleCalendarClusters: import("@kubun/protocol").ClustersRecord;
|