@triffon/google-docs-mcp 1.11.3-triffon

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.
Files changed (165) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +567 -0
  3. package/dist/auth.js +327 -0
  4. package/dist/cachedToolsList.js +38 -0
  5. package/dist/clients.js +180 -0
  6. package/dist/config.js +9 -0
  7. package/dist/downloadProxy.js +82 -0
  8. package/dist/driveQueryUtils.js +7 -0
  9. package/dist/firestoreTokenStorage.js +32 -0
  10. package/dist/googleDocsApiHelpers.js +1257 -0
  11. package/dist/googleSheetsApiHelpers.js +849 -0
  12. package/dist/index.js +240 -0
  13. package/dist/landingPage.js +103 -0
  14. package/dist/logger.js +58 -0
  15. package/dist/markdown-transformer/docsToMarkdown.js +259 -0
  16. package/dist/markdown-transformer/index.js +128 -0
  17. package/dist/markdown-transformer/markdownToDocs.js +834 -0
  18. package/dist/remoteWrapper.js +72 -0
  19. package/dist/tools/calendar/createEvent.js +89 -0
  20. package/dist/tools/calendar/deleteEvent.js +49 -0
  21. package/dist/tools/calendar/helpers.js +19 -0
  22. package/dist/tools/calendar/index.js +12 -0
  23. package/dist/tools/calendar/listEvents.js +84 -0
  24. package/dist/tools/calendar/quickAddEvent.js +55 -0
  25. package/dist/tools/calendar/updateEvent.js +84 -0
  26. package/dist/tools/docs/addTab.js +84 -0
  27. package/dist/tools/docs/appendTableRows.js +89 -0
  28. package/dist/tools/docs/appendToGoogleDoc.js +85 -0
  29. package/dist/tools/docs/cloneTable.js +159 -0
  30. package/dist/tools/docs/comments/addComment.js +83 -0
  31. package/dist/tools/docs/comments/deleteComment.js +30 -0
  32. package/dist/tools/docs/comments/getComment.js +45 -0
  33. package/dist/tools/docs/comments/index.js +14 -0
  34. package/dist/tools/docs/comments/listComments.js +43 -0
  35. package/dist/tools/docs/comments/replyToComment.js +35 -0
  36. package/dist/tools/docs/comments/resolveComment.js +55 -0
  37. package/dist/tools/docs/deleteRange.js +61 -0
  38. package/dist/tools/docs/deleteTableRows.js +62 -0
  39. package/dist/tools/docs/findAndReplace.js +54 -0
  40. package/dist/tools/docs/findElement.js +43 -0
  41. package/dist/tools/docs/findSectionsByHeading.js +46 -0
  42. package/dist/tools/docs/formatting/applyParagraphStyle.js +83 -0
  43. package/dist/tools/docs/formatting/applyTextStyle.js +49 -0
  44. package/dist/tools/docs/formatting/batchApplyTextStyle.js +86 -0
  45. package/dist/tools/docs/formatting/index.js +16 -0
  46. package/dist/tools/docs/formatting/updateTableBorders.js +81 -0
  47. package/dist/tools/docs/formatting/updateTableCellStyle.js +85 -0
  48. package/dist/tools/docs/formatting/updateTableColumnWidth.js +51 -0
  49. package/dist/tools/docs/formatting/updateTableRowStyle.js +74 -0
  50. package/dist/tools/docs/getTableStructure.js +48 -0
  51. package/dist/tools/docs/index.js +66 -0
  52. package/dist/tools/docs/insertDateChip.js +82 -0
  53. package/dist/tools/docs/insertImage.js +112 -0
  54. package/dist/tools/docs/insertPageBreak.js +47 -0
  55. package/dist/tools/docs/insertPerson.js +53 -0
  56. package/dist/tools/docs/insertRichLink.js +58 -0
  57. package/dist/tools/docs/insertSectionBreak.js +60 -0
  58. package/dist/tools/docs/insertTable.js +42 -0
  59. package/dist/tools/docs/insertTableWithData.js +125 -0
  60. package/dist/tools/docs/insertText.js +49 -0
  61. package/dist/tools/docs/listDocumentTables.js +47 -0
  62. package/dist/tools/docs/listDocumentTabs.js +59 -0
  63. package/dist/tools/docs/listSmartChips.js +41 -0
  64. package/dist/tools/docs/modifyText.js +147 -0
  65. package/dist/tools/docs/readGoogleDoc.js +164 -0
  66. package/dist/tools/docs/renameTab.js +47 -0
  67. package/dist/tools/docs/replaceTableRowData.js +55 -0
  68. package/dist/tools/docs/smartChipHelpers.js +71 -0
  69. package/dist/tools/docs/structureHelpers.js +250 -0
  70. package/dist/tools/docs/tabFieldMasks.js +48 -0
  71. package/dist/tools/docs/tableRowDataHelpers.js +53 -0
  72. package/dist/tools/docs/updateSectionStyle.js +148 -0
  73. package/dist/tools/drive/copyFile.js +63 -0
  74. package/dist/tools/drive/createDocument.js +105 -0
  75. package/dist/tools/drive/createFolder.js +48 -0
  76. package/dist/tools/drive/createFromTemplate.js +82 -0
  77. package/dist/tools/drive/deleteFile.js +72 -0
  78. package/dist/tools/drive/downloadFile.js +266 -0
  79. package/dist/tools/drive/getDocumentInfo.js +48 -0
  80. package/dist/tools/drive/getFolderInfo.js +48 -0
  81. package/dist/tools/drive/index.js +34 -0
  82. package/dist/tools/drive/listDriveFiles.js +129 -0
  83. package/dist/tools/drive/listFolderContents.js +83 -0
  84. package/dist/tools/drive/listGoogleDocs.js +70 -0
  85. package/dist/tools/drive/moveFile.js +54 -0
  86. package/dist/tools/drive/renameFile.js +39 -0
  87. package/dist/tools/drive/savePathGuard.js +86 -0
  88. package/dist/tools/drive/searchDriveFiles.js +148 -0
  89. package/dist/tools/drive/searchGoogleDocs.js +77 -0
  90. package/dist/tools/drive/setFilePermission.js +68 -0
  91. package/dist/tools/gmail/createDraft.js +58 -0
  92. package/dist/tools/gmail/deleteDraft.js +37 -0
  93. package/dist/tools/gmail/getDraft.js +52 -0
  94. package/dist/tools/gmail/getMessage.js +92 -0
  95. package/dist/tools/gmail/helpers.js +113 -0
  96. package/dist/tools/gmail/index.js +28 -0
  97. package/dist/tools/gmail/listDrafts.js +74 -0
  98. package/dist/tools/gmail/listLabels.js +31 -0
  99. package/dist/tools/gmail/listMessages.js +87 -0
  100. package/dist/tools/gmail/modifyMessageLabels.js +54 -0
  101. package/dist/tools/gmail/sendDraft.js +42 -0
  102. package/dist/tools/gmail/sendEmail.js +56 -0
  103. package/dist/tools/gmail/trashMessage.js +37 -0
  104. package/dist/tools/gmail/triageInbox.js +147 -0
  105. package/dist/tools/gmail/updateDraft.js +60 -0
  106. package/dist/tools/index.js +64 -0
  107. package/dist/tools/script/appsScriptShared.js +96 -0
  108. package/dist/tools/script/createAppsScriptProject.js +74 -0
  109. package/dist/tools/script/getAppsScriptContent.js +56 -0
  110. package/dist/tools/script/index.js +14 -0
  111. package/dist/tools/script/updateAppsScriptContent.js +56 -0
  112. package/dist/tools/sheets/addConditionalFormatting.js +143 -0
  113. package/dist/tools/sheets/addSpreadsheetSheet.js +34 -0
  114. package/dist/tools/sheets/appendSpreadsheetRows.js +44 -0
  115. package/dist/tools/sheets/appendTableRows.js +51 -0
  116. package/dist/tools/sheets/autoResizeColumns.js +67 -0
  117. package/dist/tools/sheets/autoResizeRows.js +63 -0
  118. package/dist/tools/sheets/batchWrite.js +61 -0
  119. package/dist/tools/sheets/clearSpreadsheetRange.js +31 -0
  120. package/dist/tools/sheets/comments/commentAnchor.js +95 -0
  121. package/dist/tools/sheets/comments/createSheetsCellNote.js +33 -0
  122. package/dist/tools/sheets/comments/createSheetsComment.js +168 -0
  123. package/dist/tools/sheets/comments/deleteSheetsComment.js +32 -0
  124. package/dist/tools/sheets/comments/getSheetsComment.js +51 -0
  125. package/dist/tools/sheets/comments/index.js +16 -0
  126. package/dist/tools/sheets/comments/listSheetsComments.js +177 -0
  127. package/dist/tools/sheets/comments/replyToSheetsComment.js +37 -0
  128. package/dist/tools/sheets/comments/resolveSheetsComment.js +53 -0
  129. package/dist/tools/sheets/copyFormatting.js +59 -0
  130. package/dist/tools/sheets/copySheetTo.js +36 -0
  131. package/dist/tools/sheets/createSpreadsheet.js +72 -0
  132. package/dist/tools/sheets/createTable.js +120 -0
  133. package/dist/tools/sheets/deleteChart.js +41 -0
  134. package/dist/tools/sheets/deleteConditionalFormatting.js +46 -0
  135. package/dist/tools/sheets/deleteSheet.js +43 -0
  136. package/dist/tools/sheets/deleteTable.js +56 -0
  137. package/dist/tools/sheets/duplicateSheet.js +53 -0
  138. package/dist/tools/sheets/formatCells.js +122 -0
  139. package/dist/tools/sheets/freezeRowsAndColumns.js +58 -0
  140. package/dist/tools/sheets/getConditionalFormatting.js +98 -0
  141. package/dist/tools/sheets/getSpreadsheetInfo.js +44 -0
  142. package/dist/tools/sheets/getTable.js +48 -0
  143. package/dist/tools/sheets/groupRows.js +62 -0
  144. package/dist/tools/sheets/index.js +84 -0
  145. package/dist/tools/sheets/insertChart.js +225 -0
  146. package/dist/tools/sheets/listGoogleSheets.js +66 -0
  147. package/dist/tools/sheets/listTables.js +55 -0
  148. package/dist/tools/sheets/protectRange.js +59 -0
  149. package/dist/tools/sheets/readCellFormat.js +143 -0
  150. package/dist/tools/sheets/readSpreadsheet.js +36 -0
  151. package/dist/tools/sheets/renameSheet.js +48 -0
  152. package/dist/tools/sheets/setCellBorders.js +94 -0
  153. package/dist/tools/sheets/setColumnWidths.js +43 -0
  154. package/dist/tools/sheets/setDropdownValidation.js +51 -0
  155. package/dist/tools/sheets/setRowHeights.js +63 -0
  156. package/dist/tools/sheets/ungroupAllRows.js +66 -0
  157. package/dist/tools/sheets/updateTableRange.js +51 -0
  158. package/dist/tools/sheets/writeSpreadsheet.js +45 -0
  159. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +95 -0
  160. package/dist/tools/utils/index.js +8 -0
  161. package/dist/tools/utils/replaceDocumentWithMarkdown.js +161 -0
  162. package/dist/tools/utils/replaceRangeWithMarkdown.js +72 -0
  163. package/dist/types.js +208 -0
  164. package/dist/upstreamAuth.js +62 -0
  165. package/package.json +51 -0
