google-workspace-mcp 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.
Files changed (75) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +765 -0
  3. package/dist/accounts.d.ts +85 -0
  4. package/dist/accounts.d.ts.map +1 -0
  5. package/dist/accounts.js +520 -0
  6. package/dist/accounts.js.map +1 -0
  7. package/dist/auth.d.ts +4 -0
  8. package/dist/auth.d.ts.map +1 -0
  9. package/dist/auth.js +206 -0
  10. package/dist/auth.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +426 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/errorHelpers.d.ts +40 -0
  16. package/dist/errorHelpers.d.ts.map +1 -0
  17. package/dist/errorHelpers.js +52 -0
  18. package/dist/errorHelpers.js.map +1 -0
  19. package/dist/googleDocsApiHelpers.d.ts +118 -0
  20. package/dist/googleDocsApiHelpers.d.ts.map +1 -0
  21. package/dist/googleDocsApiHelpers.js +850 -0
  22. package/dist/googleDocsApiHelpers.js.map +1 -0
  23. package/dist/googleSheetsApiHelpers.d.ts +75 -0
  24. package/dist/googleSheetsApiHelpers.d.ts.map +1 -0
  25. package/dist/googleSheetsApiHelpers.js +376 -0
  26. package/dist/googleSheetsApiHelpers.js.map +1 -0
  27. package/dist/server.d.ts +2 -0
  28. package/dist/server.d.ts.map +1 -0
  29. package/dist/server.js +119 -0
  30. package/dist/server.js.map +1 -0
  31. package/dist/serverWrapper.d.ts +21 -0
  32. package/dist/serverWrapper.d.ts.map +1 -0
  33. package/dist/serverWrapper.js +74 -0
  34. package/dist/serverWrapper.js.map +1 -0
  35. package/dist/tools/accounts.tools.d.ts +3 -0
  36. package/dist/tools/accounts.tools.d.ts.map +1 -0
  37. package/dist/tools/accounts.tools.js +154 -0
  38. package/dist/tools/accounts.tools.js.map +1 -0
  39. package/dist/tools/calendar.tools.d.ts +3 -0
  40. package/dist/tools/calendar.tools.d.ts.map +1 -0
  41. package/dist/tools/calendar.tools.js +487 -0
  42. package/dist/tools/calendar.tools.js.map +1 -0
  43. package/dist/tools/docs.tools.d.ts +3 -0
  44. package/dist/tools/docs.tools.d.ts.map +1 -0
  45. package/dist/tools/docs.tools.js +1766 -0
  46. package/dist/tools/docs.tools.js.map +1 -0
  47. package/dist/tools/drive.tools.d.ts +3 -0
  48. package/dist/tools/drive.tools.d.ts.map +1 -0
  49. package/dist/tools/drive.tools.js +1001 -0
  50. package/dist/tools/drive.tools.js.map +1 -0
  51. package/dist/tools/forms.tools.d.ts +3 -0
  52. package/dist/tools/forms.tools.d.ts.map +1 -0
  53. package/dist/tools/forms.tools.js +370 -0
  54. package/dist/tools/forms.tools.js.map +1 -0
  55. package/dist/tools/gmail.tools.d.ts +3 -0
  56. package/dist/tools/gmail.tools.d.ts.map +1 -0
  57. package/dist/tools/gmail.tools.js +520 -0
  58. package/dist/tools/gmail.tools.js.map +1 -0
  59. package/dist/tools/sheets.tools.d.ts +3 -0
  60. package/dist/tools/sheets.tools.d.ts.map +1 -0
  61. package/dist/tools/sheets.tools.js +521 -0
  62. package/dist/tools/sheets.tools.js.map +1 -0
  63. package/dist/tools/slides.tools.d.ts +3 -0
  64. package/dist/tools/slides.tools.d.ts.map +1 -0
  65. package/dist/tools/slides.tools.js +323 -0
  66. package/dist/tools/slides.tools.js.map +1 -0
  67. package/dist/types.d.ts +507 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +185 -0
  70. package/dist/types.js.map +1 -0
  71. package/dist/urlHelpers.d.ts +60 -0
  72. package/dist/urlHelpers.d.ts.map +1 -0
  73. package/dist/urlHelpers.js +101 -0
  74. package/dist/urlHelpers.js.map +1 -0
  75. package/package.json +77 -0
