google-tools-mcp 1.2.11 → 2.0.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/LICENSE +21 -21
- package/README.md +555 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +222 -21
- package/dist/markdown-transformer/index.js +19 -9
- package/dist/markdown-transformer/markdownToDocs.js +458 -28
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +13 -1
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +212 -206
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- package/package.json +82 -80
package/dist/cachedToolsList.js
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
// Precompute tools/list payload to avoid repeated toJsonSchema() calls.
|
|
2
|
-
// Supports rebuilding the cache when new tools are dynamically loaded.
|
|
3
|
-
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
-
import { toJsonSchema } from 'xsschema';
|
|
5
|
-
import { logger } from './logger.js';
|
|
6
|
-
|
|
7
|
-
export function collectToolsWhileRegistering(server, out) {
|
|
8
|
-
const add = server.addTool.bind(server);
|
|
9
|
-
server.addTool = (tool) => {
|
|
10
|
-
out.push(tool);
|
|
11
|
-
add(tool);
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function buildCachedToolsListPayload(tools) {
|
|
16
|
-
return {
|
|
17
|
-
tools: await Promise.all(tools.map(async (tool) => ({
|
|
18
|
-
annotations: tool.annotations,
|
|
19
|
-
description: tool.description,
|
|
20
|
-
inputSchema: tool.parameters
|
|
21
|
-
? await toJsonSchema(tool.parameters)
|
|
22
|
-
: {
|
|
23
|
-
additionalProperties: false,
|
|
24
|
-
properties: {},
|
|
25
|
-
type: 'object',
|
|
26
|
-
},
|
|
27
|
-
name: tool.name,
|
|
28
|
-
}))),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function installCachedToolsListHandler(server, registeredTools) {
|
|
33
|
-
const session = server.sessions[0];
|
|
34
|
-
if (!session) {
|
|
35
|
-
logger.warn('No MCP session; skipping tools/list cache install.');
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Build the initial cache from whatever tools are registered at startup
|
|
40
|
-
let cachedPayload = null;
|
|
41
|
-
|
|
42
|
-
session.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
43
|
-
// Rebuild cache when tool count changes (new tools dynamically loaded)
|
|
44
|
-
if (!cachedPayload || cachedPayload.tools.length !== registeredTools.length) {
|
|
45
|
-
logger.debug(`Rebuilding tools/list cache (${registeredTools.length} tools)...`);
|
|
46
|
-
cachedPayload = await buildCachedToolsListPayload(registeredTools);
|
|
47
|
-
}
|
|
48
|
-
return cachedPayload;
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
logger.debug(`Installed dynamic tools/list cache handler (${registeredTools.length} tools initially).`);
|
|
52
|
-
}
|
|
1
|
+
// Precompute tools/list payload to avoid repeated toJsonSchema() calls.
|
|
2
|
+
// Supports rebuilding the cache when new tools are dynamically loaded.
|
|
3
|
+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { toJsonSchema } from 'xsschema';
|
|
5
|
+
import { logger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
export function collectToolsWhileRegistering(server, out) {
|
|
8
|
+
const add = server.addTool.bind(server);
|
|
9
|
+
server.addTool = (tool) => {
|
|
10
|
+
out.push(tool);
|
|
11
|
+
add(tool);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function buildCachedToolsListPayload(tools) {
|
|
16
|
+
return {
|
|
17
|
+
tools: await Promise.all(tools.map(async (tool) => ({
|
|
18
|
+
annotations: tool.annotations,
|
|
19
|
+
description: tool.description,
|
|
20
|
+
inputSchema: tool.parameters
|
|
21
|
+
? await toJsonSchema(tool.parameters)
|
|
22
|
+
: {
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {},
|
|
25
|
+
type: 'object',
|
|
26
|
+
},
|
|
27
|
+
name: tool.name,
|
|
28
|
+
}))),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function installCachedToolsListHandler(server, registeredTools) {
|
|
33
|
+
const session = server.sessions[0];
|
|
34
|
+
if (!session) {
|
|
35
|
+
logger.warn('No MCP session; skipping tools/list cache install.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Build the initial cache from whatever tools are registered at startup
|
|
40
|
+
let cachedPayload = null;
|
|
41
|
+
|
|
42
|
+
session.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
43
|
+
// Rebuild cache when tool count changes (new tools dynamically loaded)
|
|
44
|
+
if (!cachedPayload || cachedPayload.tools.length !== registeredTools.length) {
|
|
45
|
+
logger.debug(`Rebuilding tools/list cache (${registeredTools.length} tools)...`);
|
|
46
|
+
cachedPayload = await buildCachedToolsListPayload(registeredTools);
|
|
47
|
+
}
|
|
48
|
+
return cachedPayload;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
logger.debug(`Installed dynamic tools/list cache handler (${registeredTools.length} tools initially).`);
|
|
52
|
+
}
|
|
@@ -4,7 +4,7 @@ import { logger } from './logger.js';
|
|
|
4
4
|
// --- Constants ---
|
|
5
5
|
const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
|
|
6
6
|
// --- Core Helper to Execute Batch Updates ---
|
|
7
|
-
export async function executeBatchUpdate(docs, documentId, requests) {
|
|
7
|
+
export async function executeBatchUpdate(docs, documentId, requests, writeControl) {
|
|
8
8
|
if (!requests || requests.length === 0) {
|
|
9
9
|
// console.warn("executeBatchUpdate called with no requests.");
|
|
10
10
|
return {}; // Nothing to do
|
|
@@ -16,13 +16,25 @@ export async function executeBatchUpdate(docs, documentId, requests) {
|
|
|
16
16
|
try {
|
|
17
17
|
const response = await docs.documents.batchUpdate({
|
|
18
18
|
documentId: documentId,
|
|
19
|
-
requestBody: { requests },
|
|
19
|
+
requestBody: { requests, ...(writeControl && { writeControl }) },
|
|
20
20
|
});
|
|
21
21
|
return response.data;
|
|
22
22
|
}
|
|
23
23
|
catch (error) {
|
|
24
24
|
logger.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
|
|
25
25
|
// Translate common API errors to UserErrors
|
|
26
|
+
const apiMessage = error.response?.data?.error?.message || error.message || '';
|
|
27
|
+
const apiStatus = error.response?.data?.error?.status;
|
|
28
|
+
// A write sent with writeControl that fails on the revision is a
|
|
29
|
+
// concurrency conflict. Don't rely on message wording alone —
|
|
30
|
+
// FAILED_PRECONDITION is Google's canonical status for this.
|
|
31
|
+
const isRevisionConflict = writeControl && (
|
|
32
|
+
apiStatus === 'FAILED_PRECONDITION' ||
|
|
33
|
+
((error.code === 400 || error.code === 409) && /revision|write\s*control|updated since/i.test(apiMessage))
|
|
34
|
+
);
|
|
35
|
+
if (isRevisionConflict) {
|
|
36
|
+
throw new UserError(`This document (${documentId}) changed since you last read it. Read the document again before editing to ensure you have current content.`);
|
|
37
|
+
}
|
|
26
38
|
if (error.code === 400 && error.message.includes('Invalid requests')) {
|
|
27
39
|
// Try to extract more specific info if available
|
|
28
40
|
const details = error.response?.data?.error?.details;
|
|
@@ -40,6 +52,40 @@ export async function executeBatchUpdate(docs, documentId, requests) {
|
|
|
40
52
|
throw new Error(`Google API Error (${error.code}): ${error.message}`);
|
|
41
53
|
}
|
|
42
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Creates a small stateful helper for chaining an optimistic-concurrency guard
|
|
57
|
+
* across a sequence of writes that make up a single logical operation (e.g.
|
|
58
|
+
* delete -> cleanup -> insert). The first write carries the revision from the
|
|
59
|
+
* caller's last read; each subsequent write must require the revision the
|
|
60
|
+
* previous write produced (returned as `writeControl` on a successful
|
|
61
|
+
* batchUpdate response), so a collaborator edit landing between any two of
|
|
62
|
+
* our own batches is rejected as a conflict instead of silently applied
|
|
63
|
+
* against (PR #42 review).
|
|
64
|
+
*
|
|
65
|
+
* Guarding is opt-in: when `revisionId` is null/undefined (a legacy read that
|
|
66
|
+
* never captured a revision), `current` stays undefined for the life of the
|
|
67
|
+
* chain and `advance` is a no-op, so the flow remains unguarded.
|
|
68
|
+
*
|
|
69
|
+
* @param revisionId - The revisionId from the caller's last tracked read, or null/undefined
|
|
70
|
+
* @returns { get current(), advance(response) }
|
|
71
|
+
*/
|
|
72
|
+
export function createWriteControlChain(revisionId) {
|
|
73
|
+
let pendingWriteControl = revisionId ? { requiredRevisionId: revisionId } : undefined;
|
|
74
|
+
return {
|
|
75
|
+
get current() {
|
|
76
|
+
return pendingWriteControl;
|
|
77
|
+
},
|
|
78
|
+
// Advance the chain to the revision produced by a successful write.
|
|
79
|
+
// Only advances when the chain is armed and the response carried a new
|
|
80
|
+
// writeControl — a best-effort write that fails (and is swallowed by the
|
|
81
|
+
// caller) must NOT advance the chain, since the document was not modified.
|
|
82
|
+
advance(response) {
|
|
83
|
+
if (pendingWriteControl && response?.writeControl) {
|
|
84
|
+
pendingWriteControl = response.writeControl;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
43
89
|
/**
|
|
44
90
|
* Executes batch updates with automatic splitting for large request arrays.
|
|
45
91
|
* Separates insert and format operations, executing inserts first.
|
|
@@ -50,7 +96,7 @@ export async function executeBatchUpdate(docs, documentId, requests) {
|
|
|
50
96
|
* @param log - Optional logger for progress tracking
|
|
51
97
|
* @returns Metadata about the execution (request counts, API calls, timing)
|
|
52
98
|
*/
|
|
53
|
-
export async function executeBatchUpdateWithSplitting(docs, documentId, requests, log) {
|
|
99
|
+
export async function executeBatchUpdateWithSplitting(docs, documentId, requests, log, writeControl) {
|
|
54
100
|
const overallStart = performance.now();
|
|
55
101
|
if (!requests || requests.length === 0) {
|
|
56
102
|
return {
|
|
@@ -80,6 +126,21 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
80
126
|
'insertInlineImage' in r ||
|
|
81
127
|
'insertSectionBreak' in r));
|
|
82
128
|
let totalApiCalls = 0;
|
|
129
|
+
// Chain the optimistic-concurrency guard across every batch this operation
|
|
130
|
+
// sends. When markdown is split into delete/insert/format phases (or a phase
|
|
131
|
+
// exceeds 50 requests), each successful batchUpdate returns the document's new
|
|
132
|
+
// head revision in its writeControl. Requiring that revision on the next batch
|
|
133
|
+
// means a collaborator edit landing between our batches is rejected as a
|
|
134
|
+
// conflict instead of having our precomputed ranges applied to their content
|
|
135
|
+
// (PR #42 review). Only chain when we started guarded, so legacy flows that
|
|
136
|
+
// never captured a revision stay unguarded.
|
|
137
|
+
let chainedWriteControl = writeControl;
|
|
138
|
+
const executeBatch = async (batch) => {
|
|
139
|
+
const data = await executeBatchUpdate(docs, documentId, batch, chainedWriteControl);
|
|
140
|
+
if (chainedWriteControl && data?.writeControl) {
|
|
141
|
+
chainedWriteControl = data.writeControl;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
83
144
|
// Execute delete batches first (must happen before inserts)
|
|
84
145
|
const deleteStart = performance.now();
|
|
85
146
|
if (deleteRequests.length > 0) {
|
|
@@ -91,7 +152,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
91
152
|
if (log) {
|
|
92
153
|
log.info(`Delete batch content: ${JSON.stringify(batch)}`);
|
|
93
154
|
}
|
|
94
|
-
await
|
|
155
|
+
await executeBatch(batch);
|
|
95
156
|
totalApiCalls++;
|
|
96
157
|
if (log) {
|
|
97
158
|
const batchNum = Math.floor(i / MAX_BATCH) + 1;
|
|
@@ -109,7 +170,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
109
170
|
if (insertRequests.length > 0) {
|
|
110
171
|
for (let i = 0; i < insertRequests.length; i += MAX_BATCH) {
|
|
111
172
|
const batch = insertRequests.slice(i, i + MAX_BATCH);
|
|
112
|
-
await
|
|
173
|
+
await executeBatch(batch);
|
|
113
174
|
totalApiCalls++;
|
|
114
175
|
if (log) {
|
|
115
176
|
const batchNum = Math.floor(i / MAX_BATCH) + 1;
|
|
@@ -124,7 +185,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
124
185
|
if (formatRequests.length > 0) {
|
|
125
186
|
for (let i = 0; i < formatRequests.length; i += MAX_BATCH) {
|
|
126
187
|
const batch = formatRequests.slice(i, i + MAX_BATCH);
|
|
127
|
-
await
|
|
188
|
+
await executeBatch(batch);
|
|
128
189
|
totalApiCalls++;
|
|
129
190
|
if (log) {
|
|
130
191
|
const batchNum = Math.floor(i / MAX_BATCH) + 1;
|
|
@@ -156,6 +217,9 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
|
|
|
156
217
|
},
|
|
157
218
|
totalApiCalls,
|
|
158
219
|
totalElapsedMs: Math.round(totalElapsedMs),
|
|
220
|
+
// The revision guard after the last batch, so a caller that writes again
|
|
221
|
+
// after this call can keep the chain intact.
|
|
222
|
+
finalWriteControl: chainedWriteControl,
|
|
159
223
|
};
|
|
160
224
|
}
|
|
161
225
|
// --- Text Finding Helper ---
|
|
@@ -676,7 +740,7 @@ export function buildUpdateParagraphStyleRequest(startIndex, endIndex, style, ta
|
|
|
676
740
|
return { request, fields: fieldsToUpdate };
|
|
677
741
|
}
|
|
678
742
|
// --- Specific Feature Helpers ---
|
|
679
|
-
export async function createTable(docs, documentId, rows, columns, index, tabId) {
|
|
743
|
+
export async function createTable(docs, documentId, rows, columns, index, tabId, writeControl) {
|
|
680
744
|
if (rows < 1 || columns < 1) {
|
|
681
745
|
throw new UserError('Table must have at least 1 row and 1 column.');
|
|
682
746
|
}
|
|
@@ -691,7 +755,7 @@ export async function createTable(docs, documentId, rows, columns, index, tabId)
|
|
|
691
755
|
columns: columns,
|
|
692
756
|
},
|
|
693
757
|
};
|
|
694
|
-
return executeBatchUpdate(docs, documentId, [request]);
|
|
758
|
+
return executeBatchUpdate(docs, documentId, [request], writeControl);
|
|
695
759
|
}
|
|
696
760
|
export async function insertText(docs, documentId, text, index) {
|
|
697
761
|
if (!text)
|
|
@@ -824,7 +888,7 @@ export async function addCommentHelper(docs, documentId, text, startIndex, endIn
|
|
|
824
888
|
* @param height - Optional height in points
|
|
825
889
|
* @returns Promise with batch update response
|
|
826
890
|
*/
|
|
827
|
-
export async function insertInlineImage(docs, documentId, imageUrl, index, width, height, tabId) {
|
|
891
|
+
export async function insertInlineImage(docs, documentId, imageUrl, index, width, height, tabId, writeControl) {
|
|
828
892
|
// Validate URL format
|
|
829
893
|
try {
|
|
830
894
|
new URL(imageUrl);
|
|
@@ -850,7 +914,7 @@ export async function insertInlineImage(docs, documentId, imageUrl, index, width
|
|
|
850
914
|
}),
|
|
851
915
|
},
|
|
852
916
|
};
|
|
853
|
-
return executeBatchUpdate(docs, documentId, [request]);
|
|
917
|
+
return executeBatchUpdate(docs, documentId, [request], writeControl);
|
|
854
918
|
}
|
|
855
919
|
/**
|
|
856
920
|
* Uploads a local image file to Google Drive.
|
|
@@ -933,14 +997,14 @@ localFilePath, parentFolderId, skipPublicSharing = false) {
|
|
|
933
997
|
* with the actual image blob from Drive (no public sharing needed).
|
|
934
998
|
*/
|
|
935
999
|
export async function insertImageViaAppsScript(docs, scriptClient, // script_v1.Script type
|
|
936
|
-
deploymentId, documentId, driveFileId, charIndex, tabId) {
|
|
1000
|
+
deploymentId, documentId, driveFileId, charIndex, tabId, writeControl) {
|
|
937
1001
|
const marker = `[mcp-img-${driveFileId}]`;
|
|
938
1002
|
// Step 1: Insert marker at the requested position via Docs API
|
|
939
1003
|
const location = { index: charIndex };
|
|
940
1004
|
if (tabId) {
|
|
941
1005
|
location.tabId = tabId;
|
|
942
1006
|
}
|
|
943
|
-
await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }]);
|
|
1007
|
+
await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }], writeControl);
|
|
944
1008
|
// Step 2: Call Apps Script to replace the marker with the image
|
|
945
1009
|
const response = await scriptClient.scripts.run({
|
|
946
1010
|
scriptId: deploymentId,
|