@@ -0,0 +1,1257 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { hexToRgbColor, NotImplementedError } from './types.js';
3
+ import { logger } from './logger.js';
4
+ import { buildTabsFieldMask } from './tools/docs/tabFieldMasks.js';
5
+ // --- Constants ---
6
+ const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
7
+ // --- Core Helper to Execute Batch Updates ---
8
+ export async function executeBatchUpdate(docs, documentId, requests) {
9
+ if (!requests || requests.length === 0) {
10
+ // console.warn("executeBatchUpdate called with no requests.");
11
+ return {}; // Nothing to do
12
+ }
13
+ // TODO: Consider splitting large request arrays into multiple batches if needed
14
+ if (requests.length > MAX_BATCH_UPDATE_REQUESTS) {
15
+ logger.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`);
16
+ }
17
+ try {
18
+ const response = await docs.documents.batchUpdate({
19
+ documentId: documentId,
20
+ requestBody: { requests },
21
+ });
22
+ return response.data;
23
+ }
24
+ catch (error) {
25
+ logger.error(`Google API batchUpdate Error for doc ${documentId}: ${error.message || 'Unknown error'}`);
26
+ // Translate common API errors to UserErrors
27
+ if (error.code === 400 && error.message.includes('Invalid requests')) {
28
+ // Try to extract more specific info if available
29
+ const details = error.response?.data?.error?.details;
30
+ let detailMsg = '';
31
+ if (details && Array.isArray(details)) {
32
+ detailMsg = details.map((d) => d.description || JSON.stringify(d)).join('; ');
33
+ }
34
+ throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`);
35
+ }
36
+ if (error.code === 404)
37
+ throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
38
+ if (error.code === 403)
39
+ throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
40
+ // Generic internal error for others
41
+ throw new Error(`Google API Error (${error.code}): ${error.message}`);
42
+ }
43
+ }
44
+ /**
45
+ * Executes batch updates with automatic splitting for large request arrays.
46
+ * Separates insert and format operations, executing inserts first.
47
+ *
48
+ * @param docs - The Google Docs client
49
+ * @param documentId - The document ID
50
+ * @param requests - Array of requests to execute
51
+ * @param log - Optional logger for progress tracking
52
+ * @returns Metadata about the execution (request counts, API calls, timing)
53
+ */
54
+ export async function executeBatchUpdateWithSplitting(docs, documentId, requests, log) {
55
+ const overallStart = performance.now();
56
+ if (!requests || requests.length === 0) {
57
+ return {
58
+ totalRequests: 0,
59
+ phases: {
60
+ delete: { requests: 0, apiCalls: 0, elapsedMs: 0 },
61
+ insert: { requests: 0, apiCalls: 0, elapsedMs: 0 },
62
+ format: { requests: 0, apiCalls: 0, elapsedMs: 0 },
63
+ },
64
+ totalApiCalls: 0,
65
+ totalElapsedMs: 0,
66
+ };
67
+ }
68
+ const MAX_BATCH = MAX_BATCH_UPDATE_REQUESTS;
69
+ // Separate requests into three categories
70
+ // Order of execution: delete → insert → format
71
+ const deleteRequests = requests.filter((r) => 'deleteContentRange' in r);
72
+ const insertRequests = requests.filter((r) => 'insertText' in r ||
73
+ 'insertTable' in r ||
74
+ 'insertPageBreak' in r ||
75
+ 'insertInlineImage' in r ||
76
+ 'insertSectionBreak' in r);
77
+ const formatRequests = requests.filter((r) => !('deleteContentRange' in r) &&
78
+ !('insertText' in r ||
79
+ 'insertTable' in r ||
80
+ 'insertPageBreak' in r ||
81
+ 'insertInlineImage' in r ||
82
+ 'insertSectionBreak' in r));
83
+ let totalApiCalls = 0;
84
+ // Execute delete batches first (must happen before inserts)
85
+ const deleteStart = performance.now();
86
+ if (deleteRequests.length > 0) {
87
+ if (log) {
88
+ log.info(`Executing ${deleteRequests.length} delete requests FIRST (in separate API call)`);
89
+ }
90
+ for (let i = 0; i < deleteRequests.length; i += MAX_BATCH) {
91
+ const batch = deleteRequests.slice(i, i + MAX_BATCH);
92
+ if (log) {
93
+ log.info(`Delete batch content: ${JSON.stringify(batch)}`);
94
+ }
95
+ await executeBatchUpdate(docs, documentId, batch);
96
+ totalApiCalls++;
97
+ if (log) {
98
+ const batchNum = Math.floor(i / MAX_BATCH) + 1;
99
+ const totalBatches = Math.ceil(deleteRequests.length / MAX_BATCH);
100
+ log.info(`Executed delete batch ${batchNum}/${totalBatches} (${batch.length} requests)`);
101
+ }
102
+ }
103
+ if (log) {
104
+ log.info(`Delete batches complete. Document should now be empty (except section break).`);
105
+ }
106
+ }
107
+ const deleteElapsed = performance.now() - deleteStart;
108
+ // Then execute insert batches
109
+ const insertStart = performance.now();
110
+ if (insertRequests.length > 0) {
111
+ for (let i = 0; i < insertRequests.length; i += MAX_BATCH) {
112
+ const batch = insertRequests.slice(i, i + MAX_BATCH);
113
+ await executeBatchUpdate(docs, documentId, batch);
114
+ totalApiCalls++;
115
+ if (log) {
116
+ const batchNum = Math.floor(i / MAX_BATCH) + 1;
117
+ const totalBatches = Math.ceil(insertRequests.length / MAX_BATCH);
118
+ log.info(`Executed insert batch ${batchNum}/${totalBatches} (${batch.length} requests)`);
119
+ }
120
+ }
121
+ }
122
+ const insertElapsed = performance.now() - insertStart;
123
+ // Finally execute format batches
124
+ const formatStart = performance.now();
125
+ if (formatRequests.length > 0) {
126
+ for (let i = 0; i < formatRequests.length; i += MAX_BATCH) {
127
+ const batch = formatRequests.slice(i, i + MAX_BATCH);
128
+ await executeBatchUpdate(docs, documentId, batch);
129
+ totalApiCalls++;
130
+ if (log) {
131
+ const batchNum = Math.floor(i / MAX_BATCH) + 1;
132
+ const totalBatches = Math.ceil(formatRequests.length / MAX_BATCH);
133
+ log.info(`Executed format batch ${batchNum}/${totalBatches} (${batch.length} requests)`);
134
+ }
135
+ }
136
+ }
137
+ const formatElapsed = performance.now() - formatStart;
138
+ const totalElapsedMs = performance.now() - overallStart;
139
+ return {
140
+ totalRequests: requests.length,
141
+ phases: {
142
+ delete: {
143
+ requests: deleteRequests.length,
144
+ apiCalls: Math.ceil(deleteRequests.length / MAX_BATCH) || 0,
145
+ elapsedMs: Math.round(deleteElapsed),
146
+ },
147
+ insert: {
148
+ requests: insertRequests.length,
149
+ apiCalls: Math.ceil(insertRequests.length / MAX_BATCH) || 0,
150
+ elapsedMs: Math.round(insertElapsed),
151
+ },
152
+ format: {
153
+ requests: formatRequests.length,
154
+ apiCalls: Math.ceil(formatRequests.length / MAX_BATCH) || 0,
155
+ elapsedMs: Math.round(formatElapsed),
156
+ },
157
+ },
158
+ totalApiCalls,
159
+ totalElapsedMs: Math.round(totalElapsedMs),
160
+ };
161
+ }
162
+ // --- Text Finding Helper ---
163
+ // This improved version is more robust in handling various text structure scenarios
164
+ export async function findTextRange(docs, documentId, textToFind, instance = 1, tabId) {
165
+ try {
166
+ // Request more detailed information about the document structure
167
+ // When tabId is specified, we need to use includeTabsContent to access tab-specific content
168
+ const needsTabsContent = !!tabId;
169
+ const res = await docs.documents.get({
170
+ documentId,
171
+ ...(needsTabsContent && { includeTabsContent: true }),
172
+ // Request more fields to handle various container types (not just paragraphs)
173
+ fields: needsTabsContent
174
+ ? buildTabsFieldMask('documentTab(body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex)))')
175
+ : 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
176
+ });
177
+ // Get body content from the correct tab or default
178
+ let bodyContent;
179
+ if (tabId) {
180
+ const targetTab = findTabById(res.data, tabId);
181
+ if (!targetTab) {
182
+ throw new UserError(`Tab with ID "${tabId}" not found in document.`);
183
+ }
184
+ if (!targetTab.documentTab?.body?.content) {
185
+ throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
186
+ }
187
+ bodyContent = targetTab.documentTab.body.content;
188
+ }
189
+ else {
190
+ bodyContent = res.data.body?.content;
191
+ }
192
+ if (!bodyContent) {
193
+ logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`);
194
+ return null;
195
+ }
196
+ // More robust text collection and index tracking
197
+ let fullText = '';
198
+ const segments = [];
199
+ // Process all content elements, including structural ones
200
+ const collectTextFromContent = (content) => {
201
+ content.forEach((element) => {
202
+ // Handle paragraph elements
203
+ if (element.paragraph?.elements) {
204
+ element.paragraph.elements.forEach((pe) => {
205
+ if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
206
+ const content = pe.textRun.content;
207
+ fullText += content;
208
+ segments.push({
209
+ text: content,
210
+ start: pe.startIndex,
211
+ end: pe.endIndex,
212
+ });
213
+ }
214
+ });
215
+ }
216
+ // Handle table elements - this is simplified and might need expansion
217
+ if (element.table && element.table.tableRows) {
218
+ element.table.tableRows.forEach((row) => {
219
+ if (row.tableCells) {
220
+ row.tableCells.forEach((cell) => {
221
+ if (cell.content) {
222
+ collectTextFromContent(cell.content);
223
+ }
224
+ });
225
+ }
226
+ });
227
+ }
228
+ // Add handling for other structural elements as needed
229
+ });
230
+ };
231
+ collectTextFromContent(bodyContent);
232
+ // Sort segments by starting position to ensure correct ordering
233
+ segments.sort((a, b) => a.start - b.start);
234
+ logger.debug(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
235
+ // Find the specified instance of the text
236
+ let startIndex = -1;
237
+ let endIndex = -1;
238
+ let foundCount = 0;
239
+ let searchStartIndex = 0;
240
+ while (foundCount < instance) {
241
+ const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
242
+ if (currentIndex === -1) {
243
+ logger.debug(`Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})`);
244
+ break;
245
+ }
246
+ foundCount++;
247
+ logger.debug(`Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text`);
248
+ if (foundCount === instance) {
249
+ const targetStartInFullText = currentIndex;
250
+ const targetEndInFullText = currentIndex + textToFind.length;
251
+ let currentPosInFullText = 0;
252
+ logger.debug(`Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}`);
253
+ for (const seg of segments) {
254
+ const segStartInFullText = currentPosInFullText;
255
+ const segTextLength = seg.text.length;
256
+ const segEndInFullText = segStartInFullText + segTextLength;
257
+ // Map from reconstructed text position to actual document indices
258
+ if (startIndex === -1 &&
259
+ targetStartInFullText >= segStartInFullText &&
260
+ targetStartInFullText < segEndInFullText) {
261
+ startIndex = seg.start + (targetStartInFullText - segStartInFullText);
262
+ logger.debug(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`);
263
+ }
264
+ if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
265
+ endIndex = seg.start + (targetEndInFullText - segStartInFullText);
266
+ logger.debug(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
267
+ break;
268
+ }
269
+ currentPosInFullText = segEndInFullText;
270
+ }
271
+ if (startIndex === -1 || endIndex === -1) {
272
+ logger.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
273
+ return { startIndex, endIndex };
274
+ }
275
+ logger.debug(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
276
+ return { startIndex, endIndex };
277
+ }
278
+ // Prepare for next search iteration
279
+ searchStartIndex = currentIndex + 1;
280
+ }
281
+ logger.warn(`Could not find instance ${instance} of text "${textToFind}" in document ${documentId}`);
282
+ return null; // Instance not found or mapping failed for all attempts
283
+ }
284
+ catch (error) {
285
+ logger.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
286
+ if (error.code === 404)
287
+ throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
288
+ if (error.code === 403)
289
+ throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
290
+ throw new Error(`Failed to retrieve doc for text searching: ${error.message || 'Unknown error'}`);
291
+ }
292
+ }
293
+ export async function findElements(docs, documentId, options) {
294
+ const { textQuery, elementType } = options;
295
+ if (!textQuery && !elementType) {
296
+ throw new UserError('findElement requires at least one of "textQuery" or "elementType".');
297
+ }
298
+ let res;
299
+ try {
300
+ res = await docs.documents.get({
301
+ documentId,
302
+ fields: 'body(content(startIndex,endIndex,table(rows,columns,tableRows(tableCells(content(paragraph(elements(startIndex,endIndex,textRun(content))))))),paragraph(elements(startIndex,endIndex,textRun(content)))))',
303
+ });
304
+ }
305
+ catch (error) {
306
+ if (error.code === 404)
307
+ throw new UserError(`Document not found (ID: ${documentId}).`);
308
+ if (error.code === 403)
309
+ throw new UserError(`Permission denied for document ${documentId}.`);
310
+ throw new Error(`Failed to retrieve document for findElement: ${error.message || 'Unknown error'}`);
311
+ }
312
+ const content = res.data.body?.content;
313
+ if (!content)
314
+ return [];
315
+ const results = [];
316
+ // --- Structural listing (paragraph / table) ---
317
+ if (elementType === 'paragraph' || elementType === 'table') {
318
+ for (const element of content) {
319
+ if (elementType === 'paragraph' && element.paragraph?.elements) {
320
+ const text = element.paragraph.elements
321
+ .map((pe) => pe.textRun?.content || '')
322
+ .join('');
323
+ // Skip empty structural paragraphs unless they carry a real range
324
+ if (element.startIndex == null || element.endIndex == null)
325
+ continue;
326
+ results.push({
327
+ type: 'paragraph',
328
+ startIndex: element.startIndex,
329
+ endIndex: element.endIndex,
330
+ text: text.replace(/\n$/, '').slice(0, 120),
331
+ });
332
+ }
333
+ else if (elementType === 'table' && element.table) {
334
+ if (element.startIndex == null || element.endIndex == null)
335
+ continue;
336
+ results.push({
337
+ type: 'table',
338
+ startIndex: element.startIndex,
339
+ endIndex: element.endIndex,
340
+ text: `table ${element.table.rows ?? '?'}x${element.table.columns ?? '?'}`,
341
+ });
342
+ }
343
+ }
344
+ // If no textQuery, we're done; otherwise fall through and also match text.
345
+ if (!textQuery)
346
+ return results;
347
+ }
348
+ else if (elementType === 'list' || elementType === 'image') {
349
+ // Not supported. Reject regardless of textQuery: silently returning plain
350
+ // text matches would mislabel them as if they located a list/image.
351
+ throw new UserError(`elementType "${elementType}" is not supported. Omit elementType and pass textQuery to locate content by text.`);
352
+ }
353
+ // --- Text matching (all occurrences) ---
354
+ // Each PARAGRAPH (top-level or inside a table cell) is treated as one searchable
355
+ // unit: its text runs are concatenated into a single string, with a parallel
356
+ // index map giving the true document index of every character. We search the
357
+ // concatenated string and map matches back through that map. Building the map
358
+ // per-character (rather than firstRunStart + offset) keeps indices exact even
359
+ // when a run/style boundary splits a word — so a phrase styled mid-way (bold,
360
+ // link, colour) still matches, which per-run searching missed.
361
+ //
362
+ // The paragraph (and table cell) is a HARD boundary: runs are never concatenated
363
+ // across paragraphs or cells. That confines remapping to within a paragraph,
364
+ // where text-run indices are contiguous, and so avoids the mis-indexing that a
365
+ // naive whole-document concatenate-and-remap produces across structural-index
366
+ // gaps (table cells, paragraph boundaries). A phrase spanning two paragraphs, or
367
+ // text inside a table nested within a cell, is therefore not matched.
368
+ if (textQuery) {
369
+ const units = [];
370
+ const collect = (items) => {
371
+ items.forEach((element) => {
372
+ if (element.paragraph?.elements) {
373
+ // Build one searchable unit per run of CONTIGUOUS text runs. A unit
374
+ // ends at any document-index discontinuity — a non-text element
375
+ // (inline image, smart chip, page break) or a run whose startIndex
376
+ // isn't exactly the previous run's last index + 1. This is what makes
377
+ // the per-character map safe: every unit's characters are contiguous
378
+ // in document space, so a match's range is exactly its width and can
379
+ // never span (and over-delete) an inline object. Adjacent runs split
380
+ // only by styling ARE contiguous, so cross-run phrases still match.
381
+ let text = '';
382
+ let map = [];
383
+ const flush = () => {
384
+ if (map.length > 0)
385
+ units.push({ text, map, firstIndex: map[0] });
386
+ text = '';
387
+ map = [];
388
+ };
389
+ element.paragraph.elements.forEach((pe) => {
390
+ const content = pe.textRun?.content;
391
+ if (content && pe.startIndex != null) {
392
+ if (map.length > 0 && pe.startIndex !== map[map.length - 1] + 1) {
393
+ flush(); // gap before this run — start a new unit
394
+ }
395
+ for (let i = 0; i < content.length; i++) {
396
+ text += content[i];
397
+ map.push(pe.startIndex + i);
398
+ }
399
+ }
400
+ else {
401
+ flush(); // non-text element ends the contiguous span
402
+ }
403
+ });
404
+ flush();
405
+ }
406
+ if (element.table?.tableRows) {
407
+ element.table.tableRows.forEach((row) => {
408
+ row.tableCells?.forEach((cell) => {
409
+ // Recurses one level into cell content (cell paragraphs).
410
+ // A table nested *inside* a cell is not searched: the
411
+ // documents.get field mask only populates `table` at the
412
+ // top level, so a nested table arrives without its `table`
413
+ // field and is skipped here. Documented as a limitation.
414
+ if (cell.content)
415
+ collect(cell.content);
416
+ });
417
+ });
418
+ }
419
+ });
420
+ };
421
+ collect(content);
422
+ units.sort((a, b) => a.firstIndex - b.firstIndex);
423
+ let instance = 0;
424
+ for (const unit of units) {
425
+ let from = 0;
426
+ while (true) {
427
+ const at = unit.text.indexOf(textQuery, from);
428
+ if (at === -1)
429
+ break;
430
+ instance++;
431
+ results.push({
432
+ type: 'text',
433
+ instance,
434
+ startIndex: unit.map[at],
435
+ endIndex: unit.map[at + textQuery.length - 1] + 1,
436
+ text: textQuery,
437
+ });
438
+ from = at + textQuery.length; // non-overlapping matches
439
+ }
440
+ }
441
+ }
442
+ return results;
443
+ }
444
+ // --- Paragraph Boundary Helper ---
445
+ // Enhanced version to handle document structural elements more robustly
446
+ export async function getParagraphRange(docs, documentId, indexWithin, tabId) {
447
+ try {
448
+ logger.debug(`Finding paragraph containing index ${indexWithin} in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`);
449
+ // When tabId is specified, we need to use includeTabsContent to access tab-specific content
450
+ const needsTabsContent = !!tabId;
451
+ // Request more detailed document structure to handle nested elements
452
+ const res = await docs.documents.get({
453
+ documentId,
454
+ ...(needsTabsContent && { includeTabsContent: true }),
455
+ // Request more comprehensive structure information
456
+ fields: needsTabsContent
457
+ ? buildTabsFieldMask('documentTab(body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents)))')
458
+ : 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
459
+ });
460
+ // Get body content from the correct tab or default
461
+ let bodyContent;
462
+ if (tabId) {
463
+ const targetTab = findTabById(res.data, tabId);
464
+ if (!targetTab) {
465
+ throw new UserError(`Tab with ID "${tabId}" not found in document.`);
466
+ }
467
+ if (!targetTab.documentTab?.body?.content) {
468
+ throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
469
+ }
470
+ bodyContent = targetTab.documentTab.body.content;
471
+ }
472
+ else {
473
+ bodyContent = res.data.body?.content;
474
+ }
475
+ if (!bodyContent) {
476
+ logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`);
477
+ return null;
478
+ }
479
+ // Find paragraph containing the index
480
+ // We'll look at all structural elements recursively
481
+ const findParagraphInContent = (content) => {
482
+ for (const element of content) {
483
+ // Check if we have element boundaries defined
484
+ if (element.startIndex !== undefined && element.endIndex !== undefined) {
485
+ // Check if index is within this element's range first
486
+ if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
487
+ // If it's a paragraph, we've found our target
488
+ if (element.paragraph) {
489
+ logger.debug(`Found paragraph containing index ${indexWithin}, range: ${element.startIndex}-${element.endIndex}`);
490
+ return {
491
+ startIndex: element.startIndex,
492
+ endIndex: element.endIndex,
493
+ };
494
+ }
495
+ // If it's a table, we need to check cells recursively
496
+ if (element.table && element.table.tableRows) {
497
+ logger.debug(`Index ${indexWithin} is within a table, searching cells...`);
498
+ for (const row of element.table.tableRows) {
499
+ if (row.tableCells) {
500
+ for (const cell of row.tableCells) {
501
+ if (cell.content) {
502
+ const result = findParagraphInContent(cell.content);
503
+ if (result)
504
+ return result;
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+ // For other structural elements, we didn't find a paragraph
511
+ // but we know the index is within this element
512
+ logger.warn(`Index ${indexWithin} is within element (${element.startIndex}-${element.endIndex}) but not in a paragraph`);
513
+ }
514
+ }
515
+ }
516
+ return null;
517
+ };
518
+ const paragraphRange = findParagraphInContent(bodyContent);
519
+ if (!paragraphRange) {
520
+ logger.warn(`Could not find paragraph containing index ${indexWithin}`);
521
+ }
522
+ else {
523
+ logger.debug(`Returning paragraph range: ${paragraphRange.startIndex}-${paragraphRange.endIndex}`);
524
+ }
525
+ return paragraphRange;
526
+ }
527
+ catch (error) {
528
+ logger.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message || 'Unknown error'}`);
529
+ if (error.code === 404)
530
+ throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
531
+ if (error.code === 403)
532
+ throw new UserError(`Permission denied while accessing doc ${documentId}.`);
533
+ throw new Error(`Failed to find paragraph: ${error.message || 'Unknown error'}`);
534
+ }
535
+ }
536
+ // --- Style Request Builders ---
537
+ export function buildUpdateTextStyleRequest(startIndex, endIndex, style, tabId) {
538
+ const textStyle = {};
539
+ const fieldsToUpdate = [];
540
+ if (style.bold !== undefined) {
541
+ textStyle.bold = style.bold;
542
+ fieldsToUpdate.push('bold');
543
+ }
544
+ if (style.italic !== undefined) {
545
+ textStyle.italic = style.italic;
546
+ fieldsToUpdate.push('italic');
547
+ }
548
+ if (style.underline !== undefined) {
549
+ textStyle.underline = style.underline;
550
+ fieldsToUpdate.push('underline');
551
+ }
552
+ if (style.strikethrough !== undefined) {
553
+ textStyle.strikethrough = style.strikethrough;
554
+ fieldsToUpdate.push('strikethrough');
555
+ }
556
+ if (style.fontSize !== undefined) {
557
+ textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' };
558
+ fieldsToUpdate.push('fontSize');
559
+ }
560
+ if (style.fontFamily !== undefined) {
561
+ textStyle.weightedFontFamily = { fontFamily: style.fontFamily };
562
+ fieldsToUpdate.push('weightedFontFamily');
563
+ }
564
+ if (style.foregroundColor !== undefined) {
565
+ const rgbColor = hexToRgbColor(style.foregroundColor);
566
+ if (!rgbColor)
567
+ throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
568
+ textStyle.foregroundColor = { color: { rgbColor: rgbColor } };
569
+ fieldsToUpdate.push('foregroundColor');
570
+ }
571
+ if (style.backgroundColor !== undefined) {
572
+ const rgbColor = hexToRgbColor(style.backgroundColor);
573
+ if (!rgbColor)
574
+ throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
575
+ textStyle.backgroundColor = { color: { rgbColor: rgbColor } };
576
+ fieldsToUpdate.push('backgroundColor');
577
+ }
578
+ if (style.linkUrl !== undefined) {
579
+ textStyle.link = { url: style.linkUrl };
580
+ fieldsToUpdate.push('link');
581
+ }
582
+ // TODO: Handle clearing formatting
583
+ if (fieldsToUpdate.length === 0)
584
+ return null; // No styles to apply
585
+ const range = { startIndex, endIndex };
586
+ if (tabId) {
587
+ range.tabId = tabId;
588
+ }
589
+ const request = {
590
+ updateTextStyle: {
591
+ range,
592
+ textStyle: textStyle,
593
+ fields: fieldsToUpdate.join(','),
594
+ },
595
+ };
596
+ return { request, fields: fieldsToUpdate };
597
+ }
598
+ export function buildUpdateParagraphStyleRequest(startIndex, endIndex, style, tabId) {
599
+ // Create style object and track which fields to update
600
+ const paragraphStyle = {};
601
+ const fieldsToUpdate = [];
602
+ logger.debug(`Building paragraph style request for range ${startIndex}-${endIndex} with options:`, style);
603
+ // Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
604
+ if (style.alignment !== undefined) {
605
+ paragraphStyle.alignment = style.alignment;
606
+ fieldsToUpdate.push('alignment');
607
+ logger.debug(`Setting alignment to ${style.alignment}`);
608
+ }
609
+ // Process indentation options
610
+ if (style.indentStart !== undefined) {
611
+ paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' };
612
+ fieldsToUpdate.push('indentStart');
613
+ logger.debug(`Setting left indent to ${style.indentStart}pt`);
614
+ }
615
+ if (style.indentEnd !== undefined) {
616
+ paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' };
617
+ fieldsToUpdate.push('indentEnd');
618
+ logger.debug(`Setting right indent to ${style.indentEnd}pt`);
619
+ }
620
+ // Process spacing options
621
+ if (style.spaceAbove !== undefined) {
622
+ paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' };
623
+ fieldsToUpdate.push('spaceAbove');
624
+ logger.debug(`Setting space above to ${style.spaceAbove}pt`);
625
+ }
626
+ if (style.spaceBelow !== undefined) {
627
+ paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' };
628
+ fieldsToUpdate.push('spaceBelow');
629
+ logger.debug(`Setting space below to ${style.spaceBelow}pt`);
630
+ }
631
+ // Process named style types (headings, etc.)
632
+ if (style.namedStyleType !== undefined) {
633
+ paragraphStyle.namedStyleType = style.namedStyleType;
634
+ fieldsToUpdate.push('namedStyleType');
635
+ logger.debug(`Setting named style to ${style.namedStyleType}`);
636
+ }
637
+ // Process page break control
638
+ if (style.keepWithNext !== undefined) {
639
+ paragraphStyle.keepWithNext = style.keepWithNext;
640
+ fieldsToUpdate.push('keepWithNext');
641
+ logger.debug(`Setting keepWithNext to ${style.keepWithNext}`);
642
+ }
643
+ // Verify we have styles to apply
644
+ if (fieldsToUpdate.length === 0) {
645
+ logger.warn('No paragraph styling options were provided');
646
+ return null; // No styles to apply
647
+ }
648
+ // Build the range with optional tabId
649
+ const range = { startIndex, endIndex };
650
+ if (tabId) {
651
+ range.tabId = tabId;
652
+ }
653
+ // Build the request object
654
+ const request = {
655
+ updateParagraphStyle: {
656
+ range,
657
+ paragraphStyle: paragraphStyle,
658
+ fields: fieldsToUpdate.join(','),
659
+ },
660
+ };
661
+ logger.debug(`Created paragraph style request with fields: ${fieldsToUpdate.join(', ')}`);
662
+ return { request, fields: fieldsToUpdate };
663
+ }
664
+ export function buildTableStartLocation(tableStartIndex, tabId) {
665
+ const location = { index: tableStartIndex };
666
+ if (tabId) {
667
+ location.tabId = tabId;
668
+ }
669
+ return location;
670
+ }
671
+ export function buildInsertTableRowRequest(tableStartIndex, rowIndex, insertBelow, tabId) {
672
+ return {
673
+ insertTableRow: {
674
+ tableCellLocation: {
675
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
676
+ rowIndex,
677
+ columnIndex: 0,
678
+ },
679
+ insertBelow,
680
+ },
681
+ };
682
+ }
683
+ export function buildDeleteTableRowRequest(tableStartIndex, rowIndex, tabId) {
684
+ return {
685
+ deleteTableRow: {
686
+ tableCellLocation: {
687
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
688
+ rowIndex,
689
+ columnIndex: 0,
690
+ },
691
+ },
692
+ };
693
+ }
694
+ function pointDimension(magnitude) {
695
+ return { magnitude, unit: 'PT' };
696
+ }
697
+ export function buildTableCellStyleRequest(tableStartIndex, rowIndex, columnIndex, style, tabId) {
698
+ const tableCellStyle = {};
699
+ const fields = [];
700
+ if (style.backgroundColor) {
701
+ tableCellStyle.backgroundColor = { color: { rgbColor: style.backgroundColor } };
702
+ fields.push('backgroundColor');
703
+ }
704
+ if (style.contentAlignment) {
705
+ tableCellStyle.contentAlignment = style.contentAlignment;
706
+ fields.push('contentAlignment');
707
+ }
708
+ if (style.paddingTopPt !== undefined) {
709
+ tableCellStyle.paddingTop = pointDimension(style.paddingTopPt);
710
+ fields.push('paddingTop');
711
+ }
712
+ if (style.paddingBottomPt !== undefined) {
713
+ tableCellStyle.paddingBottom = pointDimension(style.paddingBottomPt);
714
+ fields.push('paddingBottom');
715
+ }
716
+ if (style.paddingLeftPt !== undefined) {
717
+ tableCellStyle.paddingLeft = pointDimension(style.paddingLeftPt);
718
+ fields.push('paddingLeft');
719
+ }
720
+ if (style.paddingRightPt !== undefined) {
721
+ tableCellStyle.paddingRight = pointDimension(style.paddingRightPt);
722
+ fields.push('paddingRight');
723
+ }
724
+ if (style.borderTop) {
725
+ tableCellStyle.borderTop = style.borderTop;
726
+ fields.push('borderTop');
727
+ }
728
+ if (style.borderBottom) {
729
+ tableCellStyle.borderBottom = style.borderBottom;
730
+ fields.push('borderBottom');
731
+ }
732
+ if (style.borderLeft) {
733
+ tableCellStyle.borderLeft = style.borderLeft;
734
+ fields.push('borderLeft');
735
+ }
736
+ if (style.borderRight) {
737
+ tableCellStyle.borderRight = style.borderRight;
738
+ fields.push('borderRight');
739
+ }
740
+ if (fields.length === 0)
741
+ return null;
742
+ const rowSpan = style.rowSpan ?? 1;
743
+ const columnSpan = style.columnSpan ?? 1;
744
+ return {
745
+ request: {
746
+ updateTableCellStyle: {
747
+ tableRange: {
748
+ tableCellLocation: {
749
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
750
+ rowIndex,
751
+ columnIndex,
752
+ },
753
+ rowSpan,
754
+ columnSpan,
755
+ },
756
+ tableCellStyle: tableCellStyle,
757
+ fields: fields.join(','),
758
+ },
759
+ },
760
+ fields,
761
+ };
762
+ }
763
+ export function buildTableBorder(color, widthPt, dashStyle) {
764
+ return {
765
+ color: { color: { rgbColor: color } },
766
+ width: pointDimension(widthPt),
767
+ dashStyle,
768
+ };
769
+ }
770
+ export function buildTableColumnWidthRequest(tableStartIndex, columnIndices, widthPt, tabId) {
771
+ const request = {
772
+ updateTableColumnProperties: {
773
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
774
+ columnIndices,
775
+ tableColumnProperties: {
776
+ widthType: 'FIXED_WIDTH',
777
+ width: pointDimension(widthPt),
778
+ },
779
+ fields: 'widthType,width',
780
+ },
781
+ };
782
+ return request;
783
+ }
784
+ export function buildTableRowStyleRequest(tableStartIndex, rowIndices, minRowHeightPt, preventOverflow, tabId) {
785
+ const tableRowStyle = {};
786
+ const fields = [];
787
+ if (minRowHeightPt !== undefined) {
788
+ tableRowStyle.minRowHeight = pointDimension(minRowHeightPt);
789
+ fields.push('minRowHeight');
790
+ }
791
+ if (preventOverflow !== undefined) {
792
+ tableRowStyle.preventOverflow = preventOverflow;
793
+ fields.push('preventOverflow');
794
+ }
795
+ if (fields.length === 0)
796
+ return null;
797
+ const request = {
798
+ updateTableRowStyle: {
799
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
800
+ rowIndices,
801
+ tableRowStyle,
802
+ fields: fields.join(','),
803
+ },
804
+ };
805
+ return request;
806
+ }
807
+ export function buildPinTableHeaderRowsRequest(tableStartIndex, pinnedHeaderRowsCount, tabId) {
808
+ const request = {
809
+ pinTableHeaderRows: {
810
+ tableStartLocation: buildTableStartLocation(tableStartIndex, tabId),
811
+ pinnedHeaderRowsCount,
812
+ },
813
+ };
814
+ return request;
815
+ }
816
+ // --- Specific Feature Helpers ---
817
+ export async function createTable(docs, documentId, rows, columns, index, tabId) {
818
+ if (rows < 1 || columns < 1) {
819
+ throw new UserError('Table must have at least 1 row and 1 column.');
820
+ }
821
+ const location = { index };
822
+ if (tabId) {
823
+ location.tabId = tabId;
824
+ }
825
+ const request = {
826
+ insertTable: {
827
+ location,
828
+ rows: rows,
829
+ columns: columns,
830
+ },
831
+ };
832
+ return executeBatchUpdate(docs, documentId, [request]);
833
+ }
834
+ export async function insertText(docs, documentId, text, index) {
835
+ if (!text)
836
+ return {}; // Nothing to insert
837
+ const request = {
838
+ insertText: {
839
+ location: { index },
840
+ text: text,
841
+ },
842
+ };
843
+ return executeBatchUpdate(docs, documentId, [request]);
844
+ }
845
+ // --- Table Cell Helper ---
846
+ /**
847
+ * Finds the content range of a specific table cell.
848
+ * Returns the start and end indices of the cell's text content (excluding trailing newline).
849
+ */
850
+ export async function getTableCellRange(docs, documentId, tableStartIndex, rowIndex, columnIndex, tabId) {
851
+ const res = await docs.documents.get({
852
+ documentId,
853
+ ...(tabId && { includeTabsContent: true }),
854
+ });
855
+ // Get body content from the correct tab or default
856
+ let bodyContent;
857
+ if (tabId) {
858
+ const allTabs = getAllTabs(res.data);
859
+ const tab = allTabs.find((t) => t.tabProperties?.tabId === tabId);
860
+ if (!tab)
861
+ throw new UserError(`Tab with ID "${tabId}" not found.`);
862
+ bodyContent = tab.documentTab?.body?.content;
863
+ }
864
+ else {
865
+ bodyContent = res.data.body?.content;
866
+ }
867
+ if (!bodyContent) {
868
+ throw new UserError(`No content found in document ${documentId}.`);
869
+ }
870
+ // Find the table element matching tableStartIndex
871
+ const tableElement = bodyContent.find((el) => el.table && el.startIndex === tableStartIndex);
872
+ if (!tableElement || !tableElement.table) {
873
+ throw new UserError(`No table found at startIndex ${tableStartIndex}. Use readGoogleDoc with format='json' to find the correct table startIndex.`);
874
+ }
875
+ const table = tableElement.table;
876
+ const rows = table.tableRows;
877
+ if (!rows || rowIndex < 0 || rowIndex >= rows.length) {
878
+ throw new UserError(`Row index ${rowIndex} is out of range. Table has ${rows?.length ?? 0} rows (0-based).`);
879
+ }
880
+ const cells = rows[rowIndex].tableCells;
881
+ if (!cells || columnIndex < 0 || columnIndex >= cells.length) {
882
+ throw new UserError(`Column index ${columnIndex} is out of range. Row ${rowIndex} has ${cells?.length ?? 0} columns (0-based).`);
883
+ }
884
+ const cell = cells[columnIndex];
885
+ const cellContent = cell.content;
886
+ if (!cellContent || cellContent.length === 0) {
887
+ throw new UserError(`Cell (${rowIndex}, ${columnIndex}) has no content elements.`);
888
+ }
889
+ // Cell always has at least one paragraph with a trailing \n.
890
+ // We want the range covering all content *before* that final \n.
891
+ const firstParagraph = cellContent[0];
892
+ const lastParagraph = cellContent[cellContent.length - 1];
893
+ const cellStartIndex = firstParagraph.startIndex;
894
+ // The endIndex of the last paragraph includes the trailing \n.
895
+ // We subtract 1 to exclude it so delete operations don't remove the cell structure.
896
+ const cellEndIndex = lastParagraph.endIndex;
897
+ if (cellStartIndex == null || cellEndIndex == null) {
898
+ throw new UserError(`Could not determine content range for cell (${rowIndex}, ${columnIndex}).`);
899
+ }
900
+ return { startIndex: cellStartIndex, endIndex: cellEndIndex - 1 };
901
+ }
902
+ // --- Complex / Stubbed Helpers ---
903
+ export async function findParagraphsMatchingStyle(docs, documentId, styleCriteria // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true })
904
+ ) {
905
+ // TODO: Implement logic
906
+ // 1. Get document content with paragraph elements and their styles.
907
+ // 2. Iterate through paragraphs.
908
+ // 3. For each paragraph, check if its computed style matches the criteria.
909
+ // 4. Return ranges of matching paragraphs.
910
+ logger.warn('findParagraphsMatchingStyle is not implemented.');
911
+ throw new NotImplementedError('Finding paragraphs by style criteria is not yet implemented.');
912
+ // return [];
913
+ }
914
+ export async function detectAndFormatLists(docs, documentId, startIndex, endIndex) {
915
+ // TODO: Implement complex logic
916
+ // 1. Get document content (paragraphs, text runs) in the specified range (or whole doc).
917
+ // 2. Iterate through paragraphs.
918
+ // 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "*", "1.", "a)").
919
+ // 4. Determine nesting levels based on indentation or marker patterns.
920
+ // 5. Generate CreateParagraphBulletsRequests for the identified sequences.
921
+ // 6. Potentially delete the original marker text.
922
+ // 7. Execute the batch update.
923
+ logger.warn('detectAndFormatLists is not implemented.');
924
+ throw new NotImplementedError('Automatic list detection and formatting is not yet implemented.');
925
+ // return {};
926
+ }
927
+ export async function addCommentHelper(docs, documentId, text, startIndex, endIndex) {
928
+ // NOTE: Adding comments typically requires the Google Drive API v3 and different scopes!
929
+ // 'https://www.googleapis.com/auth/drive' or more specific comment scopes.
930
+ // This helper is a placeholder assuming Drive API client (`drive`) is available and authorized.
931
+ /*
932
+ const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available
933
+ await drive.comments.create({
934
+ fileId: documentId,
935
+ requestBody: {
936
+ content: text,
937
+ anchor: JSON.stringify({ // Anchor format might need verification
938
+ 'type': 'workbook#textAnchor', // Or appropriate type for Docs
939
+ 'refs': [{
940
+ 'docRevisionId': 'head', // Or specific revision
941
+ 'range': {
942
+ 'start': startIndex,
943
+ 'end': endIndex,
944
+ }
945
+ }]
946
+ })
947
+ },
948
+ fields: 'id'
949
+ });
950
+ */
951
+ logger.warn('addCommentHelper requires Google Drive API and is not implemented.');
952
+ throw new NotImplementedError('Adding comments requires Drive API setup and is not yet implemented.');
953
+ }
954
+ // --- Image Insertion Helpers ---
955
+ /**
956
+ * Inserts an inline image into a document from a publicly accessible URL
957
+ * @param docs - Google Docs API client
958
+ * @param documentId - The document ID
959
+ * @param imageUrl - Publicly accessible URL to the image
960
+ * @param index - Position in the document where image should be inserted (1-based)
961
+ * @param width - Optional width in points
962
+ * @param height - Optional height in points
963
+ * @returns Promise with batch update response
964
+ */
965
+ export async function insertInlineImage(docs, documentId, imageUrl, index, width, height, tabId) {
966
+ // Validate URL format
967
+ try {
968
+ new URL(imageUrl);
969
+ }
970
+ catch (e) {
971
+ throw new UserError(`Invalid image URL format: ${imageUrl}`);
972
+ }
973
+ // Build the insertInlineImage request
974
+ const location = { index };
975
+ if (tabId) {
976
+ location.tabId = tabId;
977
+ }
978
+ const request = {
979
+ insertInlineImage: {
980
+ location,
981
+ uri: imageUrl,
982
+ ...(width &&
983
+ height && {
984
+ objectSize: {
985
+ height: { magnitude: height, unit: 'PT' },
986
+ width: { magnitude: width, unit: 'PT' },
987
+ },
988
+ }),
989
+ },
990
+ };
991
+ return executeBatchUpdate(docs, documentId, [request]);
992
+ }
993
+ /**
994
+ * Uploads a local image file to Google Drive.
995
+ *
996
+ * When `skipPublicSharing` is false (default), the file is made publicly
997
+ * readable and its webContentLink is returned — required for the Docs API
998
+ * insertInlineImage approach.
999
+ *
1000
+ * When `skipPublicSharing` is true, only the Drive file ID is returned.
1001
+ * Use this with the Apps Script insertion path where no public URL is needed.
1002
+ */
1003
+ export async function uploadImageToDrive(drive, // drive_v3.Drive type
1004
+ localFilePath, parentFolderId, skipPublicSharing = true) {
1005
+ const fs = await import('fs');
1006
+ const path = await import('path');
1007
+ if (!fs.existsSync(localFilePath)) {
1008
+ throw new UserError(`Image file not found: ${localFilePath}`);
1009
+ }
1010
+ const resolvedPath = path.resolve(localFilePath);
1011
+ const cwd = path.resolve(process.cwd());
1012
+ if (!resolvedPath.startsWith(cwd + path.sep) && resolvedPath !== cwd) {
1013
+ throw new UserError('Image file path must be within the working directory.');
1014
+ }
1015
+ const fileName = path.basename(localFilePath);
1016
+ const mimeTypeMap = {
1017
+ '.jpg': 'image/jpeg',
1018
+ '.jpeg': 'image/jpeg',
1019
+ '.png': 'image/png',
1020
+ '.gif': 'image/gif',
1021
+ '.bmp': 'image/bmp',
1022
+ '.webp': 'image/webp',
1023
+ '.svg': 'image/svg+xml',
1024
+ };
1025
+ const ext = path.extname(localFilePath).toLowerCase();
1026
+ const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
1027
+ const fileMetadata = {
1028
+ name: fileName,
1029
+ mimeType: mimeType,
1030
+ };
1031
+ if (parentFolderId) {
1032
+ fileMetadata.parents = [parentFolderId];
1033
+ }
1034
+ const media = {
1035
+ mimeType: mimeType,
1036
+ body: fs.createReadStream(localFilePath),
1037
+ };
1038
+ const uploadResponse = await drive.files.create({
1039
+ requestBody: fileMetadata,
1040
+ media: media,
1041
+ fields: 'id,webViewLink,webContentLink',
1042
+ supportsAllDrives: true,
1043
+ });
1044
+ const fileId = uploadResponse.data.id;
1045
+ if (!fileId) {
1046
+ throw new Error('Failed to upload image to Drive - no file ID returned');
1047
+ }
1048
+ if (skipPublicSharing) {
1049
+ return fileId;
1050
+ }
1051
+ await drive.permissions.create({
1052
+ fileId: fileId,
1053
+ requestBody: {
1054
+ role: 'reader',
1055
+ type: 'anyone',
1056
+ },
1057
+ supportsAllDrives: true,
1058
+ });
1059
+ const fileInfo = await drive.files.get({
1060
+ fileId: fileId,
1061
+ fields: 'webContentLink',
1062
+ supportsAllDrives: true,
1063
+ });
1064
+ const webContentLink = fileInfo.data.webContentLink;
1065
+ if (!webContentLink) {
1066
+ throw new Error('Failed to get public URL for uploaded image');
1067
+ }
1068
+ return webContentLink;
1069
+ }
1070
+ /**
1071
+ * Inserts an image into a Google Doc via Apps Script.
1072
+ *
1073
+ * Flow:
1074
+ * 1. Insert a unique marker string at the target index using the Docs API.
1075
+ * 2. Call the deployed Apps Script which finds the marker and replaces it
1076
+ * with the actual image blob from Drive (no public sharing needed).
1077
+ */
1078
+ export async function insertImageViaAppsScript(docs, scriptClient, // script_v1.Script type
1079
+ deploymentId, documentId, driveFileId, charIndex, tabId) {
1080
+ const marker = `[mcp-img-${driveFileId}]`;
1081
+ // Step 1: Insert marker at the requested position via Docs API
1082
+ const location = { index: charIndex };
1083
+ if (tabId) {
1084
+ location.tabId = tabId;
1085
+ }
1086
+ await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }]);
1087
+ // Step 2: Call Apps Script to replace the marker with the image
1088
+ const response = await scriptClient.scripts.run({
1089
+ scriptId: deploymentId,
1090
+ requestBody: {
1091
+ function: 'insertImageByFileId',
1092
+ parameters: [documentId, driveFileId],
1093
+ },
1094
+ });
1095
+ const result = response.data?.response?.result;
1096
+ if (!result || !result.success) {
1097
+ const msg = result?.message || 'Unknown Apps Script error';
1098
+ throw new Error(`Apps Script image insertion failed: ${msg}`);
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Recursively collect all tabs from a document in a flat list with hierarchy info
1103
+ * @param doc - The Google Doc document object
1104
+ * @returns Array of tabs with nesting level information
1105
+ */
1106
+ export function getAllTabs(doc) {
1107
+ const allTabs = [];
1108
+ if (!doc.tabs || doc.tabs.length === 0) {
1109
+ return allTabs;
1110
+ }
1111
+ for (const tab of doc.tabs) {
1112
+ addCurrentAndChildTabs(tab, allTabs, 0);
1113
+ }
1114
+ return allTabs;
1115
+ }
1116
+ /**
1117
+ * Recursive helper to add tabs with their nesting level
1118
+ * @param tab - The tab to add
1119
+ * @param allTabs - The accumulator array
1120
+ * @param level - Current nesting level (0 for top-level)
1121
+ */
1122
+ function addCurrentAndChildTabs(tab, allTabs, level) {
1123
+ allTabs.push({ ...tab, level });
1124
+ if (tab.childTabs && tab.childTabs.length > 0) {
1125
+ for (const childTab of tab.childTabs) {
1126
+ addCurrentAndChildTabs(childTab, allTabs, level + 1);
1127
+ }
1128
+ }
1129
+ }
1130
+ /**
1131
+ * Get the text length from a DocumentTab
1132
+ * @param documentTab - The DocumentTab object
1133
+ * @returns Total character count
1134
+ */
1135
+ export function getTabTextLength(documentTab) {
1136
+ let totalLength = 0;
1137
+ if (!documentTab?.body?.content) {
1138
+ return 0;
1139
+ }
1140
+ documentTab.body.content.forEach((element) => {
1141
+ // Handle paragraphs
1142
+ if (element.paragraph?.elements) {
1143
+ element.paragraph.elements.forEach((pe) => {
1144
+ if (pe.textRun?.content) {
1145
+ totalLength += pe.textRun.content.length;
1146
+ }
1147
+ });
1148
+ }
1149
+ // Handle tables
1150
+ if (element.table?.tableRows) {
1151
+ element.table.tableRows.forEach((row) => {
1152
+ row.tableCells?.forEach((cell) => {
1153
+ cell.content?.forEach((cellElement) => {
1154
+ cellElement.paragraph?.elements?.forEach((pe) => {
1155
+ if (pe.textRun?.content) {
1156
+ totalLength += pe.textRun.content.length;
1157
+ }
1158
+ });
1159
+ });
1160
+ });
1161
+ });
1162
+ }
1163
+ });
1164
+ return totalLength;
1165
+ }
1166
+ /**
1167
+ * Find a specific tab by ID in a document (searches recursively through child tabs)
1168
+ * @param doc - The Google Doc document object
1169
+ * @param tabId - The tab ID to search for
1170
+ * @returns The tab object if found, null otherwise
1171
+ */
1172
+ export function findTabById(doc, tabId) {
1173
+ if (!doc.tabs || doc.tabs.length === 0) {
1174
+ return null;
1175
+ }
1176
+ // Helper function to search through tabs recursively
1177
+ const searchTabs = (tabs) => {
1178
+ for (const tab of tabs) {
1179
+ if (tab.tabProperties?.tabId === tabId) {
1180
+ return tab;
1181
+ }
1182
+ // Recursively search child tabs
1183
+ if (tab.childTabs && tab.childTabs.length > 0) {
1184
+ const found = searchTabs(tab.childTabs);
1185
+ if (found)
1186
+ return found;
1187
+ }
1188
+ }
1189
+ return null;
1190
+ };
1191
+ return searchTabs(doc.tabs);
1192
+ }
1193
+ /**
1194
+ * Fetches a document with tab content and validates that the given tabId exists and has
1195
+ * a documentTab body. Returns the resolved `docs_v1.Schema$Tab`.
1196
+ *
1197
+ * This replaces the repeated pattern:
1198
+ * docs.documents.get({ includeTabsContent: true, fields: buildTabsFieldMask(...) })
1199
+ * + findTabById() + two UserError throws
1200
+ *
1201
+ * @param docs - Authenticated Docs client
1202
+ * @param documentId - Target document ID
1203
+ * @param tabId - Tab ID to locate
1204
+ * @param documentTabFields - The documentTab subfields needed (passed to buildTabsFieldMask)
1205
+ */
1206
+ export async function getDocumentTab(docs, documentId, tabId, documentTabFields = 'documentTab(body(content(endIndex)))') {
1207
+ const res = await docs.documents.get({
1208
+ documentId,
1209
+ includeTabsContent: true,
1210
+ suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
1211
+ fields: buildTabsFieldMask(documentTabFields),
1212
+ });
1213
+ const tab = findTabById(res.data, tabId);
1214
+ if (!tab) {
1215
+ throw new UserError(`Tab with ID "${tabId}" not found in document.`);
1216
+ }
1217
+ if (!tab.documentTab) {
1218
+ throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
1219
+ }
1220
+ return tab;
1221
+ }
1222
+ /**
1223
+ * Returns the 1-based insertion index for appending to a document or tab body —
1224
+ * i.e. `endIndex - 1` of the last structural element, which positions content
1225
+ * immediately before the document's trailing newline.
1226
+ *
1227
+ * @param docs - Authenticated Docs client
1228
+ * @param documentId - Target document ID
1229
+ * @param tabId - Optional tab ID; if omitted uses the root body
1230
+ */
1231
+ export async function getAppendIndex(docs, documentId, tabId) {
1232
+ const needsTabsContent = !!tabId;
1233
+ const res = await docs.documents.get({
1234
+ documentId,
1235
+ ...(needsTabsContent && { includeTabsContent: true }),
1236
+ suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS',
1237
+ fields: needsTabsContent
1238
+ ? buildTabsFieldMask('documentTab(body(content(endIndex)))')
1239
+ : 'body(content(endIndex))',
1240
+ });
1241
+ let content;
1242
+ if (tabId) {
1243
+ const tab = findTabById(res.data, tabId);
1244
+ if (!tab)
1245
+ throw new UserError(`Tab with ID "${tabId}" not found in document.`);
1246
+ if (!tab.documentTab)
1247
+ throw new UserError(`Tab "${tabId}" does not have content.`);
1248
+ content = tab.documentTab.body?.content;
1249
+ }
1250
+ else {
1251
+ content = res.data.body?.content;
1252
+ }
1253
+ if (!content || content.length === 0)
1254
+ return 1;
1255
+ const lastEndIndex = content[content.length - 1]?.endIndex;
1256
+ return lastEndIndex != null ? lastEndIndex - 1 : 1;
1257
+ }