@@ -0,0 +1,850 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { hexToRgbColor, } from './types.js';
3
+ import { isGoogleApiError, getErrorMessage } from './errorHelpers.js';
4
+ // --- Constants ---
5
+ const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
6
+ // --- Core Helper to Execute Batch Updates ---
7
+ /**
8
+ * Execute a single batch update with error handling
9
+ */
10
+ async function executeSingleBatch(docs, documentId, requests) {
11
+ try {
12
+ const response = await docs.documents.batchUpdate({
13
+ documentId: documentId,
14
+ requestBody: { requests },
15
+ });
16
+ return response.data;
17
+ }
18
+ catch (error) {
19
+ const message = getErrorMessage(error);
20
+ const apiError = isGoogleApiError(error) ? error : null;
21
+ const code = apiError?.code;
22
+ const responseData = apiError?.response?.data;
23
+ // Translate common API errors to UserErrors
24
+ if (code === 400 && message.includes('Invalid requests')) {
25
+ // Try to extract more specific info if available
26
+ const errorResponse = responseData;
27
+ const details = errorResponse?.error?.details;
28
+ let detailMsg = '';
29
+ if (details && Array.isArray(details)) {
30
+ detailMsg = details.map((d) => d.description ?? JSON.stringify(d)).join('; ');
31
+ }
32
+ throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || message}`);
33
+ }
34
+ if (code === 404)
35
+ throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
36
+ if (code === 403)
37
+ throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
38
+ // Generic internal error for others
39
+ throw new Error(`Google API Error (${code}): ${message}`);
40
+ }
41
+ }
42
+ /**
43
+ * Execute batch updates, automatically splitting large request arrays into multiple batches.
44
+ * Returns the combined response from all batches.
45
+ */
46
+ export async function executeBatchUpdate(docs, documentId, requests) {
47
+ if (requests.length === 0) {
48
+ return {}; // Nothing to do
49
+ }
50
+ // If within limits, execute as single batch
51
+ if (requests.length <= MAX_BATCH_UPDATE_REQUESTS) {
52
+ return executeSingleBatch(docs, documentId, requests);
53
+ }
54
+ // Split into multiple batches and execute sequentially
55
+ // Note: Sequential execution is required because document indices change after each batch
56
+ const allReplies = [];
57
+ let lastDocumentId = documentId;
58
+ for (let i = 0; i < requests.length; i += MAX_BATCH_UPDATE_REQUESTS) {
59
+ const batch = requests.slice(i, i + MAX_BATCH_UPDATE_REQUESTS);
60
+ const response = await executeSingleBatch(docs, documentId, batch);
61
+ if (response.replies) {
62
+ allReplies.push(...response.replies);
63
+ }
64
+ if (response.documentId) {
65
+ lastDocumentId = response.documentId;
66
+ }
67
+ }
68
+ return {
69
+ documentId: lastDocumentId,
70
+ replies: allReplies.length > 0 ? allReplies : undefined,
71
+ };
72
+ }
73
+ // --- Text Finding Helper ---
74
+ // This improved version is more robust in handling various text structure scenarios
75
+ export async function findTextRange(docs, documentId, textToFind, instance = 1) {
76
+ try {
77
+ // Request more detailed information about the document structure
78
+ const res = await docs.documents.get({
79
+ documentId,
80
+ // Request more fields to handle various container types (not just paragraphs)
81
+ fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
82
+ });
83
+ if (!res.data.body?.content) {
84
+ return null;
85
+ }
86
+ // More robust text collection and index tracking
87
+ let fullText = '';
88
+ const segments = [];
89
+ // Process all content elements, including structural ones
90
+ const collectTextFromContent = (content) => {
91
+ content.forEach((element) => {
92
+ // Handle paragraph elements
93
+ if (element.paragraph?.elements) {
94
+ element.paragraph.elements.forEach((pe) => {
95
+ if (pe.textRun?.content &&
96
+ pe.startIndex !== null &&
97
+ pe.startIndex !== undefined &&
98
+ pe.endIndex !== null &&
99
+ pe.endIndex !== undefined) {
100
+ const textContent = pe.textRun.content;
101
+ fullText += textContent;
102
+ segments.push({
103
+ text: textContent,
104
+ start: pe.startIndex,
105
+ end: pe.endIndex,
106
+ });
107
+ }
108
+ });
109
+ }
110
+ // Handle table elements - this is simplified and might need expansion
111
+ if (element.table?.tableRows) {
112
+ element.table.tableRows.forEach((row) => {
113
+ if (row.tableCells) {
114
+ row.tableCells.forEach((cell) => {
115
+ if (cell.content) {
116
+ collectTextFromContent(cell.content);
117
+ }
118
+ });
119
+ }
120
+ });
121
+ }
122
+ // Add handling for other structural elements as needed
123
+ });
124
+ };
125
+ collectTextFromContent(res.data.body.content);
126
+ // Sort segments by starting position to ensure correct ordering
127
+ segments.sort((a, b) => a.start - b.start);
128
+ // Find the specified instance of the text
129
+ let startIndex = -1;
130
+ let endIndex = -1;
131
+ let foundCount = 0;
132
+ let searchStartIndex = 0;
133
+ while (foundCount < instance) {
134
+ const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
135
+ if (currentIndex === -1) {
136
+ break;
137
+ }
138
+ foundCount++;
139
+ if (foundCount === instance) {
140
+ const targetStartInFullText = currentIndex;
141
+ const targetEndInFullText = currentIndex + textToFind.length;
142
+ let currentPosInFullText = 0;
143
+ for (const seg of segments) {
144
+ const segStartInFullText = currentPosInFullText;
145
+ const segTextLength = seg.text.length;
146
+ const segEndInFullText = segStartInFullText + segTextLength;
147
+ // Map from reconstructed text position to actual document indices
148
+ if (startIndex === -1 &&
149
+ targetStartInFullText >= segStartInFullText &&
150
+ targetStartInFullText < segEndInFullText) {
151
+ startIndex = seg.start + (targetStartInFullText - segStartInFullText);
152
+ }
153
+ if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
154
+ endIndex = seg.start + (targetEndInFullText - segStartInFullText);
155
+ break;
156
+ }
157
+ currentPosInFullText = segEndInFullText;
158
+ }
159
+ if (startIndex === -1 || endIndex === -1) {
160
+ // Reset and try next occurrence
161
+ startIndex = -1;
162
+ endIndex = -1;
163
+ searchStartIndex = currentIndex + 1;
164
+ foundCount--;
165
+ continue;
166
+ }
167
+ return { startIndex, endIndex };
168
+ }
169
+ // Prepare for next search iteration
170
+ searchStartIndex = currentIndex + 1;
171
+ }
172
+ return null; // Instance not found or mapping failed for all attempts
173
+ }
174
+ catch (error) {
175
+ const message = getErrorMessage(error);
176
+ const code = isGoogleApiError(error) ? error.code : undefined;
177
+ if (code === 404)
178
+ throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
179
+ if (code === 403)
180
+ throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
181
+ throw new Error(`Failed to retrieve doc for text searching: ${message}`);
182
+ }
183
+ }
184
+ // --- Paragraph Boundary Helper ---
185
+ // Enhanced version to handle document structural elements more robustly
186
+ export async function getParagraphRange(docs, documentId, indexWithin) {
187
+ try {
188
+ // Request more detailed document structure to handle nested elements
189
+ const res = await docs.documents.get({
190
+ documentId,
191
+ // Request more comprehensive structure information
192
+ fields: 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
193
+ });
194
+ if (!res.data.body?.content) {
195
+ return null;
196
+ }
197
+ // Find paragraph containing the index
198
+ // We'll look at all structural elements recursively
199
+ const findParagraphInContent = (content) => {
200
+ for (const element of content) {
201
+ // Check if we have element boundaries defined (can be 0, so check both null and undefined)
202
+ if (element.startIndex !== null &&
203
+ element.startIndex !== undefined &&
204
+ element.endIndex !== null &&
205
+ element.endIndex !== undefined) {
206
+ // Check if index is within this element's range first
207
+ if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
208
+ // If it's a paragraph, we've found our target
209
+ if (element.paragraph) {
210
+ return {
211
+ startIndex: element.startIndex,
212
+ endIndex: element.endIndex,
213
+ };
214
+ }
215
+ // If it's a table, we need to check cells recursively
216
+ if (element.table?.tableRows) {
217
+ for (const row of element.table.tableRows) {
218
+ if (row.tableCells) {
219
+ for (const cell of row.tableCells) {
220
+ if (cell.content) {
221
+ const result = findParagraphInContent(cell.content);
222
+ if (result)
223
+ return result;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return null;
233
+ };
234
+ return findParagraphInContent(res.data.body.content);
235
+ }
236
+ catch (error) {
237
+ const message = getErrorMessage(error);
238
+ const code = isGoogleApiError(error) ? error.code : undefined;
239
+ if (code === 404)
240
+ throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
241
+ if (code === 403)
242
+ throw new UserError(`Permission denied while accessing doc ${documentId}.`);
243
+ throw new Error(`Failed to find paragraph: ${message}`);
244
+ }
245
+ }
246
+ // --- Style Request Builders ---
247
+ export function buildUpdateTextStyleRequest(startIndex, endIndex, style) {
248
+ const textStyle = {};
249
+ const fieldsToUpdate = [];
250
+ if (style.bold !== undefined) {
251
+ textStyle.bold = style.bold;
252
+ fieldsToUpdate.push('bold');
253
+ }
254
+ if (style.italic !== undefined) {
255
+ textStyle.italic = style.italic;
256
+ fieldsToUpdate.push('italic');
257
+ }
258
+ if (style.underline !== undefined) {
259
+ textStyle.underline = style.underline;
260
+ fieldsToUpdate.push('underline');
261
+ }
262
+ if (style.strikethrough !== undefined) {
263
+ textStyle.strikethrough = style.strikethrough;
264
+ fieldsToUpdate.push('strikethrough');
265
+ }
266
+ if (style.fontSize !== undefined) {
267
+ textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' };
268
+ fieldsToUpdate.push('fontSize');
269
+ }
270
+ if (style.fontFamily !== undefined) {
271
+ textStyle.weightedFontFamily = { fontFamily: style.fontFamily };
272
+ fieldsToUpdate.push('weightedFontFamily');
273
+ }
274
+ if (style.foregroundColor !== undefined) {
275
+ const rgbColor = hexToRgbColor(style.foregroundColor);
276
+ if (!rgbColor)
277
+ throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
278
+ textStyle.foregroundColor = { color: { rgbColor: rgbColor } };
279
+ fieldsToUpdate.push('foregroundColor');
280
+ }
281
+ if (style.backgroundColor !== undefined) {
282
+ const rgbColor = hexToRgbColor(style.backgroundColor);
283
+ if (!rgbColor)
284
+ throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
285
+ textStyle.backgroundColor = { color: { rgbColor: rgbColor } };
286
+ fieldsToUpdate.push('backgroundColor');
287
+ }
288
+ if (style.linkUrl !== undefined) {
289
+ textStyle.link = { url: style.linkUrl };
290
+ fieldsToUpdate.push('link');
291
+ }
292
+ if (style.removeLink === true) {
293
+ // To remove a link, we set link to an empty object and update the 'link' field
294
+ textStyle.link = {};
295
+ fieldsToUpdate.push('link');
296
+ }
297
+ if (fieldsToUpdate.length === 0)
298
+ return null; // No styles to apply
299
+ const request = {
300
+ updateTextStyle: {
301
+ range: { startIndex, endIndex },
302
+ textStyle: textStyle,
303
+ fields: fieldsToUpdate.join(','),
304
+ },
305
+ };
306
+ return { request, fields: fieldsToUpdate };
307
+ }
308
+ export function buildUpdateParagraphStyleRequest(startIndex, endIndex, style) {
309
+ // Create style object and track which fields to update
310
+ const paragraphStyle = {};
311
+ const fieldsToUpdate = [];
312
+ // Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
313
+ if (style.alignment !== undefined) {
314
+ paragraphStyle.alignment = style.alignment;
315
+ fieldsToUpdate.push('alignment');
316
+ }
317
+ // Process indentation options
318
+ if (style.indentStart !== undefined) {
319
+ paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' };
320
+ fieldsToUpdate.push('indentStart');
321
+ }
322
+ if (style.indentEnd !== undefined) {
323
+ paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' };
324
+ fieldsToUpdate.push('indentEnd');
325
+ }
326
+ // Process spacing options
327
+ if (style.spaceAbove !== undefined) {
328
+ paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' };
329
+ fieldsToUpdate.push('spaceAbove');
330
+ }
331
+ if (style.spaceBelow !== undefined) {
332
+ paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' };
333
+ fieldsToUpdate.push('spaceBelow');
334
+ }
335
+ // Process named style types (headings, etc.)
336
+ if (style.namedStyleType !== undefined) {
337
+ paragraphStyle.namedStyleType = style.namedStyleType;
338
+ fieldsToUpdate.push('namedStyleType');
339
+ }
340
+ // Process page break control
341
+ if (style.keepWithNext !== undefined) {
342
+ paragraphStyle.keepWithNext = style.keepWithNext;
343
+ fieldsToUpdate.push('keepWithNext');
344
+ }
345
+ // Verify we have styles to apply
346
+ if (fieldsToUpdate.length === 0) {
347
+ return null; // No styles to apply
348
+ }
349
+ // Build the request object
350
+ const request = {
351
+ updateParagraphStyle: {
352
+ range: { startIndex, endIndex },
353
+ paragraphStyle: paragraphStyle,
354
+ fields: fieldsToUpdate.join(','),
355
+ },
356
+ };
357
+ return { request, fields: fieldsToUpdate };
358
+ }
359
+ // --- Specific Feature Helpers ---
360
+ export async function createTable(docs, documentId, rows, columns, index) {
361
+ if (rows < 1 || columns < 1) {
362
+ throw new UserError('Table must have at least 1 row and 1 column.');
363
+ }
364
+ const request = {
365
+ insertTable: {
366
+ location: { index },
367
+ rows: rows,
368
+ columns: columns,
369
+ },
370
+ };
371
+ return executeBatchUpdate(docs, documentId, [request]);
372
+ }
373
+ /**
374
+ * Find a table cell's content range by navigating the document structure
375
+ * @param docs - Google Docs API client
376
+ * @param documentId - The document ID
377
+ * @param tableStartIndex - The start index of the table element
378
+ * @param rowIndex - 0-based row index
379
+ * @param columnIndex - 0-based column index
380
+ * @returns Object with startIndex and endIndex of the cell's content, or null if not found
381
+ */
382
+ export async function findTableCellRange(docs, documentId, tableStartIndex, rowIndex, columnIndex) {
383
+ const response = await docs.documents.get({ documentId });
384
+ const document = response.data;
385
+ const body = document.body;
386
+ if (!body?.content) {
387
+ throw new UserError('Document has no content');
388
+ }
389
+ // Find the table at the given start index
390
+ for (const element of body.content) {
391
+ if (element.table && element.startIndex === tableStartIndex) {
392
+ const table = element.table;
393
+ const rows = table.tableRows;
394
+ if (!rows || rows.length === 0) {
395
+ throw new UserError('Table has no rows');
396
+ }
397
+ if (rowIndex < 0 || rowIndex >= rows.length) {
398
+ throw new UserError(`Row index ${rowIndex} out of bounds. Table has ${rows.length} rows (0-${rows.length - 1}).`);
399
+ }
400
+ const row = rows[rowIndex];
401
+ const cells = row.tableCells;
402
+ if (!cells || cells.length === 0) {
403
+ throw new UserError(`Row ${rowIndex} has no cells`);
404
+ }
405
+ if (columnIndex < 0 || columnIndex >= cells.length) {
406
+ throw new UserError(`Column index ${columnIndex} out of bounds. Row has ${cells.length} columns (0-${cells.length - 1}).`);
407
+ }
408
+ const cell = cells[columnIndex];
409
+ const cellContent = cell.content;
410
+ if (!cellContent || cellContent.length === 0) {
411
+ throw new UserError(`Cell (${rowIndex}, ${columnIndex}) has no content`);
412
+ }
413
+ // Find the content range of the cell
414
+ // Cell content is an array of structural elements (usually paragraphs)
415
+ const firstElement = cellContent[0];
416
+ const lastElement = cellContent[cellContent.length - 1];
417
+ const startIndex = firstElement.startIndex ?? 0;
418
+ const endIndex = lastElement.endIndex ?? startIndex;
419
+ return { startIndex, endIndex };
420
+ }
421
+ }
422
+ throw new UserError(`No table found at index ${tableStartIndex}. Use readGoogleDoc to find table locations.`);
423
+ }
424
+ /**
425
+ * Edit the content of a specific table cell
426
+ * @param docs - Google Docs API client
427
+ * @param documentId - The document ID
428
+ * @param tableStartIndex - The start index of the table element
429
+ * @param rowIndex - 0-based row index
430
+ * @param columnIndex - 0-based column index
431
+ * @param newContent - New text content for the cell (replaces existing content)
432
+ * @returns Batch update response
433
+ */
434
+ export async function editTableCellContent(docs, documentId, tableStartIndex, rowIndex, columnIndex, newContent) {
435
+ const cellRange = await findTableCellRange(docs, documentId, tableStartIndex, rowIndex, columnIndex);
436
+ if (!cellRange) {
437
+ throw new UserError(`Could not find cell at (${rowIndex}, ${columnIndex})`);
438
+ }
439
+ const requests = [];
440
+ // Delete existing content (but leave the cell structure - delete content inside the cell)
441
+ // The cell always has at least one paragraph, so we need to be careful
442
+ // We delete from startIndex to endIndex-1 (leave the trailing newline that marks end of cell paragraph)
443
+ const deleteEndIndex = cellRange.endIndex - 1; // Keep the newline at the end
444
+ if (deleteEndIndex > cellRange.startIndex) {
445
+ requests.push({
446
+ deleteContentRange: {
447
+ range: {
448
+ startIndex: cellRange.startIndex,
449
+ endIndex: deleteEndIndex,
450
+ },
451
+ },
452
+ });
453
+ }
454
+ // Insert new content at the start of the cell
455
+ if (newContent) {
456
+ requests.push({
457
+ insertText: {
458
+ location: { index: cellRange.startIndex },
459
+ text: newContent,
460
+ },
461
+ });
462
+ }
463
+ if (requests.length === 0) {
464
+ return {}; // Nothing to do
465
+ }
466
+ return executeBatchUpdate(docs, documentId, requests);
467
+ }
468
+ /**
469
+ * Find all tables in a document and return their locations and dimensions
470
+ * @param docs - Google Docs API client
471
+ * @param documentId - The document ID
472
+ * @returns Array of table info objects
473
+ */
474
+ export async function findDocumentTables(docs, documentId) {
475
+ const response = await docs.documents.get({ documentId });
476
+ const document = response.data;
477
+ const body = document.body;
478
+ if (!body?.content) {
479
+ return [];
480
+ }
481
+ const tables = [];
482
+ for (const element of body.content) {
483
+ if (element.table) {
484
+ const table = element.table;
485
+ const rows = table.tableRows ?? [];
486
+ const columns = rows.length > 0 ? (rows[0].tableCells?.length ?? 0) : 0;
487
+ tables.push({
488
+ startIndex: element.startIndex ?? 0,
489
+ endIndex: element.endIndex ?? 0,
490
+ rows: rows.length,
491
+ columns: columns,
492
+ });
493
+ }
494
+ }
495
+ return tables;
496
+ }
497
+ export async function insertText(docs, documentId, text, index) {
498
+ if (!text)
499
+ return {}; // Nothing to insert
500
+ const request = {
501
+ insertText: {
502
+ location: { index },
503
+ text: text,
504
+ },
505
+ };
506
+ return executeBatchUpdate(docs, documentId, [request]);
507
+ }
508
+ export async function findParagraphsMatchingStyle(docs, documentId, styleCriteria) {
509
+ // Get document content
510
+ const response = await docs.documents.get({ documentId });
511
+ const document = response.data;
512
+ const body = document.body;
513
+ if (!body?.content) {
514
+ return [];
515
+ }
516
+ const matchingRanges = [];
517
+ // Helper to check if a text style matches criteria
518
+ function styleMatches(textStyle) {
519
+ if (!textStyle)
520
+ return false;
521
+ if (styleCriteria.bold !== undefined && textStyle.bold !== styleCriteria.bold) {
522
+ return false;
523
+ }
524
+ if (styleCriteria.italic !== undefined && textStyle.italic !== styleCriteria.italic) {
525
+ return false;
526
+ }
527
+ if (styleCriteria.fontFamily !== undefined) {
528
+ const fontFamily = textStyle.weightedFontFamily?.fontFamily;
529
+ if (fontFamily !== styleCriteria.fontFamily) {
530
+ return false;
531
+ }
532
+ }
533
+ if (styleCriteria.fontSize !== undefined) {
534
+ const fontSize = textStyle.fontSize?.magnitude;
535
+ if (fontSize !== styleCriteria.fontSize) {
536
+ return false;
537
+ }
538
+ }
539
+ return true;
540
+ }
541
+ // Iterate through structural elements to find paragraphs
542
+ for (const element of body.content) {
543
+ if (element.paragraph) {
544
+ const paragraph = element.paragraph;
545
+ const startIdx = element.startIndex ?? 0;
546
+ const endIdx = element.endIndex ?? startIdx;
547
+ // Check if any text run in this paragraph matches the criteria
548
+ let paragraphMatches = false;
549
+ if (paragraph.elements) {
550
+ for (const paraElement of paragraph.elements) {
551
+ if (paraElement.textRun?.textStyle) {
552
+ if (styleMatches(paraElement.textRun.textStyle)) {
553
+ paragraphMatches = true;
554
+ break;
555
+ }
556
+ }
557
+ }
558
+ }
559
+ if (paragraphMatches) {
560
+ matchingRanges.push({ startIndex: startIdx, endIndex: endIdx });
561
+ }
562
+ }
563
+ }
564
+ return matchingRanges;
565
+ }
566
+ const LIST_PATTERNS = [
567
+ // Bullet patterns: -, *, •
568
+ { regex: /^[\s]*[-*•]\s+/, bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE' },
569
+ // Numbered patterns: 1. 2. etc
570
+ { regex: /^[\s]*\d+[.)]\s+/, bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' },
571
+ // Letter patterns: a) b) A) B)
572
+ { regex: /^[\s]*[a-zA-Z][.)]\s+/, bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' },
573
+ ];
574
+ export async function detectAndFormatLists(docs, documentId, startIndex, endIndex) {
575
+ // Get document content
576
+ const response = await docs.documents.get({ documentId });
577
+ const document = response.data;
578
+ const body = document.body;
579
+ if (!body?.content) {
580
+ return {};
581
+ }
582
+ const requests = [];
583
+ const potentialListItems = [];
584
+ // Iterate through structural elements to find paragraphs
585
+ for (const element of body.content) {
586
+ if (!element.paragraph)
587
+ continue;
588
+ const paraStart = element.startIndex ?? 0;
589
+ const paraEnd = element.endIndex ?? paraStart;
590
+ // Skip if outside specified range
591
+ if (startIndex !== undefined && paraEnd < startIndex)
592
+ continue;
593
+ if (endIndex !== undefined && paraStart > endIndex)
594
+ continue;
595
+ // Skip paragraphs that are already in a list
596
+ if (element.paragraph.bullet)
597
+ continue;
598
+ // Get the text content of the paragraph
599
+ let paragraphText = '';
600
+ if (element.paragraph.elements) {
601
+ for (const paraElement of element.paragraph.elements) {
602
+ if (paraElement.textRun?.content) {
603
+ paragraphText += paraElement.textRun.content;
604
+ }
605
+ }
606
+ }
607
+ // Check if the paragraph starts with a list marker
608
+ for (const pattern of LIST_PATTERNS) {
609
+ const match = pattern.regex.exec(paragraphText);
610
+ if (match) {
611
+ potentialListItems.push({
612
+ startIndex: paraStart,
613
+ endIndex: paraEnd,
614
+ markerEndIndex: paraStart + match[0].length,
615
+ bulletPreset: pattern.bulletPreset,
616
+ });
617
+ break; // Only match one pattern per paragraph
618
+ }
619
+ }
620
+ }
621
+ if (potentialListItems.length === 0) {
622
+ return {}; // No list items detected
623
+ }
624
+ // Group consecutive paragraphs with the same bullet type into lists
625
+ // For now, just apply bullets to each detected item
626
+ // Process in reverse order to maintain correct indices when deleting markers
627
+ const sortedItems = [...potentialListItems].sort((a, b) => b.startIndex - a.startIndex);
628
+ for (const item of sortedItems) {
629
+ // First, delete the marker text
630
+ requests.push({
631
+ deleteContentRange: {
632
+ range: {
633
+ startIndex: item.startIndex,
634
+ endIndex: item.markerEndIndex,
635
+ },
636
+ },
637
+ });
638
+ // Then create the bullet
639
+ // Note: After deletion, the paragraph range shifts, but createParagraphBullets
640
+ // works on paragraph boundaries, so we use the original start index
641
+ requests.push({
642
+ createParagraphBullets: {
643
+ range: {
644
+ startIndex: item.startIndex,
645
+ endIndex: item.startIndex + 1, // Just needs to touch the paragraph
646
+ },
647
+ bulletPreset: item.bulletPreset,
648
+ },
649
+ });
650
+ }
651
+ // Reverse to get correct execution order (Google Docs processes requests in order)
652
+ requests.reverse();
653
+ return executeBatchUpdate(docs, documentId, requests);
654
+ }
655
+ // --- Image Insertion Helpers ---
656
+ /**
657
+ * Inserts an inline image into a document from a publicly accessible URL
658
+ * @param docs - Google Docs API client
659
+ * @param documentId - The document ID
660
+ * @param imageUrl - Publicly accessible URL to the image
661
+ * @param index - Position in the document where image should be inserted (1-based)
662
+ * @param width - Optional width in points
663
+ * @param height - Optional height in points
664
+ * @returns Promise with batch update response
665
+ */
666
+ export async function insertInlineImage(docs, documentId, imageUrl, index, width, height) {
667
+ // Validate URL format
668
+ try {
669
+ new URL(imageUrl);
670
+ }
671
+ catch {
672
+ throw new UserError(`Invalid image URL format: ${imageUrl}`);
673
+ }
674
+ // Build the insertInlineImage request
675
+ const request = {
676
+ insertInlineImage: {
677
+ location: { index },
678
+ uri: imageUrl,
679
+ ...(width &&
680
+ height && {
681
+ objectSize: {
682
+ height: { magnitude: height, unit: 'PT' },
683
+ width: { magnitude: width, unit: 'PT' },
684
+ },
685
+ }),
686
+ },
687
+ };
688
+ return executeBatchUpdate(docs, documentId, [request]);
689
+ }
690
+ /**
691
+ * Uploads a local image file to Google Drive and returns its public URL
692
+ * @param drive - Google Drive API client
693
+ * @param localFilePath - Path to the local image file
694
+ * @param parentFolderId - Optional parent folder ID (defaults to root)
695
+ * @returns Promise with the public webContentLink URL
696
+ */
697
+ export async function uploadImageToDrive(drive, localFilePath, parentFolderId) {
698
+ const fs = await import('fs');
699
+ const path = await import('path');
700
+ // Verify file exists
701
+ if (!fs.existsSync(localFilePath)) {
702
+ throw new UserError(`Image file not found: ${localFilePath}`);
703
+ }
704
+ // Get file name and mime type
705
+ const fileName = path.basename(localFilePath);
706
+ const mimeTypeMap = {
707
+ '.jpg': 'image/jpeg',
708
+ '.jpeg': 'image/jpeg',
709
+ '.png': 'image/png',
710
+ '.gif': 'image/gif',
711
+ '.bmp': 'image/bmp',
712
+ '.webp': 'image/webp',
713
+ '.svg': 'image/svg+xml',
714
+ };
715
+ const ext = path.extname(localFilePath).toLowerCase();
716
+ // eslint-disable-next-line security/detect-object-injection -- ext is from path.extname, limited to known file extensions
717
+ const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
718
+ // Upload file to Drive
719
+ const fileMetadata = {
720
+ name: fileName,
721
+ mimeType: mimeType,
722
+ };
723
+ if (parentFolderId) {
724
+ fileMetadata.parents = [parentFolderId];
725
+ }
726
+ const media = {
727
+ mimeType: mimeType,
728
+ body: fs.createReadStream(localFilePath),
729
+ };
730
+ const uploadResponse = await drive.files.create({
731
+ requestBody: fileMetadata,
732
+ media: media,
733
+ fields: 'id,webViewLink,webContentLink',
734
+ });
735
+ const fileId = uploadResponse.data.id;
736
+ if (!fileId) {
737
+ throw new Error('Failed to upload image to Drive - no file ID returned');
738
+ }
739
+ // Make the file publicly readable
740
+ await drive.permissions.create({
741
+ fileId: fileId,
742
+ requestBody: {
743
+ role: 'reader',
744
+ type: 'anyone',
745
+ },
746
+ });
747
+ // Get the webContentLink
748
+ const fileInfo = await drive.files.get({
749
+ fileId: fileId,
750
+ fields: 'webContentLink',
751
+ });
752
+ const webContentLink = fileInfo.data.webContentLink;
753
+ if (!webContentLink) {
754
+ throw new Error('Failed to get public URL for uploaded image');
755
+ }
756
+ return webContentLink;
757
+ }
758
+ /**
759
+ * Recursively collect all tabs from a document in a flat list with hierarchy info
760
+ * @param doc - The Google Doc document object
761
+ * @returns Array of tabs with nesting level information
762
+ */
763
+ export function getAllTabs(doc) {
764
+ const allTabs = [];
765
+ if (!doc.tabs || doc.tabs.length === 0) {
766
+ return allTabs;
767
+ }
768
+ for (const tab of doc.tabs) {
769
+ addCurrentAndChildTabs(tab, allTabs, 0);
770
+ }
771
+ return allTabs;
772
+ }
773
+ /**
774
+ * Recursive helper to add tabs with their nesting level
775
+ * @param tab - The tab to add
776
+ * @param allTabs - The accumulator array
777
+ * @param level - Current nesting level (0 for top-level)
778
+ */
779
+ function addCurrentAndChildTabs(tab, allTabs, level) {
780
+ allTabs.push({ ...tab, level });
781
+ if (tab.childTabs && tab.childTabs.length > 0) {
782
+ for (const childTab of tab.childTabs) {
783
+ addCurrentAndChildTabs(childTab, allTabs, level + 1);
784
+ }
785
+ }
786
+ }
787
+ /**
788
+ * Get the text length from a DocumentTab
789
+ * @param documentTab - The DocumentTab object
790
+ * @returns Total character count
791
+ */
792
+ export function getTabTextLength(documentTab) {
793
+ let totalLength = 0;
794
+ if (!documentTab?.body?.content) {
795
+ return 0;
796
+ }
797
+ documentTab.body.content.forEach((element) => {
798
+ // Handle paragraphs
799
+ if (element.paragraph?.elements) {
800
+ element.paragraph.elements.forEach((pe) => {
801
+ if (pe.textRun?.content) {
802
+ totalLength += pe.textRun.content.length;
803
+ }
804
+ });
805
+ }
806
+ // Handle tables
807
+ if (element.table?.tableRows) {
808
+ element.table.tableRows.forEach((row) => {
809
+ row.tableCells?.forEach((cell) => {
810
+ cell.content?.forEach((cellElement) => {
811
+ cellElement.paragraph?.elements?.forEach((pe) => {
812
+ if (pe.textRun?.content) {
813
+ totalLength += pe.textRun.content.length;
814
+ }
815
+ });
816
+ });
817
+ });
818
+ });
819
+ }
820
+ });
821
+ return totalLength;
822
+ }
823
+ /**
824
+ * Find a specific tab by ID in a document (searches recursively through child tabs)
825
+ * @param doc - The Google Doc document object
826
+ * @param tabId - The tab ID to search for
827
+ * @returns The tab object if found, null otherwise
828
+ */
829
+ export function findTabById(doc, tabId) {
830
+ if (!doc.tabs || doc.tabs.length === 0) {
831
+ return null;
832
+ }
833
+ // Helper function to search through tabs recursively
834
+ const searchTabs = (tabs) => {
835
+ for (const tab of tabs) {
836
+ if (tab.tabProperties?.tabId === tabId) {
837
+ return tab;
838
+ }
839
+ // Recursively search child tabs
840
+ if (tab.childTabs && tab.childTabs.length > 0) {
841
+ const found = searchTabs(tab.childTabs);
842
+ if (found)
843
+ return found;
844
+ }
845
+ }
846
+ return null;
847
+ };
848
+ return searchTabs(doc.tabs);
849
+ }
850
+ //# sourceMappingURL=googleDocsApiHelpers.js.map