@timesheet/plugin-google-calendar 1.2.1 → 1.3.1
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.
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.handleSyncBatch = void 0;
|
|
4
4
|
const integration_sdk_1 = require("@timesheet/integration-sdk");
|
|
5
5
|
const taskSync_1 = require("../lib/taskSync");
|
|
6
|
+
const SYSTEM = 'google-calendar';
|
|
7
|
+
const PROJECT_ENTITY = 'project';
|
|
6
8
|
exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, context) => {
|
|
7
9
|
context.logger.info('Processing sync batch', {
|
|
8
10
|
sinceVersion: input.sinceVersion,
|
|
@@ -10,6 +12,19 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
|
|
|
10
12
|
changeCount: input.changes.length,
|
|
11
13
|
hasMore: input.hasMore
|
|
12
14
|
});
|
|
15
|
+
// Pre-load all mappings once for the entire batch
|
|
16
|
+
const [projectMappings, taskMappings] = await Promise.all([
|
|
17
|
+
context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY }),
|
|
18
|
+
context.mappings.list({ system: SYSTEM, entity: 'task' })
|
|
19
|
+
]);
|
|
20
|
+
const projectMappingByLocalId = new Map();
|
|
21
|
+
for (const mapping of projectMappings) {
|
|
22
|
+
projectMappingByLocalId.set(mapping.localId, mapping);
|
|
23
|
+
}
|
|
24
|
+
const taskMappingByLocalId = new Map();
|
|
25
|
+
for (const mapping of taskMappings) {
|
|
26
|
+
taskMappingByLocalId.set(mapping.localId, mapping);
|
|
27
|
+
}
|
|
13
28
|
let syncedCount = 0;
|
|
14
29
|
const errors = [];
|
|
15
30
|
for (const change of input.changes) {
|
|
@@ -22,7 +37,7 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
|
|
|
22
37
|
event,
|
|
23
38
|
taskId: change.entityId,
|
|
24
39
|
item: change.item
|
|
25
|
-
}, context);
|
|
40
|
+
}, context, { projectMappingByLocalId, taskMappingByLocalId });
|
|
26
41
|
if (result.status === 'synced' || result.status === 'deleted') {
|
|
27
42
|
syncedCount++;
|
|
28
43
|
}
|
|
@@ -37,7 +52,7 @@ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, con
|
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
return {
|
|
40
|
-
system:
|
|
55
|
+
system: SYSTEM,
|
|
41
56
|
status: errors.length > 0 ? 'partial' : 'completed',
|
|
42
57
|
syncedCount,
|
|
43
58
|
details: {
|
|
@@ -7,6 +7,7 @@ interface GoogleCalendarClientOptions {
|
|
|
7
7
|
export declare class GoogleCalendarClient {
|
|
8
8
|
private readonly getAccessToken;
|
|
9
9
|
private readonly refreshAccessToken;
|
|
10
|
+
private cachedToken;
|
|
10
11
|
constructor(options: GoogleCalendarClientOptions);
|
|
11
12
|
testConnection(): Promise<boolean>;
|
|
12
13
|
listCalendars(): Promise<ExternalEntity[]>;
|
|
@@ -3,8 +3,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.GoogleCalendarClient = void 0;
|
|
4
4
|
class GoogleCalendarClient {
|
|
5
5
|
constructor(options) {
|
|
6
|
-
this.
|
|
7
|
-
this.
|
|
6
|
+
this.cachedToken = null;
|
|
7
|
+
this.getAccessToken = async () => {
|
|
8
|
+
if (this.cachedToken)
|
|
9
|
+
return this.cachedToken;
|
|
10
|
+
this.cachedToken = await options.getAccessToken();
|
|
11
|
+
return this.cachedToken;
|
|
12
|
+
};
|
|
13
|
+
this.refreshAccessToken = async () => {
|
|
14
|
+
this.cachedToken = null;
|
|
15
|
+
this.cachedToken = await options.refreshAccessToken();
|
|
16
|
+
return this.cachedToken;
|
|
17
|
+
};
|
|
8
18
|
}
|
|
9
19
|
async testConnection() {
|
|
10
20
|
const calendars = await this.listCalendars();
|
package/dist/lib/taskSync.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IntegrationContext } from '@timesheet/integration-sdk';
|
|
1
|
+
import { IntegrationContext, MappingRecord } from '@timesheet/integration-sdk';
|
|
2
2
|
import { GoogleCalendarConfig, GoogleCalendarSyncInput } from './types';
|
|
3
3
|
export interface GoogleCalendarSyncResult {
|
|
4
4
|
system: string;
|
|
@@ -6,7 +6,11 @@ export interface GoogleCalendarSyncResult {
|
|
|
6
6
|
syncedCount: number;
|
|
7
7
|
details?: Record<string, unknown>;
|
|
8
8
|
}
|
|
9
|
-
export
|
|
9
|
+
export interface SyncBatchCaches {
|
|
10
|
+
projectMappingByLocalId?: Map<string, MappingRecord>;
|
|
11
|
+
taskMappingByLocalId?: Map<string, MappingRecord>;
|
|
12
|
+
}
|
|
13
|
+
export declare function syncTaskToGoogleCalendar(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>, caches?: SyncBatchCaches): Promise<GoogleCalendarSyncResult>;
|
|
10
14
|
export declare function runGoogleCalendarFullSync(context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
|
|
11
15
|
export declare function handleGoogleWebhook(input: GoogleCalendarSyncInput, context: IntegrationContext<GoogleCalendarConfig>): Promise<GoogleCalendarSyncResult>;
|
|
12
16
|
export declare function ensureWatchChannels(context: IntegrationContext<GoogleCalendarConfig>): Promise<void>;
|
package/dist/lib/taskSync.js
CHANGED
|
@@ -8,7 +8,9 @@ const googleCalendarClient_1 = require("./googleCalendarClient");
|
|
|
8
8
|
const SYSTEM = 'google-calendar';
|
|
9
9
|
const PROJECT_ENTITY = 'project';
|
|
10
10
|
const TASK_ENTITY = 'task';
|
|
11
|
-
|
|
11
|
+
// Shared client instance for batch execution — avoids re-fetching the access token per change.
|
|
12
|
+
let sharedClient = null;
|
|
13
|
+
async function syncTaskToGoogleCalendar(input, context, caches) {
|
|
12
14
|
const syncDirection = context.config?.syncDirection ?? 'bidirectional';
|
|
13
15
|
if (syncDirection === 'google-to-timesheet' || syncDirection === 'external-to-timesheet') {
|
|
14
16
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'sync-direction-mismatch' } };
|
|
@@ -21,15 +23,22 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
21
23
|
if (!task) {
|
|
22
24
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'task-not-found' } };
|
|
23
25
|
}
|
|
24
|
-
const projectId = task.project?.id;
|
|
26
|
+
const projectId = task.project?.id ?? input.item?.projectId;
|
|
25
27
|
if (!projectId) {
|
|
26
28
|
return { system: SYSTEM, status: 'skipped', syncedCount: 0, details: { reason: 'missing-project' } };
|
|
27
29
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
30
|
+
// Use pre-loaded project mapping from cache if available
|
|
31
|
+
let projectMapping;
|
|
32
|
+
if (caches?.projectMappingByLocalId) {
|
|
33
|
+
projectMapping = caches.projectMappingByLocalId.get(projectId) ?? null;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
projectMapping = await context.mappings.get({
|
|
37
|
+
system: SYSTEM,
|
|
38
|
+
entity: PROJECT_ENTITY,
|
|
39
|
+
localId: projectId
|
|
40
|
+
});
|
|
41
|
+
}
|
|
33
42
|
const externalCalendarId = projectMapping?.externalId;
|
|
34
43
|
if (!externalCalendarId) {
|
|
35
44
|
return {
|
|
@@ -39,12 +48,19 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
39
48
|
details: { reason: 'missing-project-mapping', projectId }
|
|
40
49
|
};
|
|
41
50
|
}
|
|
42
|
-
const client =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
51
|
+
const client = getOrCreateClient(context);
|
|
52
|
+
// Use pre-loaded task mapping from cache if available
|
|
53
|
+
let taskMapping;
|
|
54
|
+
if (caches?.taskMappingByLocalId) {
|
|
55
|
+
taskMapping = caches.taskMappingByLocalId.get(task.id) ?? null;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
taskMapping = await context.mappings.get({
|
|
59
|
+
system: SYSTEM,
|
|
60
|
+
entity: TASK_ENTITY,
|
|
61
|
+
localId: task.id
|
|
62
|
+
});
|
|
63
|
+
}
|
|
48
64
|
if (task.deleted) {
|
|
49
65
|
if (taskMapping?.externalId) {
|
|
50
66
|
const mappedCalendarId = getMappedCalendarId(taskMapping) ?? externalCalendarId;
|
|
@@ -88,9 +104,7 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
88
104
|
details: { reason: 'missing-external-id' }
|
|
89
105
|
};
|
|
90
106
|
}
|
|
91
|
-
|
|
92
|
-
system: SYSTEM,
|
|
93
|
-
entity: TASK_ENTITY,
|
|
107
|
+
const upsertedMapping = {
|
|
94
108
|
localId: task.id,
|
|
95
109
|
externalId: externalEvent.id,
|
|
96
110
|
externalLabel: externalEvent.summary ?? task.description ?? task.id,
|
|
@@ -100,7 +114,16 @@ async function syncTaskToGoogleCalendar(input, context) {
|
|
|
100
114
|
updated: externalEvent.updated ?? ''
|
|
101
115
|
},
|
|
102
116
|
syncStatus: 'SYNCED'
|
|
117
|
+
};
|
|
118
|
+
await context.mappings.upsert({
|
|
119
|
+
system: SYSTEM,
|
|
120
|
+
entity: TASK_ENTITY,
|
|
121
|
+
...upsertedMapping
|
|
103
122
|
});
|
|
123
|
+
// Update in-memory cache so subsequent changes in the same batch see this mapping
|
|
124
|
+
if (caches?.taskMappingByLocalId) {
|
|
125
|
+
caches.taskMappingByLocalId.set(task.id, upsertedMapping);
|
|
126
|
+
}
|
|
104
127
|
return {
|
|
105
128
|
system: SYSTEM,
|
|
106
129
|
status: 'synced',
|
|
@@ -318,10 +341,10 @@ function toTaskDateRange(event) {
|
|
|
318
341
|
let endRaw = event.end?.dateTime;
|
|
319
342
|
// Handle all-day events (date field instead of dateTime)
|
|
320
343
|
if (!startRaw && event.start?.date) {
|
|
321
|
-
startRaw = `${event.start.date}T00:00:
|
|
344
|
+
startRaw = `${event.start.date}T00:00:00+00:00`;
|
|
322
345
|
}
|
|
323
346
|
if (!endRaw && event.end?.date) {
|
|
324
|
-
endRaw = `${event.end.date}T00:00:
|
|
347
|
+
endRaw = `${event.end.date}T00:00:00+00:00`;
|
|
325
348
|
}
|
|
326
349
|
if (!startRaw || !endRaw) {
|
|
327
350
|
return null;
|
|
@@ -332,8 +355,8 @@ function toTaskDateRange(event) {
|
|
|
332
355
|
return null;
|
|
333
356
|
}
|
|
334
357
|
return {
|
|
335
|
-
startDateTime: start
|
|
336
|
-
endDateTime: end
|
|
358
|
+
startDateTime: toTimesheetDateTime(start),
|
|
359
|
+
endDateTime: toTimesheetDateTime(end)
|
|
337
360
|
};
|
|
338
361
|
}
|
|
339
362
|
function buildGoogleEventPayload(task) {
|
|
@@ -392,14 +415,21 @@ function createClient(context) {
|
|
|
392
415
|
refreshAccessToken: () => context.credentials.refreshToken('google')
|
|
393
416
|
});
|
|
394
417
|
}
|
|
418
|
+
function getOrCreateClient(context) {
|
|
419
|
+
if (!sharedClient) {
|
|
420
|
+
sharedClient = createClient(context);
|
|
421
|
+
}
|
|
422
|
+
return sharedClient;
|
|
423
|
+
}
|
|
395
424
|
async function loadTask(taskId, input, context) {
|
|
425
|
+
// Prefer inline item data from sync change — avoids an API round-trip
|
|
426
|
+
if (input?.item && typeof input.item === 'object' && input.item.id) {
|
|
427
|
+
return input.item;
|
|
428
|
+
}
|
|
396
429
|
try {
|
|
397
430
|
return await context.data.getTask(taskId);
|
|
398
431
|
}
|
|
399
432
|
catch {
|
|
400
|
-
if (input?.item && typeof input.item === 'object') {
|
|
401
|
-
return input.item;
|
|
402
|
-
}
|
|
403
433
|
return null;
|
|
404
434
|
}
|
|
405
435
|
}
|
|
@@ -493,6 +523,10 @@ async function ensureWatchChannels(context) {
|
|
|
493
523
|
}
|
|
494
524
|
}
|
|
495
525
|
}
|
|
526
|
+
/** Format a Date to the offset format the Timesheet backend expects: yyyy-MM-dd'T'HH:mm:ss+00:00 */
|
|
527
|
+
function toTimesheetDateTime(date) {
|
|
528
|
+
return date.toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
529
|
+
}
|
|
496
530
|
function readMetadataString(metadata, key) {
|
|
497
531
|
const value = metadata[key];
|
|
498
532
|
if (typeof value === 'string' && value.trim().length > 0) {
|
package/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "google-calendar-sync",
|
|
3
3
|
"name": "Google Calendar Sync",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.1",
|
|
5
5
|
"description": "Synchronize Timesheet tasks with Google Calendar",
|
|
6
6
|
"longDescription": "Bidirectional synchronization between Timesheet tasks and Google Calendar events.",
|
|
7
7
|
"icon": "google-calendar",
|