google-tools-mcp 1.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 (95) hide show
  1. package/dist/auth.js +275 -0
  2. package/dist/cachedToolsList.js +52 -0
  3. package/dist/clients.js +97 -0
  4. package/dist/googleDocsApiHelpers.js +883 -0
  5. package/dist/googleSheetsApiHelpers.js +808 -0
  6. package/dist/helpers.js +210 -0
  7. package/dist/index.js +55 -0
  8. package/dist/logger.js +45 -0
  9. package/dist/markdown-transformer/docsToMarkdown.js +259 -0
  10. package/dist/markdown-transformer/index.js +126 -0
  11. package/dist/markdown-transformer/markdownToDocs.js +834 -0
  12. package/dist/tools/docs/addTab.js +92 -0
  13. package/dist/tools/docs/appendToGoogleDoc.js +81 -0
  14. package/dist/tools/docs/comments/addComment.js +83 -0
  15. package/dist/tools/docs/comments/deleteComment.js +30 -0
  16. package/dist/tools/docs/comments/getComment.js +45 -0
  17. package/dist/tools/docs/comments/index.js +14 -0
  18. package/dist/tools/docs/comments/listComments.js +43 -0
  19. package/dist/tools/docs/comments/replyToComment.js +35 -0
  20. package/dist/tools/docs/comments/resolveComment.js +55 -0
  21. package/dist/tools/docs/deleteRange.js +72 -0
  22. package/dist/tools/docs/findAndReplace.js +54 -0
  23. package/dist/tools/docs/formatting/applyParagraphStyle.js +83 -0
  24. package/dist/tools/docs/formatting/applyTextStyle.js +49 -0
  25. package/dist/tools/docs/formatting/index.js +6 -0
  26. package/dist/tools/docs/index.js +38 -0
  27. package/dist/tools/docs/insertImage.js +122 -0
  28. package/dist/tools/docs/insertPageBreak.js +58 -0
  29. package/dist/tools/docs/insertTable.js +53 -0
  30. package/dist/tools/docs/insertTableWithData.js +135 -0
  31. package/dist/tools/docs/insertText.js +61 -0
  32. package/dist/tools/docs/listDocumentTabs.js +60 -0
  33. package/dist/tools/docs/modifyText.js +158 -0
  34. package/dist/tools/docs/readGoogleDoc.js +165 -0
  35. package/dist/tools/docs/renameTab.js +61 -0
  36. package/dist/tools/drive/copyFile.js +63 -0
  37. package/dist/tools/drive/createDocument.js +89 -0
  38. package/dist/tools/drive/createFolder.js +48 -0
  39. package/dist/tools/drive/createFromTemplate.js +82 -0
  40. package/dist/tools/drive/deleteFile.js +72 -0
  41. package/dist/tools/drive/getDocumentInfo.js +48 -0
  42. package/dist/tools/drive/getFolderInfo.js +48 -0
  43. package/dist/tools/drive/index.js +30 -0
  44. package/dist/tools/drive/listFolderContents.js +82 -0
  45. package/dist/tools/drive/listGoogleDocs.js +67 -0
  46. package/dist/tools/drive/listSharedDrives.js +52 -0
  47. package/dist/tools/drive/listSharedWithMe.js +97 -0
  48. package/dist/tools/drive/moveFile.js +54 -0
  49. package/dist/tools/drive/renameFile.js +39 -0
  50. package/dist/tools/drive/searchGoogleDocs.js +98 -0
  51. package/dist/tools/extras/index.js +7 -0
  52. package/dist/tools/extras/readFile.js +82 -0
  53. package/dist/tools/extras/searchFileContents.js +81 -0
  54. package/dist/tools/gmail/drafts.js +165 -0
  55. package/dist/tools/gmail/labels.js +103 -0
  56. package/dist/tools/gmail/messages.js +448 -0
  57. package/dist/tools/gmail/settings.js +528 -0
  58. package/dist/tools/gmail/threads.js +145 -0
  59. package/dist/tools/index.js +163 -0
  60. package/dist/tools/sheets/addConditionalFormatting.js +143 -0
  61. package/dist/tools/sheets/addSpreadsheetSheet.js +34 -0
  62. package/dist/tools/sheets/appendSpreadsheetRows.js +43 -0
  63. package/dist/tools/sheets/appendTableRows.js +50 -0
  64. package/dist/tools/sheets/autoResizeColumns.js +67 -0
  65. package/dist/tools/sheets/batchWrite.js +59 -0
  66. package/dist/tools/sheets/clearSpreadsheetRange.js +31 -0
  67. package/dist/tools/sheets/copyFormatting.js +59 -0
  68. package/dist/tools/sheets/createSpreadsheet.js +71 -0
  69. package/dist/tools/sheets/createTable.js +120 -0
  70. package/dist/tools/sheets/deleteChart.js +41 -0
  71. package/dist/tools/sheets/deleteSheet.js +43 -0
  72. package/dist/tools/sheets/deleteTable.js +56 -0
  73. package/dist/tools/sheets/duplicateSheet.js +53 -0
  74. package/dist/tools/sheets/formatCells.js +106 -0
  75. package/dist/tools/sheets/freezeRowsAndColumns.js +58 -0
  76. package/dist/tools/sheets/getSpreadsheetInfo.js +44 -0
  77. package/dist/tools/sheets/getTable.js +48 -0
  78. package/dist/tools/sheets/groupRows.js +62 -0
  79. package/dist/tools/sheets/index.js +66 -0
  80. package/dist/tools/sheets/insertChart.js +225 -0
  81. package/dist/tools/sheets/listGoogleSheets.js +62 -0
  82. package/dist/tools/sheets/listTables.js +55 -0
  83. package/dist/tools/sheets/readCellFormat.js +143 -0
  84. package/dist/tools/sheets/readSpreadsheet.js +36 -0
  85. package/dist/tools/sheets/renameSheet.js +48 -0
  86. package/dist/tools/sheets/setColumnWidths.js +43 -0
  87. package/dist/tools/sheets/setDropdownValidation.js +51 -0
  88. package/dist/tools/sheets/ungroupAllRows.js +66 -0
  89. package/dist/tools/sheets/updateTableRange.js +51 -0
  90. package/dist/tools/sheets/writeSpreadsheet.js +43 -0
  91. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +93 -0
  92. package/dist/tools/utils/index.js +6 -0
  93. package/dist/tools/utils/replaceDocumentWithMarkdown.js +154 -0
  94. package/dist/types.js +186 -0
  95. package/package.json +30 -0
