google-tools-mcp 1.2.11 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
@@ -4,7 +4,7 @@ import { MarkdownConversionError } from '../types.js';
4
4
  // --- Markdown-it Setup ---
5
5
  function createParser() {
6
6
  return new MarkdownIt({
7
- html: false,
7
+ html: true,
8
8
  linkify: true,
9
9
  typographer: false,
10
10
  breaks: false,
@@ -23,6 +23,164 @@ function getHeadingLevel(token) {
23
23
  const match = token.tag.match(/h(\d)/);
24
24
  return match ? parseInt(match[1], 10) : null;
25
25
  }
26
+ function getAttr(token, name) {
27
+ return token.attrs?.find((attr) => attr[0].toLowerCase() === name.toLowerCase())?.[1] ?? null;
28
+ }
29
+ function parseAlignmentFromStyle(style) {
30
+ const match = style?.match(/(?:^|;)\s*text-align\s*:\s*(left|center|right|justify)\s*(?:;|$)/i);
31
+ if (!match)
32
+ return null;
33
+ return alignmentToDocs(match[1]);
34
+ }
35
+ function alignmentToDocs(value) {
36
+ switch (value.toLowerCase()) {
37
+ case 'center':
38
+ return 'CENTER';
39
+ case 'right':
40
+ return 'END';
41
+ case 'justify':
42
+ case 'justified':
43
+ return 'JUSTIFIED';
44
+ case 'left':
45
+ return 'START';
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ // Human-readable hints for recognized CSS properties, used when a declaration
51
+ // is dropped so the warning tells the author what format IS supported.
52
+ const STYLE_PROPERTY_HINTS = {
53
+ 'color': 'a 6-digit hex value like #ff0000',
54
+ 'foreground-color': 'a 6-digit hex value like #ff0000',
55
+ 'background-color': 'a 6-digit hex value like #ff0000',
56
+ 'background': 'a 6-digit hex value like #ff0000',
57
+ 'font-size': 'a point value like 12pt',
58
+ 'font-family': 'a plain font name (letters, digits, spaces, hyphens, periods only)',
59
+ 'text-align': 'left, center, right, or justify',
60
+ };
61
+ /**
62
+ * Parses an inline `style="..."` attribute into the subset of formatting this
63
+ * converter understands, AND reports every declaration that could not be
64
+ * applied. This is deny-by-default: nothing is silently dropped without a
65
+ * matching entry in `unhandled` (issue #34 follow-up: recognized tags like
66
+ * <span> were losing unrecognized CSS properties/values with no warning).
67
+ *
68
+ * @param options.allowAlignment - Pass true when the caller separately applies
69
+ * `text-align` at the paragraph level (e.g. <p>/<div>/table cells) so a
70
+ * valid `text-align` isn't double-reported as unhandled here.
71
+ * @param options.allowRunFormatting - Whether color/background-color/font-size/
72
+ * font-family can actually be applied in this context. Only <span> (and other
73
+ * run-level formatting) pushes these onto the character formatting stack; a
74
+ * wrapping <p>/<div>/table cell never applies them to its contained text, so
75
+ * for those callers this must be false -- otherwise a *validly formatted*
76
+ * declaration (e.g. `color:#ff0000` on a <p>) would be marked "handled" even
77
+ * though nothing ever consumes it, recreating the exact silent-drop bug this
78
+ * function exists to prevent. Defaults to true (the <span> case).
79
+ */
80
+ function parseStyleDeclarations(style, options = {}) {
81
+ const allowAlignment = options.allowAlignment === true;
82
+ const allowRunFormatting = options.allowRunFormatting !== false;
83
+ const formatting = {};
84
+ const unhandled = [];
85
+ if (!style)
86
+ return { formatting, unhandled };
87
+ for (const declaration of style.split(';')) {
88
+ const [rawName, ...rawValueParts] = declaration.split(':');
89
+ if (!rawName || rawValueParts.length === 0)
90
+ continue;
91
+ const name = rawName.trim().toLowerCase();
92
+ const value = rawValueParts.join(':').trim();
93
+ if (!value)
94
+ continue;
95
+ const isColor = name === 'color' || name === 'foreground-color';
96
+ const isBackground = name === 'background-color' || name === 'background';
97
+ const isFontSize = name === 'font-size';
98
+ const isFontFamily = name === 'font-family';
99
+ if (isColor || isBackground || isFontSize || isFontFamily) {
100
+ if (!allowRunFormatting) {
101
+ unhandled.push({ property: name, value, reason: 'not-applicable-here' });
102
+ continue;
103
+ }
104
+ if (isColor && /^#[0-9a-f]{6}$/i.test(value)) {
105
+ formatting.foregroundColor = value;
106
+ continue;
107
+ }
108
+ if (isBackground && /^#[0-9a-f]{6}$/i.test(value)) {
109
+ formatting.backgroundColor = value;
110
+ continue;
111
+ }
112
+ if (isFontSize) {
113
+ const match = value.match(/^(\d+(?:\.\d+)?)pt$/i);
114
+ if (match) {
115
+ formatting.fontSize = Number(match[1]);
116
+ continue;
117
+ }
118
+ }
119
+ if (isFontFamily) {
120
+ const family = value.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '');
121
+ if (family && /^[\w .-]+$/.test(family)) {
122
+ formatting.fontFamily = family;
123
+ continue;
124
+ }
125
+ }
126
+ unhandled.push({ property: name, value, reason: 'unsupported-value' });
127
+ continue;
128
+ }
129
+ if (name === 'text-align') {
130
+ const alignment = alignmentToDocs(value);
131
+ if (!alignment) {
132
+ unhandled.push({ property: name, value, reason: 'unsupported-value' });
133
+ continue;
134
+ }
135
+ if (!allowAlignment) {
136
+ unhandled.push({ property: name, value, reason: 'not-applicable-here' });
137
+ }
138
+ continue;
139
+ }
140
+ unhandled.push({ property: name, value, reason: 'unsupported-property' });
141
+ }
142
+ return { formatting, unhandled };
143
+ }
144
+ /**
145
+ * Emits one warning per style declaration that `parseStyleDeclarations`
146
+ * could not apply, so recognized tags never discard formatting silently.
147
+ */
148
+ function addUnhandledStyleWarnings(context, tag, unhandled) {
149
+ for (const { property, value, reason } of unhandled) {
150
+ let detail;
151
+ if (reason === 'not-applicable-here') {
152
+ detail = property === 'text-align'
153
+ ? `text-align is only applied on block-level elements like <p> or <div>, not <${tag}>`
154
+ : `${property} is only applied on inline elements like <span>, not on the <${tag}> wrapper itself`;
155
+ }
156
+ else {
157
+ detail = STYLE_PROPERTY_HINTS[property]
158
+ ? `expected ${STYLE_PROPERTY_HINTS[property]}`
159
+ : 'this CSS property is not supported';
160
+ }
161
+ addWarning(context, `Dropped unsupported style declaration "${property}: ${value}" on <${tag}> (${detail}); the formatting was not applied.`);
162
+ }
163
+ }
164
+ function parseRichHtmlTag(rawHtml) {
165
+ const trimmed = rawHtml.trim();
166
+ const match = trimmed.match(/^<\/?\s*([a-z0-9]+)([^>]*)\/?>$/i);
167
+ if (!match)
168
+ return null;
169
+ const tag = match[1].toLowerCase();
170
+ const attrs = match[2] ?? '';
171
+ const closing = /^<\s*\//.test(trimmed);
172
+ const selfClosing = /\/\s*>$/.test(trimmed);
173
+ return { tag, attrs, closing, selfClosing };
174
+ }
175
+ function parseAttrs(rawAttrs) {
176
+ const attrs = {};
177
+ const attrRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
178
+ let match;
179
+ while ((match = attrRegex.exec(rawAttrs)) !== null) {
180
+ attrs[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
181
+ }
182
+ return attrs;
183
+ }
26
184
  const CODE_FONT_FAMILY = 'Roboto Mono';
27
185
  const CODE_TEXT_HEX = '#188038';
28
186
  const CODE_BACKGROUND_HEX = '#F1F3F4';
@@ -31,6 +189,10 @@ const CODE_BACKGROUND_HEX = '#F1F3F4';
31
189
  // These constants define the visual style for programmatically created code blocks.
32
190
  const CODE_BLOCK_BG_RGB = { red: 0.937, green: 0.945, blue: 0.953 }; // #EFF1F3
33
191
  const CODE_BLOCK_BORDER_RGB = { red: 0.855, green: 0.863, blue: 0.878 }; // #DADCE0
192
+ // Google's native horizontal-rule color, #878787 (rgb 135/255) — measured by
193
+ // rasterizing a PDF export of a doc containing a native (Insert > Horizontal
194
+ // line) rule. See the horizontal rule styling comment below for context.
195
+ const HR_BORDER_RGB = { red: 0.5294117647, green: 0.5294117647, blue: 0.5294117647 }; // #878787
34
196
  // IMPORTANT: The Google Docs API always inserts a newline character ("\n") BEFORE
35
197
  // the table when processing an insertTable request. So calling insertTable at index T
36
198
  // produces the following document structure:
@@ -61,11 +223,13 @@ const EMPTY_1x1_TABLE_SIZE = 6;
61
223
  * @param startIndex - The document index where content should be inserted (1-based)
62
224
  * @param tabId - Optional tab ID for multi-tab documents
63
225
  * @param options - Optional conversion options (e.g. firstHeadingAsTitle)
64
- * @returns Array of Google Docs API requests (insertions first, then formatting)
226
+ * @returns `{ requests, warnings }` — Google Docs API requests (insertions
227
+ * first, then formatting) and human-readable warnings for any markdown
228
+ * constructs that were silently dropped during conversion.
65
229
  */
66
230
  export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, options) {
67
231
  if (!markdown || markdown.trim().length === 0) {
68
- return [];
232
+ return { requests: [], warnings: [] };
69
233
  }
70
234
  const parser = createParser();
71
235
  const tokens = parser.parse(markdown, {});
@@ -86,6 +250,10 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
86
250
  codeBlockRanges: [],
87
251
  tableState: undefined,
88
252
  inTableCell: false,
253
+ paragraphFormattingStack: [],
254
+ htmlParagraphPushStack: [],
255
+ htmlSpanPushStack: [],
256
+ warningCounts: new Map(),
89
257
  tabId,
90
258
  titleConsumed: false,
91
259
  firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
@@ -96,7 +264,10 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
96
264
  processToken(token, context);
97
265
  }
98
266
  finalizeFormatting(context);
99
- return [...context.insertRequests, ...context.formatRequests];
267
+ return {
268
+ requests: [...context.insertRequests, ...context.formatRequests],
269
+ warnings: collectWarnings(context),
270
+ };
100
271
  }
101
272
  catch (error) {
102
273
  if (error instanceof MarkdownConversionError) {
@@ -129,6 +300,12 @@ function processToken(token, context) {
129
300
  case 'code_inline':
130
301
  handleCodeInlineToken(token, context);
131
302
  break;
303
+ case 'image': {
304
+ const alt = token.content || getAttr(token, 'alt') || 'image';
305
+ const src = getAttr(token, 'src') || 'unknown URL';
306
+ addWarning(context, `Dropped image "${alt}" (${src}) — the Docs API path does not support inline images from markdown; use the insertImage tool instead.`);
307
+ break;
308
+ }
132
309
  // Inline formatting
133
310
  case 'strong_open':
134
311
  context.formattingStack.push({ bold: true });
@@ -231,10 +408,15 @@ function processToken(token, context) {
231
408
  case 'th_open':
232
409
  case 'td_open': {
233
410
  if (context.tableState) {
411
+ const cellStyle = getAttr(token, 'style');
412
+ const alignment = parseAlignmentFromStyle(cellStyle);
413
+ const { unhandled } = parseStyleDeclarations(cellStyle, { allowAlignment: true, allowRunFormatting: false });
414
+ addUnhandledStyleWarnings(context, token.type === 'th_open' ? 'th' : 'td', unhandled);
234
415
  context.tableState.currentCell = {
235
416
  text: '',
236
417
  isHeader: context.tableState.inHeader || token.type === 'th_open',
237
418
  textRanges: [],
419
+ alignment,
238
420
  };
239
421
  context.inTableCell = true;
240
422
  }
@@ -264,11 +446,25 @@ function processToken(token, context) {
264
446
  case 'hr':
265
447
  handleHorizontalRule(context);
266
448
  break;
267
- // Blockquotes (skip for now)
268
449
  case 'blockquote_open':
450
+ context.paragraphFormattingStack.push({
451
+ indentStart: 36,
452
+ borderLeft: true,
453
+ });
454
+ break;
269
455
  case 'blockquote_close':
456
+ context.paragraphFormattingStack.pop();
457
+ break;
458
+ case 'html_inline':
459
+ handleHtmlInlineToken(token, context);
460
+ break;
461
+ case 'html_block':
462
+ handleHtmlBlockToken(token, context);
270
463
  break;
271
464
  default:
465
+ if (token.content?.trim()) {
466
+ addWarning(context, `Dropped unsupported markdown token "${token.type}" containing "${summarizeContent(token.content)}".`);
467
+ }
272
468
  break;
273
469
  }
274
470
  }
@@ -306,6 +502,145 @@ function handleHorizontalRule(context) {
306
502
  insertText('\n', context);
307
503
  context.hrRanges.push({ startIndex: start, endIndex: context.currentIndex });
308
504
  }
505
+ function handleHtmlInlineToken(token, context) {
506
+ const parsed = parseRichHtmlTag(token.content);
507
+ if (!parsed) {
508
+ addWarning(context, `Dropped unsupported inline HTML "${summarizeContent(token.content)}".`);
509
+ return;
510
+ }
511
+ const attrs = parseAttrs(parsed.attrs);
512
+ if (parsed.selfClosing && parsed.tag !== 'br') {
513
+ addWarning(context, `Dropped unsupported inline HTML <${parsed.tag}>.`);
514
+ return;
515
+ }
516
+ if (parsed.closing) {
517
+ switch (parsed.tag) {
518
+ case 'u':
519
+ popFormatting(context, 'underline');
520
+ break;
521
+ case 'mark':
522
+ popFormatting(context, 'backgroundColor');
523
+ break;
524
+ case 'span': {
525
+ const pushed = context.htmlSpanPushStack.pop();
526
+ if (pushed)
527
+ popFormatting(context, 'richSpan');
528
+ break;
529
+ }
530
+ case 'p':
531
+ case 'div': {
532
+ const pushed = context.htmlParagraphPushStack.pop();
533
+ if (pushed)
534
+ context.paragraphFormattingStack.pop();
535
+ break;
536
+ }
537
+ case 'blockquote':
538
+ context.paragraphFormattingStack.pop();
539
+ break;
540
+ default:
541
+ addWarning(context, `Ignored unsupported inline HTML tag </${parsed.tag}>; its text content was preserved where possible.`);
542
+ }
543
+ return;
544
+ }
545
+ switch (parsed.tag) {
546
+ case 'u':
547
+ context.formattingStack.push({ underline: true });
548
+ break;
549
+ case 'mark':
550
+ context.formattingStack.push({ backgroundColor: '#FFF2CC' });
551
+ break;
552
+ case 'span': {
553
+ const { formatting, unhandled } = parseStyleDeclarations(attrs.style);
554
+ addUnhandledStyleWarnings(context, 'span', unhandled);
555
+ if (hasFormatting(formatting)) {
556
+ formatting.richSpan = true;
557
+ context.formattingStack.push(formatting);
558
+ context.htmlSpanPushStack.push(true);
559
+ }
560
+ else {
561
+ context.htmlSpanPushStack.push(false);
562
+ }
563
+ break;
564
+ }
565
+ case 'p':
566
+ case 'div': {
567
+ const alignment = alignmentToDocs(attrs.align ?? '') ?? parseAlignmentFromStyle(attrs.style);
568
+ const { unhandled } = parseStyleDeclarations(attrs.style, { allowAlignment: true, allowRunFormatting: false });
569
+ addUnhandledStyleWarnings(context, parsed.tag, unhandled);
570
+ if (alignment) {
571
+ context.paragraphFormattingStack.push({ alignment });
572
+ context.htmlParagraphPushStack.push(true);
573
+ }
574
+ else {
575
+ context.htmlParagraphPushStack.push(false);
576
+ }
577
+ break;
578
+ }
579
+ case 'blockquote':
580
+ context.paragraphFormattingStack.push({
581
+ indentStart: 36,
582
+ borderLeft: true,
583
+ });
584
+ break;
585
+ case 'br':
586
+ insertText('\n', context);
587
+ break;
588
+ default:
589
+ addWarning(context, `Ignored unsupported inline HTML tag <${parsed.tag}>; its text content was preserved where possible.`);
590
+ }
591
+ }
592
+ function handleHtmlBlockToken(token, context) {
593
+ const content = token.content.trim();
594
+ const match = content.match(/^<\s*(p|div|blockquote)\b([^>]*)>([\s\S]*)<\/\s*\1\s*>$/i);
595
+ if (!match) {
596
+ const tag = content.match(/^<\s*([a-z0-9]+)/i)?.[1]?.toLowerCase();
597
+ addWarning(context, `Dropped unsupported HTML block${tag ? ` <${tag}>` : ''} containing "${summarizeContent(content)}".`);
598
+ return;
599
+ }
600
+ const tag = match[1].toLowerCase();
601
+ const attrs = parseAttrs(match[2]);
602
+ const inner = match[3];
603
+ let pushedParagraphFormatting = false;
604
+ if (tag === 'blockquote') {
605
+ context.paragraphFormattingStack.push({
606
+ indentStart: 36,
607
+ borderLeft: true,
608
+ });
609
+ pushedParagraphFormatting = true;
610
+ }
611
+ else {
612
+ const alignment = alignmentToDocs(attrs.align ?? '') ?? parseAlignmentFromStyle(attrs.style);
613
+ const { unhandled } = parseStyleDeclarations(attrs.style, { allowAlignment: true, allowRunFormatting: false });
614
+ addUnhandledStyleWarnings(context, tag, unhandled);
615
+ if (alignment) {
616
+ context.paragraphFormattingStack.push({ alignment });
617
+ pushedParagraphFormatting = true;
618
+ }
619
+ }
620
+ const parser = createParser();
621
+ for (const child of parser.parse(inner, {})) {
622
+ processToken(child, context);
623
+ }
624
+ if (pushedParagraphFormatting) {
625
+ context.paragraphFormattingStack.pop();
626
+ }
627
+ }
628
+ function addWarning(context, warning) {
629
+ // Deduplicate by message but keep a count so repeated drops of the same
630
+ // construct are reported as "(N occurrences)" rather than collapsing to one.
631
+ context.warningCounts.set(warning, (context.warningCounts.get(warning) ?? 0) + 1);
632
+ }
633
+ function collectWarnings(context) {
634
+ const warnings = [];
635
+ for (const [message, count] of context.warningCounts) {
636
+ warnings.push(count > 1 ? `${message} (${count} occurrences)` : message);
637
+ }
638
+ return warnings;
639
+ }
640
+ function summarizeContent(content) {
641
+ const normalized = content.replace(/\s+/g, ' ').trim();
642
+ return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
643
+ }
309
644
  // --- Paragraph Handlers ---
310
645
  function handleParagraphOpen(context) {
311
646
  if (context.listStack.length === 0) {
@@ -329,10 +664,15 @@ function handleParagraphClose(context) {
329
664
  }
330
665
  // Record the range for normal paragraphs (not list items) so we can apply spacing later
331
666
  if (paragraphStart !== undefined && context.listStack.length === 0) {
332
- context.normalParagraphRanges.push({
667
+ const paragraphRange = {
333
668
  startIndex: paragraphStart,
334
669
  endIndex: context.currentIndex,
335
- });
670
+ };
671
+ const paragraphFormatting = mergeParagraphFormattingStack(context.paragraphFormattingStack);
672
+ if (hasParagraphFormatting(paragraphFormatting)) {
673
+ paragraphRange.formatting = paragraphFormatting;
674
+ }
675
+ context.normalParagraphRanges.push(paragraphRange);
336
676
  }
337
677
  context.currentParagraphStart = undefined;
338
678
  }
@@ -548,15 +888,13 @@ function handleTableClose(tableState, context) {
548
888
  if (range.formatting.bold ||
549
889
  range.formatting.italic ||
550
890
  range.formatting.strikethrough ||
551
- range.formatting.code) {
552
- const styleReq = buildUpdateTextStyleRequest(absStart, absEnd, {
553
- bold: range.formatting.bold,
554
- italic: range.formatting.italic,
555
- strikethrough: range.formatting.strikethrough,
556
- fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
557
- foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
558
- backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
559
- }, context.tabId);
891
+ range.formatting.code ||
892
+ range.formatting.underline ||
893
+ range.formatting.foregroundColor ||
894
+ range.formatting.backgroundColor ||
895
+ range.formatting.fontSize ||
896
+ range.formatting.fontFamily) {
897
+ const styleReq = buildUpdateTextStyleRequest(absStart, absEnd, formattingToTextStyle(range.formatting), context.tabId);
560
898
  if (styleReq)
561
899
  context.formatRequests.push(styleReq.request);
562
900
  }
@@ -572,6 +910,11 @@ function handleTableClose(tableState, context) {
572
910
  if (headerStyleReq)
573
911
  context.formatRequests.push(headerStyleReq.request);
574
912
  }
913
+ if (cell.alignment) {
914
+ const alignmentReq = buildUpdateParagraphStyleRequest(adjustedIndex, adjustedIndex + cell.text.length, { alignment: cell.alignment }, context.tabId);
915
+ if (alignmentReq)
916
+ context.formatRequests.push(alignmentReq.request);
917
+ }
575
918
  cumulativeTextLength += cell.text.length;
576
919
  }
577
920
  }
@@ -606,6 +949,18 @@ function mergeFormattingStack(stack) {
606
949
  merged.code = state.code;
607
950
  if (state.link !== undefined)
608
951
  merged.link = state.link;
952
+ if (state.underline !== undefined)
953
+ merged.underline = state.underline;
954
+ if (state.foregroundColor !== undefined)
955
+ merged.foregroundColor = state.foregroundColor;
956
+ if (state.backgroundColor !== undefined)
957
+ merged.backgroundColor = state.backgroundColor;
958
+ if (state.fontSize !== undefined)
959
+ merged.fontSize = state.fontSize;
960
+ if (state.fontFamily !== undefined)
961
+ merged.fontFamily = state.fontFamily;
962
+ if (state.richSpan !== undefined)
963
+ merged.richSpan = state.richSpan;
609
964
  }
610
965
  return merged;
611
966
  }
@@ -614,7 +969,41 @@ function hasFormatting(formatting) {
614
969
  formatting.italic === true ||
615
970
  formatting.strikethrough === true ||
616
971
  formatting.code === true ||
617
- formatting.link !== undefined);
972
+ formatting.link !== undefined ||
973
+ formatting.underline === true ||
974
+ formatting.foregroundColor !== undefined ||
975
+ formatting.backgroundColor !== undefined ||
976
+ formatting.fontSize !== undefined ||
977
+ formatting.fontFamily !== undefined);
978
+ }
979
+ function formattingToTextStyle(formatting) {
980
+ return {
981
+ bold: formatting.bold,
982
+ italic: formatting.italic,
983
+ underline: formatting.underline,
984
+ strikethrough: formatting.strikethrough,
985
+ fontFamily: formatting.code ? CODE_FONT_FAMILY : formatting.fontFamily,
986
+ fontSize: formatting.fontSize,
987
+ foregroundColor: formatting.code ? CODE_TEXT_HEX : formatting.foregroundColor,
988
+ backgroundColor: formatting.code ? CODE_BACKGROUND_HEX : formatting.backgroundColor,
989
+ };
990
+ }
991
+ function mergeParagraphFormattingStack(stack) {
992
+ const merged = {};
993
+ for (const state of stack) {
994
+ if (state.alignment !== undefined)
995
+ merged.alignment = state.alignment;
996
+ if (state.indentStart !== undefined)
997
+ merged.indentStart = state.indentStart;
998
+ if (state.borderLeft !== undefined)
999
+ merged.borderLeft = state.borderLeft;
1000
+ }
1001
+ return merged;
1002
+ }
1003
+ function hasParagraphFormatting(formatting) {
1004
+ return (formatting.alignment !== undefined ||
1005
+ formatting.indentStart !== undefined ||
1006
+ formatting.borderLeft === true);
618
1007
  }
619
1008
  function popFormatting(context, type) {
620
1009
  for (let i = context.formattingStack.length - 1; i >= 0; i--) {
@@ -661,15 +1050,13 @@ function finalizeFormatting(context) {
661
1050
  if (range.formatting.bold ||
662
1051
  range.formatting.italic ||
663
1052
  range.formatting.strikethrough ||
664
- range.formatting.code) {
665
- const styleRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, {
666
- bold: range.formatting.bold,
667
- italic: range.formatting.italic,
668
- strikethrough: range.formatting.strikethrough,
669
- fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
670
- foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
671
- backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
672
- }, context.tabId);
1053
+ range.formatting.code ||
1054
+ range.formatting.underline ||
1055
+ range.formatting.foregroundColor ||
1056
+ range.formatting.backgroundColor ||
1057
+ range.formatting.fontSize ||
1058
+ range.formatting.fontFamily) {
1059
+ const styleRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, formattingToTextStyle(range.formatting), context.tabId);
673
1060
  if (styleRequest) {
674
1061
  context.formatRequests.push(styleRequest.request);
675
1062
  }
@@ -711,6 +1098,38 @@ function finalizeFormatting(context) {
711
1098
  fields: 'spaceBelow',
712
1099
  },
713
1100
  });
1101
+ if (normalRange.formatting) {
1102
+ const paraStyle = {};
1103
+ const fields = [];
1104
+ if (normalRange.formatting.alignment) {
1105
+ paraStyle.alignment = normalRange.formatting.alignment;
1106
+ fields.push('alignment');
1107
+ }
1108
+ if (normalRange.formatting.indentStart !== undefined) {
1109
+ paraStyle.indentStart = { magnitude: normalRange.formatting.indentStart, unit: 'PT' };
1110
+ fields.push('indentStart');
1111
+ }
1112
+ if (normalRange.formatting.borderLeft) {
1113
+ paraStyle.borderLeft = {
1114
+ color: {
1115
+ color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
1116
+ },
1117
+ width: { magnitude: 2, unit: 'PT' },
1118
+ padding: { magnitude: 6, unit: 'PT' },
1119
+ dashStyle: 'SOLID',
1120
+ };
1121
+ fields.push('borderLeft');
1122
+ }
1123
+ if (fields.length > 0) {
1124
+ context.formatRequests.push({
1125
+ updateParagraphStyle: {
1126
+ range,
1127
+ paragraphStyle: paraStyle,
1128
+ fields: fields.join(','),
1129
+ },
1130
+ });
1131
+ }
1132
+ }
714
1133
  }
715
1134
  // List trailing spacing: apply spaceBelow to the last paragraph of each
716
1135
  // top-level list so there is a visible gap between the list and the content
@@ -779,7 +1198,18 @@ function finalizeFormatting(context) {
779
1198
  },
780
1199
  });
781
1200
  }
