@kubun/connector-google-calendar 0.17.1 → 0.17.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/lib/account-key.d.ts +10 -0
- package/lib/account-key.js +19 -0
- package/lib/actions.d.ts +1 -0
- package/lib/actions.js +76 -24
- package/lib/api.d.ts +31 -4
- package/lib/api.js +103 -69
- package/lib/connector.js +29 -4
- package/lib/errors.d.ts +22 -0
- package/lib/errors.js +31 -0
- package/lib/event-id.d.ts +12 -0
- package/lib/event-id.js +35 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/intent.d.ts +23 -0
- package/lib/intent.js +86 -0
- package/lib/model.d.ts +24 -13
- package/lib/model.js +38 -24
- package/lib/predict.d.ts +8 -0
- package/lib/predict.js +12 -0
- package/lib/provider.d.ts +1 -0
- package/lib/provider.js +125 -53
- package/lib/target.d.ts +11 -0
- package/lib/target.js +68 -0
- package/package.json +12 -6
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { createGoogleCalendarActionHandler, type GoogleCalendarActionHandlerConfig, } from './actions.js';
|
|
2
2
|
export { buildCalendarListURL, buildEventFromParams, buildEventsListURL, createEvent, type GoogleCalendarListResponse, type GoogleCalendarResource, type GoogleEventDateTime, type GoogleEventResource, type GoogleEventsListResponse, mapCalendarToEntity, mapEventToEntity, updateEvent, } from './api.js';
|
|
3
3
|
export { type CreateGoogleCalendarConnectorOptions, createGoogleCalendarConnector, } from './connector.js';
|
|
4
|
+
export { GoogleCalendarError, type GoogleCalendarErrorCode, type GoogleCalendarErrorOptions, GoogleCalendarEventConflict, } from './errors.js';
|
|
5
|
+
export { deriveEventID } from './event-id.js';
|
|
6
|
+
export { canonicalizeEventIntent, eventHoldsIntent } from './intent.js';
|
|
4
7
|
export { googleCalendarClusters, googleCalendarDefinition, googleCalendarEventDefinition, } from './model.js';
|
|
8
|
+
export { predictCreatedEventSourceID } from './predict.js';
|
|
5
9
|
export { type CalendarCheckpoint, createGoogleCalendarProvider, type GoogleCalendarProviderConfig, } from './provider.js';
|
|
10
|
+
export { resolveCalendarTarget } from './target.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { createGoogleCalendarActionHandler } from './actions.js';
|
|
2
2
|
export { buildCalendarListURL, buildEventFromParams, buildEventsListURL, createEvent, mapCalendarToEntity, mapEventToEntity, updateEvent } from './api.js';
|
|
3
3
|
export { createGoogleCalendarConnector } from './connector.js';
|
|
4
|
+
export { GoogleCalendarError, GoogleCalendarEventConflict } from './errors.js';
|
|
5
|
+
export { deriveEventID } from './event-id.js';
|
|
6
|
+
export { canonicalizeEventIntent, eventHoldsIntent } from './intent.js';
|
|
4
7
|
export { googleCalendarClusters, googleCalendarDefinition, googleCalendarEventDefinition } from './model.js';
|
|
8
|
+
export { predictCreatedEventSourceID } from './predict.js';
|
|
5
9
|
export { createGoogleCalendarProvider } from './provider.js';
|
|
10
|
+
export { resolveCalendarTarget } from './target.js';
|
package/lib/intent.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EntityRecord, IntentCanonicalizer } from '@kubun/connector';
|
|
2
|
+
/**
|
|
3
|
+
* The Calendar intent canonicalizer (see `IntentCanonicalizer`). Folds in only
|
|
4
|
+
* what Google itself does to a written event:
|
|
5
|
+
* - a start/end is compared as the instant (or, all-day, the date) it names,
|
|
6
|
+
* only when it names one by itself (an explicit offset), and an all-day
|
|
7
|
+
* value never matches a timed one;
|
|
8
|
+
* - `calendarID` is compared exactly: an operation's intent already carries
|
|
9
|
+
* the calendar the `'primary'` alias was resolved to before the claim;
|
|
10
|
+
* - a cancelled (deleted) event holds no create or update.
|
|
11
|
+
*/
|
|
12
|
+
export declare const canonicalizeEventIntent: IntentCanonicalizer;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a Calendar event holds a write's intent: every event field the
|
|
15
|
+
* intent sets carries the intended value, as Google keeps it. Keys of the
|
|
16
|
+
* intent that are not event fields (a target, say) are not compared, and an
|
|
17
|
+
* intent that sets no event field does not hold.
|
|
18
|
+
*/
|
|
19
|
+
export declare function eventHoldsIntent(params: {
|
|
20
|
+
op: 'create' | 'update';
|
|
21
|
+
intent: Record<string, unknown>;
|
|
22
|
+
record: EntityRecord;
|
|
23
|
+
}): boolean;
|
package/lib/intent.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { digestJSON } from '@kubun/id';
|
|
2
|
+
import { toB64U } from '@sozai/codec';
|
|
3
|
+
import { googleCalendarEventDefinition } from './model.js';
|
|
4
|
+
const EVENT_FIELDS = new Set(googleCalendarEventDefinition.providerMergePolicy.group);
|
|
5
|
+
const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/;
|
|
7
|
+
// A date-time that names its instant by itself: an explicit `Z` or `±hh:mm`.
|
|
8
|
+
const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/i;
|
|
9
|
+
// A start/end in the form Google keeps it. An all-day value is its calendar
|
|
10
|
+
// date (Google stores only the date) — kept a string, so it never equals a
|
|
11
|
+
// timed instant. A timed value with an explicit offset is the instant it names
|
|
12
|
+
// (Google re-renders a dateTime in the calendar's offset). Anything else — an
|
|
13
|
+
// offset-less date-time, which would otherwise be read in the host's time
|
|
14
|
+
// zone — stays as it is and is compared exactly.
|
|
15
|
+
function canonicalDateTime(value, allDay) {
|
|
16
|
+
if (typeof value !== 'string') return value;
|
|
17
|
+
if (allDay) return DATE_PREFIX.test(value) ? value.slice(0, 10) : value;
|
|
18
|
+
return WITH_OFFSET.test(value) ? Date.parse(value) : value;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The Calendar intent canonicalizer (see `IntentCanonicalizer`). Folds in only
|
|
22
|
+
* what Google itself does to a written event:
|
|
23
|
+
* - a start/end is compared as the instant (or, all-day, the date) it names,
|
|
24
|
+
* only when it names one by itself (an explicit offset), and an all-day
|
|
25
|
+
* value never matches a timed one;
|
|
26
|
+
* - `calendarID` is compared exactly: an operation's intent already carries
|
|
27
|
+
* the calendar the `'primary'` alias was resolved to before the claim;
|
|
28
|
+
* - a cancelled (deleted) event holds no create or update.
|
|
29
|
+
*/ export const canonicalizeEventIntent = ({ model, intent, record })=>{
|
|
30
|
+
if (model !== googleCalendarEventDefinition.name) return {
|
|
31
|
+
intent,
|
|
32
|
+
record
|
|
33
|
+
};
|
|
34
|
+
if (record.status === 'CANCELLED') return null;
|
|
35
|
+
// Each side is read in its own shape: an all-day intent (declared, or a
|
|
36
|
+
// bare date) as dates, the record by its own allDay flag. A timed intent
|
|
37
|
+
// against an all-day event, or the reverse, therefore never compares equal.
|
|
38
|
+
const intentAllDay = intent.allDay === true;
|
|
39
|
+
const recordAllDay = record.allDay === true;
|
|
40
|
+
const canonicalIntent = {
|
|
41
|
+
...intent
|
|
42
|
+
};
|
|
43
|
+
const canonicalRecord = {
|
|
44
|
+
...record
|
|
45
|
+
};
|
|
46
|
+
for (const field of [
|
|
47
|
+
'startAt',
|
|
48
|
+
'endAt'
|
|
49
|
+
]){
|
|
50
|
+
if (field in canonicalIntent) {
|
|
51
|
+
const value = canonicalIntent[field];
|
|
52
|
+
const asDate = intentAllDay || typeof value === 'string' && BARE_DATE.test(value);
|
|
53
|
+
canonicalIntent[field] = canonicalDateTime(value, asDate);
|
|
54
|
+
}
|
|
55
|
+
canonicalRecord[field] = canonicalDateTime(canonicalRecord[field], recordAllDay);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
intent: canonicalIntent,
|
|
59
|
+
record: canonicalRecord
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
function canonicalOf(value) {
|
|
63
|
+
return toB64U(digestJSON(value === undefined ? null : value));
|
|
64
|
+
}
|
|
65
|
+
// An intent that sets no event field confirms nothing, and does not hold.
|
|
66
|
+
function holds(compared) {
|
|
67
|
+
let matched = 0;
|
|
68
|
+
for (const [field, intended] of Object.entries(compared.intent)){
|
|
69
|
+
if (!EVENT_FIELDS.has(field)) continue;
|
|
70
|
+
if (canonicalOf(compared.record[field]) !== canonicalOf(intended)) return false;
|
|
71
|
+
matched++;
|
|
72
|
+
}
|
|
73
|
+
return matched > 0;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether a Calendar event holds a write's intent: every event field the
|
|
77
|
+
* intent sets carries the intended value, as Google keeps it. Keys of the
|
|
78
|
+
* intent that are not event fields (a target, say) are not compared, and an
|
|
79
|
+
* intent that sets no event field does not hold.
|
|
80
|
+
*/ export function eventHoldsIntent(params) {
|
|
81
|
+
const compared = canonicalizeEventIntent({
|
|
82
|
+
model: googleCalendarEventDefinition.name,
|
|
83
|
+
...params
|
|
84
|
+
});
|
|
85
|
+
return compared != null && holds(compared);
|
|
86
|
+
}
|
package/lib/model.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export declare const googleCalendarDefinition: {
|
|
|
20
20
|
readonly backgroundColor: {
|
|
21
21
|
readonly type: "string";
|
|
22
22
|
};
|
|
23
|
+
readonly accountKey: {
|
|
24
|
+
readonly type: "string";
|
|
25
|
+
readonly minLength: 43;
|
|
26
|
+
readonly maxLength: 43;
|
|
27
|
+
};
|
|
23
28
|
readonly sourceService: {
|
|
24
29
|
readonly $ref: "#/definitions/SourceService";
|
|
25
30
|
};
|
|
@@ -36,6 +41,7 @@ export declare const googleCalendarDefinition: {
|
|
|
36
41
|
readonly required: ["summary", "sourceService", "sourceID", "lastSyncedAt"];
|
|
37
42
|
readonly additionalProperties: false;
|
|
38
43
|
readonly definitions: {
|
|
44
|
+
readonly TimeZone: import("json-schema-typed").JSONSchema.String;
|
|
39
45
|
readonly SourceService: {
|
|
40
46
|
readonly type: "string";
|
|
41
47
|
readonly title: "SourceService";
|
|
@@ -54,7 +60,6 @@ export declare const googleCalendarDefinition: {
|
|
|
54
60
|
readonly title: "URL";
|
|
55
61
|
};
|
|
56
62
|
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
57
|
-
readonly TimeZone: import("json-schema-typed").JSONSchema.String;
|
|
58
63
|
};
|
|
59
64
|
};
|
|
60
65
|
readonly fieldsMeta: {};
|
|
@@ -104,6 +109,9 @@ export declare const googleCalendarEventDefinition: {
|
|
|
104
109
|
readonly calendarID: {
|
|
105
110
|
readonly type: "string";
|
|
106
111
|
};
|
|
112
|
+
readonly etag: {
|
|
113
|
+
readonly type: "string";
|
|
114
|
+
};
|
|
107
115
|
readonly calendar: {
|
|
108
116
|
readonly $ref: "#/definitions/CalendarDocID";
|
|
109
117
|
};
|
|
@@ -123,24 +131,12 @@ export declare const googleCalendarEventDefinition: {
|
|
|
123
131
|
readonly required: ["title", "startAt", "allDay", "calendarID", "calendar", "sourceService", "sourceID", "lastSyncedAt"];
|
|
124
132
|
readonly additionalProperties: false;
|
|
125
133
|
readonly definitions: {
|
|
126
|
-
readonly SourceService: {
|
|
127
|
-
readonly type: "string";
|
|
128
|
-
readonly title: "SourceService";
|
|
129
|
-
readonly minLength: 1;
|
|
130
|
-
readonly maxLength: 50;
|
|
131
|
-
};
|
|
132
134
|
readonly SourceID: {
|
|
133
135
|
readonly type: "string";
|
|
134
136
|
readonly title: "SourceID";
|
|
135
137
|
readonly minLength: 1;
|
|
136
138
|
readonly maxLength: 1400;
|
|
137
139
|
};
|
|
138
|
-
readonly SourceURL: {
|
|
139
|
-
readonly type: "string";
|
|
140
|
-
readonly format: "uri";
|
|
141
|
-
readonly title: "URL";
|
|
142
|
-
};
|
|
143
|
-
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
144
140
|
readonly TimeZone: import("json-schema-typed").JSONSchema.String;
|
|
145
141
|
readonly HtmlLink: {
|
|
146
142
|
readonly type: "string";
|
|
@@ -157,6 +153,18 @@ export declare const googleCalendarEventDefinition: {
|
|
|
157
153
|
readonly title: "EventStatus";
|
|
158
154
|
readonly enum: ["CONFIRMED", "TENTATIVE", "CANCELLED"];
|
|
159
155
|
};
|
|
156
|
+
readonly SourceService: {
|
|
157
|
+
readonly type: "string";
|
|
158
|
+
readonly title: "SourceService";
|
|
159
|
+
readonly minLength: 1;
|
|
160
|
+
readonly maxLength: 50;
|
|
161
|
+
};
|
|
162
|
+
readonly SourceURL: {
|
|
163
|
+
readonly type: "string";
|
|
164
|
+
readonly format: "uri";
|
|
165
|
+
readonly title: "URL";
|
|
166
|
+
};
|
|
167
|
+
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
160
168
|
};
|
|
161
169
|
};
|
|
162
170
|
readonly fieldsMeta: {
|
|
@@ -165,5 +173,8 @@ export declare const googleCalendarEventDefinition: {
|
|
|
165
173
|
readonly model: "#0";
|
|
166
174
|
};
|
|
167
175
|
};
|
|
176
|
+
readonly providerMergePolicy: {
|
|
177
|
+
readonly group: ["title", "startAt", "endAt", "allDay", "timeZone", "description", "location", "status", "htmlLink", "recurringEventID", "eventID", "calendarID", "calendar", "sourceService", "sourceID", "sourceURL", "etag"];
|
|
178
|
+
};
|
|
168
179
|
};
|
|
169
180
|
export declare const googleCalendarClusters: import("@kubun/protocol").ClustersRecord;
|
package/lib/model.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { connectorClusters, connectorModelIDs } from '@kubun/connector';
|
|
1
|
+
import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
|
|
2
2
|
import { modelIDs, modelsClusters } from '@kubun/models';
|
|
3
3
|
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
-
import {
|
|
4
|
+
import { docIDSchema, timeZoneSchema, urlSchema } from '@kubun/scalars';
|
|
5
5
|
export const googleCalendarDefinition = {
|
|
6
6
|
name: 'GoogleCalendar',
|
|
7
7
|
behavior: 'default',
|
|
@@ -26,6 +26,11 @@ export const googleCalendarDefinition = {
|
|
|
26
26
|
backgroundColor: {
|
|
27
27
|
type: 'string'
|
|
28
28
|
},
|
|
29
|
+
accountKey: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
minLength: 43,
|
|
32
|
+
maxLength: 43
|
|
33
|
+
},
|
|
29
34
|
sourceService: {
|
|
30
35
|
$ref: '#/definitions/SourceService'
|
|
31
36
|
},
|
|
@@ -47,20 +52,7 @@ export const googleCalendarDefinition = {
|
|
|
47
52
|
],
|
|
48
53
|
additionalProperties: false,
|
|
49
54
|
definitions: {
|
|
50
|
-
|
|
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,
|
|
55
|
+
...externalEntityDefinition.schema.definitions,
|
|
64
56
|
TimeZone: timeZoneSchema
|
|
65
57
|
}
|
|
66
58
|
},
|
|
@@ -114,6 +106,9 @@ export const googleCalendarEventDefinition = {
|
|
|
114
106
|
calendarID: {
|
|
115
107
|
type: 'string'
|
|
116
108
|
},
|
|
109
|
+
etag: {
|
|
110
|
+
type: 'string'
|
|
111
|
+
},
|
|
117
112
|
calendar: {
|
|
118
113
|
$ref: '#/definitions/CalendarDocID'
|
|
119
114
|
},
|
|
@@ -142,12 +137,7 @@ export const googleCalendarEventDefinition = {
|
|
|
142
137
|
],
|
|
143
138
|
additionalProperties: false,
|
|
144
139
|
definitions: {
|
|
145
|
-
|
|
146
|
-
type: 'string',
|
|
147
|
-
title: 'SourceService',
|
|
148
|
-
minLength: 1,
|
|
149
|
-
maxLength: 50
|
|
150
|
-
},
|
|
140
|
+
...externalEntityDefinition.schema.definitions,
|
|
151
141
|
// A composite calendarID/eventID: calendar id (email-like, up to ~254) +
|
|
152
142
|
// '/' + a Google event id (up to 1024). 500 is too small.
|
|
153
143
|
SourceID: {
|
|
@@ -156,8 +146,6 @@ export const googleCalendarEventDefinition = {
|
|
|
156
146
|
minLength: 1,
|
|
157
147
|
maxLength: 1400
|
|
158
148
|
},
|
|
159
|
-
SourceURL: urlSchema,
|
|
160
|
-
DateTime: dateTimeSchema,
|
|
161
149
|
TimeZone: timeZoneSchema,
|
|
162
150
|
HtmlLink: urlSchema,
|
|
163
151
|
CalendarDocID: docIDSchema,
|
|
@@ -177,6 +165,32 @@ export const googleCalendarEventDefinition = {
|
|
|
177
165
|
type: 'document',
|
|
178
166
|
model: '#0'
|
|
179
167
|
}
|
|
168
|
+
},
|
|
169
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
170
|
+
// provider content, so it stays outside the group.
|
|
171
|
+
providerMergePolicy: {
|
|
172
|
+
group: [
|
|
173
|
+
'title',
|
|
174
|
+
'startAt',
|
|
175
|
+
'endAt',
|
|
176
|
+
'allDay',
|
|
177
|
+
'timeZone',
|
|
178
|
+
'description',
|
|
179
|
+
'location',
|
|
180
|
+
'status',
|
|
181
|
+
'htmlLink',
|
|
182
|
+
'recurringEventID',
|
|
183
|
+
'eventID',
|
|
184
|
+
'calendarID',
|
|
185
|
+
'calendar',
|
|
186
|
+
'sourceService',
|
|
187
|
+
'sourceID',
|
|
188
|
+
'sourceURL',
|
|
189
|
+
// The provider revision of this state: an update is written
|
|
190
|
+
// conditionally on it (If-Match), so it must travel with the fields it
|
|
191
|
+
// versions.
|
|
192
|
+
'etag'
|
|
193
|
+
]
|
|
180
194
|
}
|
|
181
195
|
};
|
|
182
196
|
const builder = new GraphBuilder({
|
package/lib/predict.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CreatedSourceIDPredictor } from '@kubun/connector';
|
|
2
|
+
/**
|
|
3
|
+
* The provider id a calendar event create is stored under: the resolved
|
|
4
|
+
* calendar and the event id derived from the operation's deterministic event
|
|
5
|
+
* id, as the create handler inserts it. `null` for a calendar create and for
|
|
6
|
+
* an input whose calendar is not resolved.
|
|
7
|
+
*/
|
|
8
|
+
export declare const predictCreatedEventSourceID: CreatedSourceIDPredictor;
|
package/lib/predict.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { deriveEventID } from './event-id.js';
|
|
2
|
+
/**
|
|
3
|
+
* The provider id a calendar event create is stored under: the resolved
|
|
4
|
+
* calendar and the event id derived from the operation's deterministic event
|
|
5
|
+
* id, as the create handler inserts it. `null` for a calendar create and for
|
|
6
|
+
* an input whose calendar is not resolved.
|
|
7
|
+
*/ export const predictCreatedEventSourceID = ({ model, input, deterministicEventID })=>{
|
|
8
|
+
if (model !== 'GoogleCalendarEvent') return null;
|
|
9
|
+
const calendarID = input.calendarID;
|
|
10
|
+
if (typeof calendarID !== 'string' || calendarID === '' || calendarID === 'primary') return null;
|
|
11
|
+
return `${calendarID}/${deriveEventID(deterministicEventID)}`;
|
|
12
|
+
};
|
package/lib/provider.d.ts
CHANGED
package/lib/provider.js
CHANGED
|
@@ -1,39 +1,12 @@
|
|
|
1
|
-
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import {
|
|
1
|
+
import { ConnectorError, fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { deriveAccountKey } from './account-key.js';
|
|
3
|
+
import { buildCalendarListURL, buildEventsListURL, calendarAPIClient, getCalendarListEntry, getEvent, getEventWithETag, mapCalendarToEntity, mapEventToEntity, splitEventSourceID } from './api.js';
|
|
3
4
|
import { googleCalendarClusters } from './model.js';
|
|
4
5
|
const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
|
|
5
6
|
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
|
-
}
|
|
17
|
-
async function fetchJSON(url, credential, fetchFn, signal) {
|
|
18
|
-
const response = await fetchFn(url, {
|
|
19
|
-
headers: {
|
|
20
|
-
Authorization: `Bearer ${credential.accessToken}`
|
|
21
|
-
},
|
|
22
|
-
signal
|
|
23
|
-
});
|
|
24
|
-
if (!response.ok) {
|
|
25
|
-
throw new GoogleCalendarAPIError(response.status, await parseGoogleError(response));
|
|
26
|
-
}
|
|
27
|
-
return await response.json();
|
|
28
|
-
}
|
|
29
7
|
function computeTimeMin(boundary) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const match = /^P(\d+)D$/i.exec(boundary.maxAge);
|
|
33
|
-
if (match == null) return undefined;
|
|
34
|
-
const rawDays = match[1];
|
|
35
|
-
if (rawDays == null) return undefined;
|
|
36
|
-
const days = Number.parseInt(rawDays, 10);
|
|
8
|
+
const days = parseMaxAgeDays(boundary);
|
|
9
|
+
if (days == null) return undefined;
|
|
37
10
|
const date = new Date();
|
|
38
11
|
date.setDate(date.getDate() - days);
|
|
39
12
|
return date.toISOString();
|
|
@@ -46,7 +19,9 @@ async function* fetchAllCalendars(params) {
|
|
|
46
19
|
syncToken: isFirstPage ? params.syncToken : undefined,
|
|
47
20
|
pageToken
|
|
48
21
|
});
|
|
49
|
-
const data = await
|
|
22
|
+
const data = await params.client.get(url, {
|
|
23
|
+
signal: params.signal
|
|
24
|
+
});
|
|
50
25
|
yield {
|
|
51
26
|
items: data.items ?? [],
|
|
52
27
|
nextSyncToken: data.nextSyncToken,
|
|
@@ -66,7 +41,9 @@ async function* fetchEventsForCalendar(params) {
|
|
|
66
41
|
pageToken,
|
|
67
42
|
timeMin: params.timeMin
|
|
68
43
|
});
|
|
69
|
-
const data = await
|
|
44
|
+
const data = await params.client.get(url, {
|
|
45
|
+
signal: params.signal
|
|
46
|
+
});
|
|
70
47
|
yield {
|
|
71
48
|
items: data.items ?? [],
|
|
72
49
|
nextSyncToken: data.nextSyncToken
|
|
@@ -77,7 +54,13 @@ async function* fetchEventsForCalendar(params) {
|
|
|
77
54
|
}
|
|
78
55
|
export function createGoogleCalendarProvider(config) {
|
|
79
56
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
57
|
+
const client = calendarAPIClient(config.credential.accessToken, fetchFn);
|
|
80
58
|
const excludeSet = new Set(config.excludeCalendars ?? []);
|
|
59
|
+
const mintFetchHLC = config.mintFetchHLC;
|
|
60
|
+
// Derived once per provider instance — a pure function of (iss,sub), so it is
|
|
61
|
+
// stable across refresh and re-auth. Absent when the credential predates
|
|
62
|
+
// providerAccount capture.
|
|
63
|
+
const accountKey = config.credential.providerAccount != null ? deriveAccountKey(config.credential.providerAccount) : undefined;
|
|
81
64
|
async function* fetchAllEntities(params) {
|
|
82
65
|
// Reuse a window frozen by a prior sync (carried in an expired checkpoint on
|
|
83
66
|
// the 410 resync path, or a re-driven initial) instead of recomputing from
|
|
@@ -89,8 +72,7 @@ export function createGoogleCalendarProvider(config) {
|
|
|
89
72
|
const allCalendars = [];
|
|
90
73
|
let calendarListSyncToken;
|
|
91
74
|
for await (const page of fetchAllCalendars({
|
|
92
|
-
|
|
93
|
-
fetchFn,
|
|
75
|
+
client,
|
|
94
76
|
signal: params.signal
|
|
95
77
|
})){
|
|
96
78
|
for (const cal of page.items){
|
|
@@ -103,9 +85,14 @@ export function createGoogleCalendarProvider(config) {
|
|
|
103
85
|
}
|
|
104
86
|
}
|
|
105
87
|
// Yield calendar batch (no checkpoint yet — will be on the last event batch)
|
|
88
|
+
const calendarListToken = mintFetchHLC();
|
|
106
89
|
yield {
|
|
107
90
|
modelID: CALENDAR_MODEL_ID,
|
|
108
|
-
entities: allCalendars.map(
|
|
91
|
+
entities: allCalendars.map((c)=>({
|
|
92
|
+
present: true,
|
|
93
|
+
record: mapCalendarToEntity(c, accountKey),
|
|
94
|
+
sourceFetchHLC: calendarListToken
|
|
95
|
+
})),
|
|
109
96
|
hasMore: true
|
|
110
97
|
};
|
|
111
98
|
// Phase 2: fetch events per calendar
|
|
@@ -116,8 +103,7 @@ export function createGoogleCalendarProvider(config) {
|
|
|
116
103
|
let eventsSyncToken;
|
|
117
104
|
for await (const page of fetchEventsForCalendar({
|
|
118
105
|
calendarID: calendar.id,
|
|
119
|
-
|
|
120
|
-
fetchFn,
|
|
106
|
+
client,
|
|
121
107
|
timeMin,
|
|
122
108
|
signal: params.signal
|
|
123
109
|
})){
|
|
@@ -141,14 +127,20 @@ export function createGoogleCalendarProvider(config) {
|
|
|
141
127
|
// Split items: cancelled → deleted, others → entities
|
|
142
128
|
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
143
129
|
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
130
|
+
const pageToken = mintFetchHLC();
|
|
144
131
|
yield {
|
|
145
132
|
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
146
|
-
entities: activeItems.map((event)=>
|
|
133
|
+
entities: activeItems.map((event)=>({
|
|
134
|
+
present: true,
|
|
135
|
+
record: mapEventToEntity(event, calendar.id),
|
|
136
|
+
sourceFetchHLC: pageToken
|
|
137
|
+
})),
|
|
147
138
|
// Match the composite identity mapEventToEntity assigns, so the
|
|
148
139
|
// tombstone lookup hits the calendar-namespaced document.
|
|
149
140
|
deleted: cancelledItems.map((e)=>({
|
|
150
|
-
|
|
151
|
-
sourceID: `${calendar.id}/${e.id}
|
|
141
|
+
present: false,
|
|
142
|
+
sourceID: `${calendar.id}/${e.id}`,
|
|
143
|
+
sourceFetchHLC: pageToken
|
|
152
144
|
})),
|
|
153
145
|
hasMore: !(isLastCalendar && isLastPage),
|
|
154
146
|
checkpoint
|
|
@@ -185,8 +177,7 @@ export function createGoogleCalendarProvider(config) {
|
|
|
185
177
|
const deletedCalendarIDs = [];
|
|
186
178
|
let newCalendarListSyncToken;
|
|
187
179
|
for await (const page of fetchAllCalendars({
|
|
188
|
-
|
|
189
|
-
fetchFn,
|
|
180
|
+
client,
|
|
190
181
|
syncToken: checkpoint.calendarListSyncToken,
|
|
191
182
|
signal: params.signal
|
|
192
183
|
})){
|
|
@@ -202,12 +193,18 @@ export function createGoogleCalendarProvider(config) {
|
|
|
202
193
|
}
|
|
203
194
|
}
|
|
204
195
|
// Yield calendar batch (active + deletions)
|
|
196
|
+
const calendarListToken = mintFetchHLC();
|
|
205
197
|
yield {
|
|
206
198
|
modelID: CALENDAR_MODEL_ID,
|
|
207
|
-
entities: activeCalendars.map(
|
|
199
|
+
entities: activeCalendars.map((c)=>({
|
|
200
|
+
present: true,
|
|
201
|
+
record: mapCalendarToEntity(c, accountKey),
|
|
202
|
+
sourceFetchHLC: calendarListToken
|
|
203
|
+
})),
|
|
208
204
|
deleted: deletedCalendarIDs.map((id)=>({
|
|
209
|
-
|
|
210
|
-
sourceID: id
|
|
205
|
+
present: false,
|
|
206
|
+
sourceID: id,
|
|
207
|
+
sourceFetchHLC: calendarListToken
|
|
211
208
|
})),
|
|
212
209
|
hasMore: true
|
|
213
210
|
};
|
|
@@ -244,8 +241,7 @@ export function createGoogleCalendarProvider(config) {
|
|
|
244
241
|
let eventsSyncToken;
|
|
245
242
|
for await (const page of fetchEventsForCalendar({
|
|
246
243
|
calendarID,
|
|
247
|
-
|
|
248
|
-
fetchFn,
|
|
244
|
+
client,
|
|
249
245
|
syncToken: storedToken,
|
|
250
246
|
timeMin: isNew ? timeMin : undefined,
|
|
251
247
|
signal: params.signal
|
|
@@ -269,14 +265,20 @@ export function createGoogleCalendarProvider(config) {
|
|
|
269
265
|
// Split items: cancelled → deleted, others → entities
|
|
270
266
|
const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
|
|
271
267
|
const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
|
|
268
|
+
const pageToken = mintFetchHLC();
|
|
272
269
|
yield {
|
|
273
270
|
modelID: CALENDAR_EVENT_MODEL_ID,
|
|
274
|
-
entities: activeItems.map((event)=>
|
|
271
|
+
entities: activeItems.map((event)=>({
|
|
272
|
+
present: true,
|
|
273
|
+
record: mapEventToEntity(event, calendarID),
|
|
274
|
+
sourceFetchHLC: pageToken
|
|
275
|
+
})),
|
|
275
276
|
// Match the composite identity mapEventToEntity assigns, so the
|
|
276
277
|
// tombstone lookup hits the calendar-namespaced document.
|
|
277
278
|
deleted: cancelledItems.map((e)=>({
|
|
278
|
-
|
|
279
|
-
sourceID: `${calendarID}/${e.id}
|
|
279
|
+
present: false,
|
|
280
|
+
sourceID: `${calendarID}/${e.id}`,
|
|
281
|
+
sourceFetchHLC: pageToken
|
|
280
282
|
})),
|
|
281
283
|
hasMore: !(isLastCalendar && isLastPage),
|
|
282
284
|
checkpoint: newCheckpoint
|
|
@@ -303,11 +305,81 @@ export function createGoogleCalendarProvider(config) {
|
|
|
303
305
|
};
|
|
304
306
|
}
|
|
305
307
|
}
|
|
308
|
+
// Targeted single-entity fetch (anti-entropy repair verification). Mints its
|
|
309
|
+
// own fetch-order token at request start, independent of any batch token
|
|
310
|
+
// (see ServerProviderFactory.mintFetchHLC's doc). `model` distinguishes the
|
|
311
|
+
// account-level GoogleCalendar resource from a GoogleCalendarEvent — only
|
|
312
|
+
// the latter's sourceID is the composite calendarID/eventID.
|
|
313
|
+
async function fetchEntity(model, sourceID) {
|
|
314
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
315
|
+
return fetchOrAbsent({
|
|
316
|
+
sourceID,
|
|
317
|
+
sourceFetchHLC,
|
|
318
|
+
read: async ()=>{
|
|
319
|
+
if (model === CALENDAR_MODEL_ID) {
|
|
320
|
+
const calendar = await getCalendarListEntry({
|
|
321
|
+
accessToken: config.credential.accessToken,
|
|
322
|
+
calendarID: sourceID,
|
|
323
|
+
fetch: fetchFn
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
record: mapCalendarToEntity(calendar, accountKey)
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const { calendarID, eventID } = splitEventSourceID(sourceID);
|
|
330
|
+
const record = await getEvent({
|
|
331
|
+
accessToken: config.credential.accessToken,
|
|
332
|
+
calendarID,
|
|
333
|
+
eventID,
|
|
334
|
+
fetch: fetchFn
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
record
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// Provider-authoritative re-read for write-back recovery. Unlike
|
|
343
|
+
// fetchEntity, the present:true branch also surfaces a providerETag when the
|
|
344
|
+
// provider reports one — the current revision an update's If-Match can
|
|
345
|
+
// condition on.
|
|
346
|
+
async function reconcile(model, sourceID) {
|
|
347
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
348
|
+
return fetchOrAbsent({
|
|
349
|
+
sourceID,
|
|
350
|
+
sourceFetchHLC,
|
|
351
|
+
read: async ()=>{
|
|
352
|
+
if (model === CALENDAR_MODEL_ID) {
|
|
353
|
+
const calendar = await getCalendarListEntry({
|
|
354
|
+
accessToken: config.credential.accessToken,
|
|
355
|
+
calendarID: sourceID,
|
|
356
|
+
fetch: fetchFn
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
record: mapCalendarToEntity(calendar, accountKey)
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const { calendarID, eventID } = splitEventSourceID(sourceID);
|
|
363
|
+
const { record, etag } = await getEventWithETag({
|
|
364
|
+
accessToken: config.credential.accessToken,
|
|
365
|
+
calendarID,
|
|
366
|
+
eventID,
|
|
367
|
+
fetch: fetchFn
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
record,
|
|
371
|
+
providerETag: etag
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
306
376
|
return {
|
|
307
377
|
supportedStrategies: [
|
|
308
378
|
'on-demand'
|
|
309
379
|
],
|
|
310
380
|
fetchAll: fetchAllEntities,
|
|
381
|
+
fetchEntity,
|
|
382
|
+
reconcile,
|
|
311
383
|
async *fetchChanges (params) {
|
|
312
384
|
const checkpoint = params.checkpoint;
|
|
313
385
|
if (checkpoint == null) {
|
|
@@ -319,7 +391,7 @@ export function createGoogleCalendarProvider(config) {
|
|
|
319
391
|
} catch (err) {
|
|
320
392
|
// 410 Gone = the stored syncToken has expired; Google requires a full
|
|
321
393
|
// resync. Discard the checkpoint by delegating to fetchAll.
|
|
322
|
-
if (err instanceof
|
|
394
|
+
if (err instanceof ConnectorError && err.httpStatus === 410) {
|
|
323
395
|
yield* fetchAllEntities(params);
|
|
324
396
|
return;
|
|
325
397
|
}
|
package/lib/target.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ActionTargetResolver } from '@kubun/connector';
|
|
2
|
+
/**
|
|
3
|
+
* The Calendar target resolver (see `ActionTargetResolver`): a write's target
|
|
4
|
+
* is the calendar it lands in.
|
|
5
|
+
* - A create targets the `calendarID` of its input, required.
|
|
6
|
+
* - An update targets the calendar of its event (the `calendarID/eventID`
|
|
7
|
+
* sourceID); a `calendarID` in its input must name that same calendar.
|
|
8
|
+
* - The `'primary'` alias is resolved to the account's synced primary
|
|
9
|
+
* calendar, and the input carries the resolved id from then on.
|
|
10
|
+
*/
|
|
11
|
+
export declare const resolveCalendarTarget: ActionTargetResolver;
|