@@ -0,0 +1,834 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import { buildUpdateTextStyleRequest, buildUpdateParagraphStyleRequest, } from '../googleDocsApiHelpers.js';
3
+ import { MarkdownConversionError } from '../types.js';
4
+ // --- Markdown-it Setup ---
5
+ function createParser() {
6
+ return new MarkdownIt({
7
+ html: false,
8
+ linkify: true,
9
+ typographer: false,
10
+ breaks: false,
11
+ xhtmlOut: false,
12
+ });
13
+ }
14
+ function getLinkHref(token) {
15
+ if (token.type !== 'link_open')
16
+ return null;
17
+ const hrefAttr = token.attrs?.find((attr) => attr[0] === 'href');
18
+ return hrefAttr ? hrefAttr[1] : null;
19
+ }
20
+ function getHeadingLevel(token) {
21
+ if (!token.type.startsWith('heading_'))
22
+ return null;
23
+ const match = token.tag.match(/h(\d)/);
24
+ return match ? parseInt(match[1], 10) : null;
25
+ }
26
+ const CODE_FONT_FAMILY = 'Roboto Mono';
27
+ const CODE_TEXT_HEX = '#188038';
28
+ const CODE_BACKGROUND_HEX = '#F1F3F4';
29
+ // --- Code Block (table-based) Constants ---
30
+ // Google Docs "Code Block" building block is a styled 1x1 table.
31
+ // These constants define the visual style for programmatically created code blocks.
32
+ const CODE_BLOCK_BG_RGB = { red: 0.937, green: 0.945, blue: 0.953 }; // #EFF1F3
33
+ const CODE_BLOCK_BORDER_RGB = { red: 0.855, green: 0.863, blue: 0.878 }; // #DADCE0
34
+ // IMPORTANT: The Google Docs API always inserts a newline character ("\n") BEFORE
35
+ // the table when processing an insertTable request. So calling insertTable at index T
36
+ // produces the following document structure:
37
+ //
38
+ // T → paragraph break ("\n") — auto-inserted by the API
39
+ // T + 1 → table.startIndex (the actual table element)
40
+ // T + 2 → tableRow.startIndex
41
+ // T + 3 → tableCell.startIndex
42
+ // T + 4 → paragraph.startIndex ← cell content (text insertion point)
43
+ // T + 6 → table.endIndex
44
+ //
45
+ // Therefore:
46
+ // CELL_CONTENT_OFFSET = 4 (from insertTable target T to cell content at T+4)
47
+ // EMPTY_1x1_TABLE_SIZE = 6 (total positions: 1 newline + 5 table structure)
48
+ // Actual table start for updateTableCellStyle = T + 1 (NOT T)
49
+ //
50
+ // Verified empirically via documents.get on a real document with a 1x1 table.
51
+ const CELL_CONTENT_OFFSET = 4;
52
+ const EMPTY_1x1_TABLE_SIZE = 6;
53
+ // --- Main Conversion Function ---
54
+ /**
55
+ * Converts a markdown string to an array of Google Docs API batch update requests.
56
+ *
57
+ * This is an internal function -- callers should use `insertMarkdown()` from
58
+ * the barrel export instead.
59
+ *
60
+ * @param markdown - The markdown content to convert
61
+ * @param startIndex - The document index where content should be inserted (1-based)
62
+ * @param tabId - Optional tab ID for multi-tab documents
63
+ * @param options - Optional conversion options (e.g. firstHeadingAsTitle)
64
+ * @returns Array of Google Docs API requests (insertions first, then formatting)
65
+ */
66
+ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, options) {
67
+ if (!markdown || markdown.trim().length === 0) {
68
+ return [];
69
+ }
70
+ const parser = createParser();
71
+ const tokens = parser.parse(markdown, {});
72
+ const context = {
73
+ currentIndex: startIndex,
74
+ insertRequests: [],
75
+ formatRequests: [],
76
+ textRanges: [],
77
+ formattingStack: [],
78
+ listStack: [],
79
+ paragraphRanges: [],
80
+ normalParagraphRanges: [],
81
+ listSpacingRanges: [],
82
+ pendingListItems: [],
83
+ openListItemStack: [],
84
+ hrRanges: [],
85
+ codeBlockRanges: [],
86
+ tableState: undefined,
87
+ inTableCell: false,
88
+ tabId,
89
+ titleConsumed: false,
90
+ firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
91
+ };
92
+ try {
93
+ for (const token of tokens) {
94
+ processToken(token, context);
95
+ }
96
+ finalizeFormatting(context);
97
+ return [...context.insertRequests, ...context.formatRequests];
98
+ }
99
+ catch (error) {
100
+ if (error instanceof MarkdownConversionError) {
101
+ throw error;
102
+ }
103
+ throw new MarkdownConversionError(`Failed to convert markdown: ${error instanceof Error ? error.message : 'Unknown error'}`);
104
+ }
105
+ }
106
+ // --- Token Processing ---
107
+ function processToken(token, context) {
108
+ switch (token.type) {
109
+ // Headings
110
+ case 'heading_open':
111
+ handleHeadingOpen(token, context);
112
+ break;
113
+ case 'heading_close':
114
+ handleHeadingClose(context);
115
+ break;
116
+ // Paragraphs
117
+ case 'paragraph_open':
118
+ handleParagraphOpen(context);
119
+ break;
120
+ case 'paragraph_close':
121
+ handleParagraphClose(context);
122
+ break;
123
+ // Text content
124
+ case 'text':
125
+ handleTextToken(token, context);
126
+ break;
127
+ case 'code_inline':
128
+ handleCodeInlineToken(token, context);
129
+ break;
130
+ // Inline formatting
131
+ case 'strong_open':
132
+ context.formattingStack.push({ bold: true });
133
+ break;
134
+ case 'strong_close':
135
+ popFormatting(context, 'bold');
136
+ break;
137
+ case 'em_open':
138
+ context.formattingStack.push({ italic: true });
139
+ break;
140
+ case 'em_close':
141
+ popFormatting(context, 'italic');
142
+ break;
143
+ case 's_open':
144
+ context.formattingStack.push({ strikethrough: true });
145
+ break;
146
+ case 's_close':
147
+ popFormatting(context, 'strikethrough');
148
+ break;
149
+ // Links
150
+ case 'link_open': {
151
+ const href = getLinkHref(token);
152
+ if (href) {
153
+ context.formattingStack.push({ link: href });
154
+ }
155
+ break;
156
+ }
157
+ case 'link_close':
158
+ popFormatting(context, 'link');
159
+ break;
160
+ // Lists
161
+ case 'bullet_list_open':
162
+ context.listStack.push({ type: 'bullet', level: context.listStack.length });
163
+ break;
164
+ case 'bullet_list_close':
165
+ handleListClose(context);
166
+ break;
167
+ case 'ordered_list_open':
168
+ context.listStack.push({ type: 'ordered', level: context.listStack.length });
169
+ break;
170
+ case 'ordered_list_close':
171
+ handleListClose(context);
172
+ break;
173
+ case 'list_item_open':
174
+ handleListItemOpen(context);
175
+ break;
176
+ case 'list_item_close':
177
+ handleListItemClose(context);
178
+ break;
179
+ // Breaks
180
+ case 'softbreak':
181
+ if (context.inTableCell && context.tableState?.currentCell) {
182
+ context.tableState.currentCell.text += ' ';
183
+ }
184
+ else {
185
+ insertText(' ', context);
186
+ }
187
+ break;
188
+ case 'hardbreak':
189
+ if (context.inTableCell && context.tableState?.currentCell) {
190
+ context.tableState.currentCell.text += '\n';
191
+ }
192
+ else {
193
+ insertText('\n', context);
194
+ }
195
+ break;
196
+ // Inline container
197
+ case 'inline':
198
+ if (token.children) {
199
+ for (const child of token.children) {
200
+ processToken(child, context);
201
+ }
202
+ }
203
+ break;
204
+ // Tables
205
+ case 'table_open':
206
+ context.tableState = { rows: [], currentRow: [], inHeader: false, currentCell: null };
207
+ break;
208
+ case 'thead_open':
209
+ if (context.tableState)
210
+ context.tableState.inHeader = true;
211
+ break;
212
+ case 'thead_close':
213
+ if (context.tableState)
214
+ context.tableState.inHeader = false;
215
+ break;
216
+ case 'tbody_open':
217
+ case 'tbody_close':
218
+ break;
219
+ case 'tr_open':
220
+ if (context.tableState)
221
+ context.tableState.currentRow = [];
222
+ break;
223
+ case 'tr_close':
224
+ if (context.tableState && context.tableState.currentRow.length > 0) {
225
+ context.tableState.rows.push([...context.tableState.currentRow]);
226
+ context.tableState.currentRow = [];
227
+ }
228
+ break;
229
+ case 'th_open':
230
+ case 'td_open': {
231
+ if (context.tableState) {
232
+ context.tableState.currentCell = {
233
+ text: '',
234
+ isHeader: context.tableState.inHeader || token.type === 'th_open',
235
+ textRanges: [],
236
+ };
237
+ context.inTableCell = true;
238
+ }
239
+ break;
240
+ }
241
+ case 'th_close':
242
+ case 'td_close':
243
+ if (context.tableState?.currentCell) {
244
+ context.tableState.currentRow.push(context.tableState.currentCell);
245
+ context.tableState.currentCell = null;
246
+ }
247
+ context.inTableCell = false;
248
+ break;
249
+ case 'table_close':
250
+ if (context.tableState) {
251
+ handleTableClose(context.tableState, context);
252
+ context.tableState = undefined;
253
+ context.inTableCell = false;
254
+ }
255
+ break;
256
+ // Code blocks
257
+ case 'fence':
258
+ case 'code_block':
259
+ handleCodeBlockToken(token, context);
260
+ break;
261
+ // Horizontal rules
262
+ case 'hr':
263
+ handleHorizontalRule(context);
264
+ break;
265
+ // Blockquotes (skip for now)
266
+ case 'blockquote_open':
267
+ case 'blockquote_close':
268
+ break;
269
+ default:
270
+ break;
271
+ }
272
+ }
273
+ // --- Heading Handlers ---
274
+ function handleHeadingOpen(token, context) {
275
+ const level = getHeadingLevel(token);
276
+ if (level) {
277
+ context.currentHeadingLevel = level;
278
+ context.currentParagraphStart = context.currentIndex;
279
+ }
280
+ }
281
+ function handleHeadingClose(context) {
282
+ if (context.currentHeadingLevel && context.currentParagraphStart !== undefined) {
283
+ // When firstHeadingAsTitle is enabled, the very first H1 becomes a TITLE.
284
+ const useTitle = context.firstHeadingAsTitle && !context.titleConsumed && context.currentHeadingLevel === 1;
285
+ if (useTitle) {
286
+ context.titleConsumed = true;
287
+ }
288
+ context.paragraphRanges.push({
289
+ startIndex: context.currentParagraphStart,
290
+ endIndex: context.currentIndex,
291
+ namedStyleType: useTitle ? 'TITLE' : `HEADING_${context.currentHeadingLevel}`,
292
+ });
293
+ insertText('\n', context);
294
+ context.currentHeadingLevel = undefined;
295
+ context.currentParagraphStart = undefined;
296
+ }
297
+ }
298
+ // --- Horizontal Rule ---
299
+ function handleHorizontalRule(context) {
300
+ if (!lastInsertEndsWithNewline(context)) {
301
+ insertText('\n', context);
302
+ }
303
+ const start = context.currentIndex;
304
+ insertText('\n', context);
305
+ context.hrRanges.push({ startIndex: start, endIndex: context.currentIndex });
306
+ }
307
+ // --- Paragraph Handlers ---
308
+ function handleParagraphOpen(context) {
309
+ if (context.listStack.length === 0) {
310
+ context.currentParagraphStart = context.currentIndex;
311
+ }
312
+ }
313
+ function handleParagraphClose(context) {
314
+ // Track normal (non-list) paragraph ranges for spacing
315
+ const paragraphStart = context.currentParagraphStart;
316
+ if (!lastInsertEndsWithNewline(context)) {
317
+ insertText('\n', context);
318
+ }
319
+ const currentListItem = getCurrentOpenListItem(context);
320
+ if (currentListItem) {
321
+ const paragraphEndIndex = lastInsertEndsWithNewline(context)
322
+ ? context.currentIndex - 1
323
+ : context.currentIndex;
324
+ if (paragraphEndIndex > currentListItem.startIndex) {
325
+ currentListItem.endIndex = paragraphEndIndex;
326
+ }
327
+ }
328
+ // Record the range for normal paragraphs (not list items) so we can apply spacing later
329
+ if (paragraphStart !== undefined && context.listStack.length === 0) {
330
+ context.normalParagraphRanges.push({
331
+ startIndex: paragraphStart,
332
+ endIndex: context.currentIndex,
333
+ });
334
+ }
335
+ context.currentParagraphStart = undefined;
336
+ }
337
+ // --- List Handlers ---
338
+ function handleListItemOpen(context) {
339
+ if (context.listStack.length === 0) {
340
+ throw new MarkdownConversionError('List item found outside of list context');
341
+ }
342
+ const currentList = context.listStack[context.listStack.length - 1];
343
+ const itemStart = context.currentIndex;
344
+ if (currentList.level > 0) {
345
+ insertText('\t'.repeat(currentList.level), context);
346
+ }
347
+ const listItem = {
348
+ startIndex: itemStart,
349
+ nestingLevel: currentList.level,
350
+ bulletPreset: currentList.type === 'ordered' ? 'NUMBERED_DECIMAL_ALPHA_ROMAN' : 'BULLET_DISC_CIRCLE_SQUARE',
351
+ taskPrefixProcessed: false,
352
+ };
353
+ context.pendingListItems.push(listItem);
354
+ context.openListItemStack.push(context.pendingListItems.length - 1);
355
+ }
356
+ function handleListItemClose(context) {
357
+ const openIndex = context.openListItemStack.pop();
358
+ if (openIndex === undefined)
359
+ return;
360
+ const listItem = context.pendingListItems[openIndex];
361
+ if (listItem.endIndex === undefined) {
362
+ const computedEndIndex = lastInsertEndsWithNewline(context)
363
+ ? context.currentIndex - 1
364
+ : context.currentIndex;
365
+ if (computedEndIndex > listItem.startIndex) {
366
+ listItem.endIndex = computedEndIndex;
367
+ }
368
+ }
369
+ if (!lastInsertEndsWithNewline(context)) {
370
+ insertText('\n', context);
371
+ }
372
+ }
373
+ function handleListClose(context) {
374
+ context.listStack.pop();
375
+ // When a top-level list closes (stack becomes empty), record the range of the
376
+ // last list item's paragraph so we can apply spaceBelow to it. This creates a
377
+ // visible gap between the end of a list and the following content.
378
+ if (context.listStack.length === 0) {
379
+ // Find the last pending list item that has a valid endIndex
380
+ for (let i = context.pendingListItems.length - 1; i >= 0; i--) {
381
+ const item = context.pendingListItems[i];
382
+ if (item.endIndex !== undefined && item.endIndex > item.startIndex) {
383
+ context.listSpacingRanges.push({
384
+ startIndex: item.startIndex,
385
+ endIndex: item.endIndex,
386
+ });
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ }
392
+ // --- Text Handling ---
393
+ function handleTextToken(token, context) {
394
+ let text = token.content;
395
+ if (!text)
396
+ return;
397
+ // Intercept text when inside a table cell — collect into cell buffer
398
+ if (context.inTableCell && context.tableState?.currentCell) {
399
+ const cell = context.tableState.currentCell;
400
+ const startIndex = cell.text.length;
401
+ cell.text += text;
402
+ const formatting = mergeFormattingStack(context.formattingStack);
403
+ if (hasFormatting(formatting)) {
404
+ cell.textRanges.push({ startIndex, endIndex: cell.text.length, formatting });
405
+ }
406
+ return;
407
+ }
408
+ const currentListItem = getCurrentOpenListItem(context);
409
+ if (currentListItem && !currentListItem.taskPrefixProcessed) {
410
+ currentListItem.taskPrefixProcessed = true;
411
+ const taskPrefixMatch = text.match(/^\[( |x|X)\]\s+/);
412
+ if (taskPrefixMatch) {
413
+ currentListItem.bulletPreset = 'BULLET_CHECKBOX';
414
+ text = text.slice(taskPrefixMatch[0].length);
415
+ if (!text)
416
+ return;
417
+ }
418
+ }
419
+ const startIndex = context.currentIndex;
420
+ const endIndex = startIndex + text.length;
421
+ insertText(text, context);
422
+ const currentFormatting = mergeFormattingStack(context.formattingStack);
423
+ if (hasFormatting(currentFormatting)) {
424
+ context.textRanges.push({ startIndex, endIndex, formatting: currentFormatting });
425
+ }
426
+ }
427
+ function handleCodeInlineToken(token, context) {
428
+ context.formattingStack.push({ code: true });
429
+ handleTextToken(token, context);
430
+ popFormatting(context, 'code');
431
+ }
432
+ function handleCodeBlockToken(token, context) {
433
+ const normalizedContent = token.content.endsWith('\n')
434
+ ? token.content.slice(0, -1)
435
+ : token.content;
436
+ const language = token.info?.trim() || undefined;
437
+ // Ensure previous content ends with a newline before inserting the table
438
+ if (context.insertRequests.length > 0 && !lastInsertEndsWithNewline(context)) {
439
+ insertText('\n', context);
440
+ }
441
+ const tableStartIndex = context.currentIndex;
442
+ // 1. Insert a 1x1 table (creates the table structure with an empty paragraph in the cell)
443
+ const tableLocation = { index: tableStartIndex };
444
+ if (context.tabId)
445
+ tableLocation.tabId = context.tabId;
446
+ context.insertRequests.push({
447
+ insertTable: {
448
+ location: tableLocation,
449
+ rows: 1,
450
+ columns: 1,
451
+ },
452
+ });
453
+ // 2. Insert code text into the cell paragraph
454
+ // For a 1x1 table at index N, the cell paragraph content starts at N + CELL_CONTENT_OFFSET
455
+ const cellContentIndex = tableStartIndex + CELL_CONTENT_OFFSET;
456
+ const textLength = normalizedContent.length;
457
+ if (textLength > 0) {
458
+ const cellLocation = { index: cellContentIndex };
459
+ if (context.tabId)
460
+ cellLocation.tabId = context.tabId;
461
+ context.insertRequests.push({
462
+ insertText: {
463
+ location: cellLocation,
464
+ text: normalizedContent,
465
+ },
466
+ });
467
+ }
468
+ // 3. Track the code block for table/text formatting in finalization
469
+ context.codeBlockRanges.push({
470
+ tableStartIndex,
471
+ textStartIndex: cellContentIndex,
472
+ textEndIndex: cellContentIndex + textLength,
473
+ language,
474
+ });
475
+ // 4. Advance currentIndex past the entire table structure
476
+ // Total table size = EMPTY_1x1_TABLE_SIZE + inserted text length
477
+ context.currentIndex = tableStartIndex + EMPTY_1x1_TABLE_SIZE + textLength;
478
+ // 5. Ensure a newline after the table for paragraph separation
479
+ insertText('\n', context);
480
+ }
481
+ // --- Table Handler ---
482
+ //
483
+ // Google Docs API table index layout for an R×C table inserted at T:
484
+ //
485
+ // T → newline (auto-inserted before table)
486
+ // T + 1 → table element
487
+ // T + 2 → row[0]
488
+ // T + 3 → cell[0][0]
489
+ // T + 4 → paragraph in cell[0][0] ← content insertion point
490
+ // T + 5 → cell[0][1] (for C > 1)
491
+ // T + 6 → paragraph in cell[0][1]
492
+ // ...
493
+ // T + 2+C → row[1] (for R > 1)
494
+ // ...
495
+ // T + end → table end
496
+ //
497
+ // Formulas (verified empirically via CELL_CONTENT_OFFSET and EMPTY_1x1_TABLE_SIZE):
498
+ // cellContentIndex(T, r, c, C) = T + 4 + r * (1 + 2*C) + 2*c
499
+ // emptyTableSize(R, C) = 3 + R * (1 + 2*C)
500
+ function handleTableClose(tableState, context) {
501
+ const rows = tableState.rows;
502
+ if (rows.length === 0)
503
+ return;
504
+ const numRows = rows.length;
505
+ const numCols = rows.reduce((max, row) => Math.max(max, row.length), 0);
506
+ if (numCols === 0)
507
+ return;
508
+ // Ensure newline before table
509
+ if (!lastInsertEndsWithNewline(context)) {
510
+ insertText('\n', context);
511
+ }
512
+ const tableStartIndex = context.currentIndex;
513
+ // Insert the table structure
514
+ const tableLocation = { index: tableStartIndex };
515
+ if (context.tabId)
516
+ tableLocation.tabId = context.tabId;
517
+ context.insertRequests.push({
518
+ insertTable: {
519
+ location: tableLocation,
520
+ rows: numRows,
521
+ columns: numCols,
522
+ },
523
+ });
524
+ // Insert text into each cell, tracking cumulative offset from prior insertions
525
+ let cumulativeTextLength = 0;
526
+ for (let r = 0; r < numRows; r++) {
527
+ for (let c = 0; c < numCols; c++) {
528
+ const cell = rows[r]?.[c];
529
+ if (!cell?.text)
530
+ continue;
531
+ const baseCellIndex = tableStartIndex + 4 + r * (1 + 2 * numCols) + 2 * c;
532
+ const adjustedIndex = baseCellIndex + cumulativeTextLength;
533
+ const cellLocation = { index: adjustedIndex };
534
+ if (context.tabId)
535
+ cellLocation.tabId = context.tabId;
536
+ context.insertRequests.push({
537
+ insertText: {
538
+ location: cellLocation,
539
+ text: cell.text,
540
+ },
541
+ });
542
+ // Apply inline formatting (bold, italic, links, etc.) from within the cell
543
+ for (const range of cell.textRanges) {
544
+ const absStart = adjustedIndex + range.startIndex;
545
+ const absEnd = adjustedIndex + range.endIndex;
546
+ if (range.formatting.bold ||
547
+ range.formatting.italic ||
548
+ range.formatting.strikethrough ||
549
+ range.formatting.code) {
550
+ const styleReq = buildUpdateTextStyleRequest(absStart, absEnd, {
551
+ bold: range.formatting.bold,
552
+ italic: range.formatting.italic,
553
+ strikethrough: range.formatting.strikethrough,
554
+ fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
555
+ foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
556
+ backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
557
+ }, context.tabId);
558
+ if (styleReq)
559
+ context.formatRequests.push(styleReq.request);
560
+ }
561
+ if (range.formatting.link) {
562
+ const linkReq = buildUpdateTextStyleRequest(absStart, absEnd, { linkUrl: range.formatting.link }, context.tabId);
563
+ if (linkReq)
564
+ context.formatRequests.push(linkReq.request);
565
+ }
566
+ }
567
+ // Bold all text in header cells
568
+ if (cell.isHeader && cell.text.length > 0) {
569
+ const headerStyleReq = buildUpdateTextStyleRequest(adjustedIndex, adjustedIndex + cell.text.length, { bold: true }, context.tabId);
570
+ if (headerStyleReq)
571
+ context.formatRequests.push(headerStyleReq.request);
572
+ }
573
+ cumulativeTextLength += cell.text.length;
574
+ }
575
+ }
576
+ // Advance currentIndex past the full table (empty structure + all inserted text)
577
+ const emptyTableSize = 3 + numRows * (1 + 2 * numCols);
578
+ context.currentIndex = tableStartIndex + emptyTableSize + cumulativeTextLength;
579
+ // Ensure newline after table
580
+ insertText('\n', context);
581
+ }
582
+ // --- Insert Helper ---
583
+ function insertText(text, context) {
584
+ const location = { index: context.currentIndex };
585
+ if (context.tabId) {
586
+ location.tabId = context.tabId;
587
+ }
588
+ context.insertRequests.push({
589
+ insertText: { location: location, text },
590
+ });
591
+ context.currentIndex += text.length;
592
+ }
593
+ // --- Formatting Stack ---
594
+ function mergeFormattingStack(stack) {
595
+ const merged = {};
596
+ for (const state of stack) {
597
+ if (state.bold !== undefined)
598
+ merged.bold = state.bold;
599
+ if (state.italic !== undefined)
600
+ merged.italic = state.italic;
601
+ if (state.strikethrough !== undefined)
602
+ merged.strikethrough = state.strikethrough;
603
+ if (state.code !== undefined)
604
+ merged.code = state.code;
605
+ if (state.link !== undefined)
606
+ merged.link = state.link;
607
+ }
608
+ return merged;
609
+ }
610
+ function hasFormatting(formatting) {
611
+ return (formatting.bold === true ||
612
+ formatting.italic === true ||
613
+ formatting.strikethrough === true ||
614
+ formatting.code === true ||
615
+ formatting.link !== undefined);
616
+ }
617
+ function popFormatting(context, type) {
618
+ for (let i = context.formattingStack.length - 1; i >= 0; i--) {
619
+ if (context.formattingStack[i][type] !== undefined) {
620
+ context.formattingStack.splice(i, 1);
621
+ break;
622
+ }
623
+ }
624
+ }
625
+ // --- Finalization ---
626
+ function finalizeFormatting(context) {
627
+ // Character-level formatting (bold, italic, strikethrough, code, links)
628
+ for (const range of context.textRanges) {
629
+ const rangeLocation = {
630
+ startIndex: range.startIndex,
631
+ endIndex: range.endIndex,
632
+ };
633
+ if (context.tabId) {
634
+ rangeLocation.tabId = context.tabId;
635
+ }
636
+ if (range.formatting.bold ||
637
+ range.formatting.italic ||
638
+ range.formatting.strikethrough ||
639
+ range.formatting.code) {
640
+ const styleRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, {
641
+ bold: range.formatting.bold,
642
+ italic: range.formatting.italic,
643
+ strikethrough: range.formatting.strikethrough,
644
+ fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
645
+ foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
646
+ backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
647
+ }, context.tabId);
648
+ if (styleRequest) {
649
+ context.formatRequests.push(styleRequest.request);
650
+ }
651
+ }
652
+ if (range.formatting.link) {
653
+ const linkRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, { linkUrl: range.formatting.link }, context.tabId);
654
+ if (linkRequest) {
655
+ context.formatRequests.push(linkRequest.request);
656
+ }
657
+ }
658
+ }
659
+ // Paragraph-level formatting (headings)
660
+ for (const paraRange of context.paragraphRanges) {
661
+ if (paraRange.namedStyleType) {
662
+ const paraRequest = buildUpdateParagraphStyleRequest(paraRange.startIndex, paraRange.endIndex, { namedStyleType: paraRange.namedStyleType }, context.tabId);
663
+ if (paraRequest) {
664
+ context.formatRequests.push(paraRequest.request);
665
+ }
666
+ }
667
+ }
668
+ // Normal paragraph spacing (spaceBelow so paragraphs have visible gaps between them,
669
+ // matching the visual separation expected from markdown-rendered paragraphs).
670
+ // The default Google Docs NORMAL_TEXT style has 0pt spacing, so without this
671
+ // paragraphs would appear crammed together with no gap.
672
+ for (const normalRange of context.normalParagraphRanges) {
673
+ const range = {
674
+ startIndex: normalRange.startIndex,
675
+ endIndex: normalRange.endIndex,
676
+ };
677
+ if (context.tabId) {
678
+ range.tabId = context.tabId;
679
+ }
680
+ context.formatRequests.push({
681
+ updateParagraphStyle: {
682
+ range,
683
+ paragraphStyle: {
684
+ spaceBelow: { magnitude: 8, unit: 'PT' },
685
+ },
686
+ fields: 'spaceBelow',
687
+ },
688
+ });
689
+ }
690
+ // List trailing spacing: apply spaceBelow to the last paragraph of each
691
+ // top-level list so there is a visible gap between the list and the content
692
+ // that follows it.
693
+ for (const listRange of context.listSpacingRanges) {
694
+ const range = {
695
+ startIndex: listRange.startIndex,
696
+ endIndex: listRange.endIndex,
697
+ };
698
+ if (context.tabId) {
699
+ range.tabId = context.tabId;
700
+ }
701
+ context.formatRequests.push({
702
+ updateParagraphStyle: {
703
+ range,
704
+ paragraphStyle: {
705
+ spaceBelow: { magnitude: 8, unit: 'PT' },
706
+ },
707
+ fields: 'spaceBelow',
708
+ },
709
+ });
710
+ }
711
+ // Code block table formatting (1x1 table with background + monospace text)
712
+ for (const codeBlock of context.codeBlockRanges) {
713
+ // The actual table element starts at tableStartIndex + 1 because insertTable
714
+ // auto-inserts a preceding newline at tableStartIndex (see constants comment above).
715
+ const tableStartLocation = { index: codeBlock.tableStartIndex + 1 };
716
+ if (context.tabId)
717
+ tableStartLocation.tabId = context.tabId;
718
+ // Style the text inside the cell as monospace
719
+ if (codeBlock.textEndIndex > codeBlock.textStartIndex) {
720
+ const codeTextStyle = buildUpdateTextStyleRequest(codeBlock.textStartIndex, codeBlock.textEndIndex, { fontFamily: CODE_FONT_FAMILY }, context.tabId);
721
+ if (codeTextStyle) {
722
+ context.formatRequests.push(codeTextStyle.request);
723
+ }
724
+ }
725
+ // Set cell background color to light gray
726
+ const borderStyle = {
727
+ color: { color: { rgbColor: CODE_BLOCK_BORDER_RGB } },
728
+ width: { magnitude: 0.5, unit: 'PT' },
729
+ dashStyle: 'SOLID',
730
+ };
731
+ context.formatRequests.push({
732
+ updateTableCellStyle: {
733
+ tableRange: {
734
+ tableCellLocation: {
735
+ tableStartLocation: tableStartLocation,
736
+ rowIndex: 0,
737
+ columnIndex: 0,
738
+ },
739
+ rowSpan: 1,
740
+ columnSpan: 1,
741
+ },
742
+ tableCellStyle: {
743
+ backgroundColor: { color: { rgbColor: CODE_BLOCK_BG_RGB } },
744
+ paddingTop: { magnitude: 8, unit: 'PT' },
745
+ paddingBottom: { magnitude: 8, unit: 'PT' },
746
+ paddingLeft: { magnitude: 12, unit: 'PT' },
747
+ paddingRight: { magnitude: 12, unit: 'PT' },
748
+ borderTop: borderStyle,
749
+ borderBottom: borderStyle,
750
+ borderLeft: borderStyle,
751
+ borderRight: borderStyle,
752
+ },
753
+ fields: 'backgroundColor,paddingTop,paddingBottom,paddingLeft,paddingRight,borderTop,borderBottom,borderLeft,borderRight',
754
+ },
755
+ });
756
+ }
757
+ // Horizontal rule styling (bottom border on empty paragraphs)
758
+ for (const hrRange of context.hrRanges) {
759
+ const range = {
760
+ startIndex: hrRange.startIndex,
761
+ endIndex: hrRange.endIndex,
762
+ };
763
+ if (context.tabId) {
764
+ range.tabId = context.tabId;
765
+ }
766
+ context.formatRequests.push({
767
+ updateParagraphStyle: {
768
+ range,
769
+ paragraphStyle: {
770
+ borderBottom: {
771
+ color: {
772
+ color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
773
+ },
774
+ width: { magnitude: 1, unit: 'PT' },
775
+ padding: { magnitude: 6, unit: 'PT' },
776
+ dashStyle: 'SOLID',
777
+ },
778
+ },
779
+ fields: 'borderBottom',
780
+ },
781
+ });
782
+ }
783
+ // List formatting: merge *adjacent* items of the same bullet type into single
784
+ // ranges so Google Docs treats them as one list (with sequential numbering).
785
+ // Items are only merged when they're truly adjacent (gap of at most 1 char
786
+ // for the newline between them). Separate lists with paragraphs, headings, or
787
+ // other content between them must NOT be merged, otherwise
788
+ // createParagraphBullets would turn all intervening content into bullets.
789
+ const validListItems = context.pendingListItems
790
+ .filter((item) => item.endIndex !== undefined && item.endIndex > item.startIndex)
791
+ .sort((a, b) => a.startIndex - b.startIndex);
792
+ const mergedListRanges = [];
793
+ for (const item of validListItems) {
794
+ const last = mergedListRanges[mergedListRanges.length - 1];
795
+ if (last && last.bulletPreset === item.bulletPreset && item.startIndex <= last.endIndex + 1) {
796
+ last.endIndex = Math.max(last.endIndex, item.endIndex);
797
+ }
798
+ else {
799
+ mergedListRanges.push({
800
+ startIndex: item.startIndex,
801
+ endIndex: item.endIndex,
802
+ bulletPreset: item.bulletPreset,
803
+ });
804
+ }
805
+ }
806
+ // Apply bottom-to-top to avoid index shifts from tab consumption
807
+ mergedListRanges.sort((a, b) => b.startIndex - a.startIndex);
808
+ for (const merged of mergedListRanges) {
809
+ const rangeLocation = {
810
+ startIndex: merged.startIndex,
811
+ endIndex: merged.endIndex,
812
+ };
813
+ if (context.tabId) {
814
+ rangeLocation.tabId = context.tabId;
815
+ }
816
+ context.formatRequests.push({
817
+ createParagraphBullets: {
818
+ range: rangeLocation,
819
+ bulletPreset: merged.bulletPreset,
820
+ },
821
+ });
822
+ }
823
+ }
824
+ // --- Utility ---
825
+ function getCurrentOpenListItem(context) {
826
+ const openIndex = context.openListItemStack[context.openListItemStack.length - 1];
827
+ if (openIndex === undefined)
828
+ return null;
829
+ return context.pendingListItems[openIndex] ?? null;
830
+ }
831
+ function lastInsertEndsWithNewline(context) {
832
+ const lastInsert = context.insertRequests[context.insertRequests.length - 1]?.insertText?.text;
833
+ return Boolean(lastInsert && lastInsert.endsWith('\n'));
834
+ }