782
- // Horizontal rule styling (bottom border on empty paragraphs)
1201
+ // Horizontal rule styling (bottom border on empty paragraphs).
1202
+ //
1203
+ // The Google Docs REST API cannot insert a true native horizontal rule
1204
+ // (the `horizontalRule` ParagraphElement produced by Insert > Horizontal
1205
+ // line); that remains an open API limitation
1206
+ // (https://issuetracker.google.com/issues/152996327). A bottom border on
1207
+ // an empty paragraph is the closest achievable emulation.
1208
+ //
1209
+ // Color/weight are matched to Google's native rule, which renders at
1210
+ // #878787 (rgb 135/255) and 1pt thick — measured by rasterizing a PDF
1211
+ // export of a doc containing native rules. The previous value (0.75 grey,
1212
+ // #bfbfbf) rendered noticeably lighter than the real thing.
783
1213
  for (const hrRange of context.hrRanges) {
784
1214
  const range = {
785
1215
  startIndex: hrRange.startIndex,
@@ -794,7 +1224,7 @@ function finalizeFormatting(context) {
794
1224
  paragraphStyle: {
795
1225
  borderBottom: {
796
1226
  color: {
797
- color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
1227
+ color: { rgbColor: HR_BORDER_RGB },
798
1228
  },
799
1229
  width: { magnitude: 1, unit: 'PT' },
800
1230
  padding: { magnitude: 6, unit: 'PT' },