google-tools-mcp 1.2.12 → 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 +537 -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 +128 -0
  10. package/dist/markdown-transformer/index.js +17 -7
  11. package/dist/markdown-transformer/markdownToDocs.js +185 -27
  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 +12 -0
  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 +197 -196
  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 +30 -6
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +71 -8
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -81
@@ -4,6 +4,134 @@
4
4
  * When these are detected on a text run, we render backtick code in markdown.
5
5
  */
6
6
  const CODE_FONT_FAMILIES = new Set(['Roboto Mono', 'Courier New', 'Consolas', 'monospace']);
7
+ /**
8
+ * Inspects the body content that replaceDocumentWithMarkdown will delete and
9
+ * re-insert, and reports anything docsJsonToMarkdown cannot represent at all.
10
+ * Returns an array of human-readable warning strings (empty if the content
11
+ * converts losslessly). Call before replaceDocumentWithMarkdown to warn the AI
12
+ * about what a body replacement will permanently lose.
13
+ *
14
+ * Checked: inline/positioned images and footnote references, neither of which
15
+ * has any markdown representation in either direction of this converter; a
16
+ * generated table of contents; and, generically, every OTHER Docs API
17
+ * `ParagraphElement` variant that `extractFormattedText()` does not render.
18
+ *
19
+ * `extractFormattedText()` only ever renders `textRun` (see below). The Docs
20
+ * API `ParagraphElement` union also defines `autoText`, `pageBreak`,
21
+ * `columnBreak`, `horizontalRule`, `equation`, `person`, `richLink`, and
22
+ * `dateElement` (https://developers.google.com/workspace/docs/api/reference/rest/v1/documents#ParagraphElement).
23
+ * None of those has a markdown representation, so a body replacement deletes
24
+ * them permanently and the current markdown output never mentions they were
25
+ * there. Rather than enumerate that list (and silently miss whatever variant
26
+ * Google adds next), this scanner is deny-by-default: any paragraph-element
27
+ * key other than `startIndex`/`endIndex` (position metadata) and the three
28
+ * keys explicitly handled elsewhere in this function (`textRun`, rendered;
29
+ * `inlineObjectElement`/`footnoteReference`, already counted above) is
30
+ * treated as an unhandled content variant and reported by name.
31
+ *
32
+ * NOT checked (intentionally): custom text/highlight colors and non-default
33
+ * paragraph alignment. Those round-trip losslessly through the rich-markdown
34
+ * HTML extensions this converter emits by default (`<span style="color:...">`,
35
+ * `<p align="...">`, table alignment markers) and their inverse parsing in
36
+ * markdownToDocs.js, so warning about them here would be inaccurate.
37
+ *
38
+ * IMPORTANT — accuracy: every warning is derived from `bodyContent` itself,
39
+ * i.e. the exact body (a specific tab's body in tab mode, the document body
40
+ * otherwise) that the replacement mutates. This deliberately does NOT inspect
41
+ * document-level `headers`/`footers`, which are separate document segments a
42
+ * body-content replacement does not delete, nor the global `inlineObjects`/
43
+ * `footnotes` maps, which can include content belonging to OTHER tabs that a
44
+ * scoped replacement never touches.
45
+ *
46
+ * @param {Array} bodyContent structural elements of the body being replaced
47
+ * (e.g. `contentSource.body.content`).
48
+ * @returns {string[]} warnings
49
+ */
50
+ // ParagraphElement keys this converter already accounts for: startIndex/
51
+ // endIndex are position metadata (not content); textRun is rendered by
52
+ // extractFormattedText(); inlineObjectElement/footnoteReference are counted
53
+ // as their own dedicated warnings below. Any other key on a ParagraphElement
54
+ // is, by the Docs API's own union contract, an unhandled content-bearing
55
+ // variant (autoText, pageBreak, columnBreak, horizontalRule, equation,
56
+ // person, richLink, dateElement, or a future addition) that extractFormattedText
57
+ // silently drops.
58
+ const HANDLED_PARAGRAPH_ELEMENT_KEYS = new Set([
59
+ 'startIndex',
60
+ 'endIndex',
61
+ 'textRun',
62
+ 'inlineObjectElement',
63
+ 'footnoteReference',
64
+ ]);
65
+ export function checkMarkdownFidelity(bodyContent) {
66
+ const warnings = [];
67
+ let imageCount = 0;
68
+ let footnoteCount = 0;
69
+ let tocCount = 0;
70
+ const unhandledElementCounts = {};
71
+ function scanParagraphElements(elements) {
72
+ for (const pe of elements) {
73
+ // Inline images embedded in the body flow — deleted with the body.
74
+ if (pe.inlineObjectElement) {
75
+ imageCount++;
76
+ }
77
+ // Footnote references live in the body; deleting the body removes them
78
+ // (and Docs then drops the orphaned footnote).
79
+ if (pe.footnoteReference) {
80
+ footnoteCount++;
81
+ }
82
+ // Deny-by-default: anything that isn't a key we explicitly handle is an
83
+ // unhandled ParagraphElement variant. Grouped and named below rather
84
+ // than warning per element.
85
+ for (const key of Object.keys(pe)) {
86
+ if (!HANDLED_PARAGRAPH_ELEMENT_KEYS.has(key)) {
87
+ unhandledElementCounts[key] = (unhandledElementCounts[key] ?? 0) + 1;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ function scanBodyContent(content) {
93
+ for (const element of content) {
94
+ if (element.paragraph) {
95
+ // Positioned (floating) images anchored to this paragraph are also
96
+ // removed when the paragraph is deleted.
97
+ if (Array.isArray(element.paragraph.positionedObjectIds)) {
98
+ imageCount += element.paragraph.positionedObjectIds.length;
99
+ }
100
+ scanParagraphElements(element.paragraph.elements ?? []);
101
+ }
102
+ else if (element.table) {
103
+ for (const row of (element.table.tableRows ?? [])) {
104
+ for (const cell of (row.tableCells ?? [])) {
105
+ scanBodyContent(cell.content ?? []);
106
+ }
107
+ }
108
+ }
109
+ // A generated table of contents sits in the body, so the replacement
110
+ // deletes it, and markdown has no way to express one, so the importer
111
+ // cannot put it back. Without this the whole round trip drops the TOC
112
+ // with nothing said about it.
113
+ else if (element.tableOfContents) {
114
+ tocCount++;
115
+ }
116
+ }
117
+ }
118
+ scanBodyContent(bodyContent ?? []);
119
+ if (imageCount > 0) {
120
+ warnings.push(`${imageCount} image(s) — will be removed`);
121
+ }
122
+ if (footnoteCount > 0) {
123
+ warnings.push(`${footnoteCount} footnote(s) — will be removed`);
124
+ }
125
+ if (tocCount > 0) {
126
+ warnings.push(`${tocCount} table(s) of contents — will be removed (markdown cannot express a generated TOC; reinsert it in Docs afterward)`);
127
+ }
128
+ const unhandledKeys = Object.keys(unhandledElementCounts).sort();
129
+ if (unhandledKeys.length > 0) {
130
+ const parts = unhandledKeys.map((key) => `${unhandledElementCounts[key]} ${key}`).join(', ');
131
+ warnings.push(`${parts} — unsupported content type(s) with no markdown representation; will be removed`);
132
+ }
133
+ return warnings;
134
+ }
7
135
  // --- Main Conversion ---
8
136
  /**
9
137
  * Converts Google Docs JSON structure to a markdown string.
@@ -12,24 +12,31 @@
12
12
  import { docsJsonToMarkdown } from './docsToMarkdown.js';
13
13
  import { convertMarkdownToRequests } from './markdownToDocs.js';
14
14
  import { executeBatchUpdateWithSplitting, findTabById } from '../googleDocsApiHelpers.js';
15
- export { docsJsonToMarkdown } from './docsToMarkdown.js';
15
+ export { docsJsonToMarkdown, checkMarkdownFidelity } from './docsToMarkdown.js';
16
16
  /** Formats InsertMarkdownResult into a concise human-readable debug summary. */
17
17
  export function formatInsertResult(result) {
18
18
  const lines = [];
19
+ if (result.warnings?.length) {
20
+ lines.push('WARNINGS (content dropped):');
21
+ for (const warning of result.warnings) {
22
+ lines.push(` - ${warning}`);
23
+ }
24
+ lines.push('');
25
+ }
19
26
  lines.push(`Markdown insert completed in ${result.totalElapsedMs}ms`);
20
27
  lines.push(` Parse: ${result.parseElapsedMs}ms`);
21
28
  lines.push(` Requests: ${result.totalRequests} total (${Object.entries(result.requestsByType)
22
29
  .map(([k, v]) => `${v} ${k}`)
23
30
  .join(', ')})`);
24
31
  lines.push(` API calls: ${result.batchUpdate.totalApiCalls} batchUpdate calls in ${result.batchUpdate.totalElapsedMs}ms`);
25
- const { phases } = result.batchUpdate;
26
- if (phases.delete.requests > 0) {
32
+ const phases = result.batchUpdate.phases;
33
+ if (phases?.delete?.requests > 0) {
27
34
  lines.push(` Delete phase: ${phases.delete.requests} requests, ${phases.delete.apiCalls} calls, ${phases.delete.elapsedMs}ms`);
28
35
  }
29
- if (phases.insert.requests > 0) {
36
+ if (phases?.insert?.requests > 0) {
30
37
  lines.push(` Insert phase: ${phases.insert.requests} requests, ${phases.insert.apiCalls} calls, ${phases.insert.elapsedMs}ms`);
31
38
  }
32
- if (phases.format.requests > 0) {
39
+ if (phases?.format?.requests > 0) {
33
40
  lines.push(` Format phase: ${phases.format.requests} requests, ${phases.format.apiCalls} calls, ${phases.format.elapsedMs}ms`);
34
41
  }
35
42
  return lines.join('\n');
@@ -85,6 +92,7 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
85
92
  const overallStart = performance.now();
86
93
  const startIndex = options?.startIndex ?? 1;
87
94
  const tabId = options?.tabId;
95
+ const writeControl = options?.writeControl;
88
96
  // Fetch the document's default text style so we can explicitly set
89
97
  // foreground color on inserted text (fixes issue #14 — text without
90
98
  // explicit color shows "no color selected" in the Docs color picker).
@@ -109,7 +117,7 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
109
117
  ...(options?.firstHeadingAsTitle && { firstHeadingAsTitle: true }),
110
118
  ...(defaultForegroundColor && { defaultForegroundColor }),
111
119
  };
112
- const requests = convertMarkdownToRequests(markdown, startIndex, tabId, conversionOptions);
120
+ const { requests, warnings } = convertMarkdownToRequests(markdown, startIndex, tabId, conversionOptions);
113
121
  const parseElapsedMs = Math.round(performance.now() - parseStart);
114
122
  // Count requests by type
115
123
  const requestsByType = {};
@@ -119,6 +127,7 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
119
127
  }
120
128
  if (requests.length === 0) {
121
129
  return {
130
+ warnings,
122
131
  totalRequests: 0,
123
132
  requestsByType,
124
133
  parseElapsedMs,
@@ -135,8 +144,9 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
135
144
  totalElapsedMs: Math.round(performance.now() - overallStart),
136
145
  };
137
146
  }
138
- const batchUpdate = await executeBatchUpdateWithSplitting(docs, documentId, requests);
147
+ const batchUpdate = await executeBatchUpdateWithSplitting(docs, documentId, requests, undefined, writeControl);
139
148
  return {
149
+ warnings,
140
150
  totalRequests: requests.length,
141
151
  requestsByType,
142
152
  parseElapsedMs,
@@ -47,34 +47,119 @@ function alignmentToDocs(value) {
47
47
  return null;
48
48
  }
49
49
  }
50
- function parseStyleDeclarations(style) {
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;
51
83
  const formatting = {};
84
+ const unhandled = [];
52
85
  if (!style)
53
- return formatting;
86
+ return { formatting, unhandled };
54
87
  for (const declaration of style.split(';')) {
55
88
  const [rawName, ...rawValueParts] = declaration.split(':');
56
89
  if (!rawName || rawValueParts.length === 0)
57
90
  continue;
58
91
  const name = rawName.trim().toLowerCase();
59
92
  const value = rawValueParts.join(':').trim();
60
- if ((name === 'color' || name === 'foreground-color') && /^#[0-9a-f]{6}$/i.test(value)) {
61
- formatting.foregroundColor = value;
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;
62
128
  }
63
- else if ((name === 'background-color' || name === 'background') && /^#[0-9a-f]{6}$/i.test(value)) {
64
- formatting.backgroundColor = value;
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;
65
139
  }
66
- else if (name === 'font-size') {
67
- const match = value.match(/^(\d+(?:\.\d+)?)pt$/i);
68
- if (match)
69
- formatting.fontSize = Number(match[1]);
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`;
70
155
  }
71
- else if (name === 'font-family') {
72
- const family = value.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '');
73
- if (family && /^[\w .-]+$/.test(family))
74
- formatting.fontFamily = family;
156
+ else {
157
+ detail = STYLE_PROPERTY_HINTS[property]
158
+ ? `expected ${STYLE_PROPERTY_HINTS[property]}`
159
+ : 'this CSS property is not supported';
75
160
  }
161
+ addWarning(context, `Dropped unsupported style declaration "${property}: ${value}" on <${tag}> (${detail}); the formatting was not applied.`);
76
162
  }
77
- return formatting;
78
163
  }
79
164
  function parseRichHtmlTag(rawHtml) {
80
165
  const trimmed = rawHtml.trim();
@@ -104,6 +189,10 @@ const CODE_BACKGROUND_HEX = '#F1F3F4';
104
189
  // These constants define the visual style for programmatically created code blocks.
105
190
  const CODE_BLOCK_BG_RGB = { red: 0.937, green: 0.945, blue: 0.953 }; // #EFF1F3
106
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
107
196
  // IMPORTANT: The Google Docs API always inserts a newline character ("\n") BEFORE
108
197
  // the table when processing an insertTable request. So calling insertTable at index T
109
198
  // produces the following document structure:
@@ -134,11 +223,13 @@ const EMPTY_1x1_TABLE_SIZE = 6;
134
223
  * @param startIndex - The document index where content should be inserted (1-based)
135
224
  * @param tabId - Optional tab ID for multi-tab documents
136
225
  * @param options - Optional conversion options (e.g. firstHeadingAsTitle)
137
- * @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.
138
229
  */
139
230
  export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, options) {
140
231
  if (!markdown || markdown.trim().length === 0) {
141
- return [];
232
+ return { requests: [], warnings: [] };
142
233
  }
143
234
  const parser = createParser();
144
235
  const tokens = parser.parse(markdown, {});
@@ -161,6 +252,8 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
161
252
  inTableCell: false,
162
253
  paragraphFormattingStack: [],
163
254
  htmlParagraphPushStack: [],
255
+ htmlSpanPushStack: [],
256
+ warningCounts: new Map(),
164
257
  tabId,
165
258
  titleConsumed: false,
166
259
  firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
@@ -171,7 +264,10 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
171
264
  processToken(token, context);
172
265
  }
173
266
  finalizeFormatting(context);
174
- return [...context.insertRequests, ...context.formatRequests];
267
+ return {
268
+ requests: [...context.insertRequests, ...context.formatRequests],
269
+ warnings: collectWarnings(context),
270
+ };
175
271
  }
176
272
  catch (error) {
177
273
  if (error instanceof MarkdownConversionError) {
@@ -204,6 +300,12 @@ function processToken(token, context) {
204
300
  case 'code_inline':
205
301
  handleCodeInlineToken(token, context);
206
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
+ }
207
309
  // Inline formatting
208
310
  case 'strong_open':
209
311
  context.formattingStack.push({ bold: true });
@@ -306,7 +408,10 @@ function processToken(token, context) {
306
408
  case 'th_open':
307
409
  case 'td_open': {
308
410
  if (context.tableState) {
309
- const alignment = parseAlignmentFromStyle(getAttr(token, 'style'));
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);
310
415
  context.tableState.currentCell = {
311
416
  text: '',
312
417
  isHeader: context.tableState.inHeader || token.type === 'th_open',
@@ -357,6 +462,9 @@ function processToken(token, context) {
357
462
  handleHtmlBlockToken(token, context);
358
463
  break;
359
464
  default:
465
+ if (token.content?.trim()) {
466
+ addWarning(context, `Dropped unsupported markdown token "${token.type}" containing "${summarizeContent(token.content)}".`);
467
+ }
360
468
  break;
361
469
  }
362
470
  }
@@ -396,11 +504,15 @@ function handleHorizontalRule(context) {
396
504
  }
397
505
  function handleHtmlInlineToken(token, context) {
398
506
  const parsed = parseRichHtmlTag(token.content);
399
- if (!parsed)
507
+ if (!parsed) {
508
+ addWarning(context, `Dropped unsupported inline HTML "${summarizeContent(token.content)}".`);
400
509
  return;
510
+ }
401
511
  const attrs = parseAttrs(parsed.attrs);
402
- if (parsed.selfClosing && parsed.tag !== 'br')
512
+ if (parsed.selfClosing && parsed.tag !== 'br') {
513
+ addWarning(context, `Dropped unsupported inline HTML <${parsed.tag}>.`);
403
514
  return;
515
+ }
404
516
  if (parsed.closing) {
405
517
  switch (parsed.tag) {
406
518
  case 'u':
@@ -409,9 +521,12 @@ function handleHtmlInlineToken(token, context) {
409
521
  case 'mark':
410
522
  popFormatting(context, 'backgroundColor');
411
523
  break;
412
- case 'span':
413
- popFormatting(context, 'richSpan');
524
+ case 'span': {
525
+ const pushed = context.htmlSpanPushStack.pop();
526
+ if (pushed)
527
+ popFormatting(context, 'richSpan');
414
528
  break;
529
+ }
415
530
  case 'p':
416
531
  case 'div': {
417
532
  const pushed = context.htmlParagraphPushStack.pop();
@@ -422,6 +537,8 @@ function handleHtmlInlineToken(token, context) {
422
537
  case 'blockquote':
423
538
  context.paragraphFormattingStack.pop();
424
539
  break;
540
+ default:
541
+ addWarning(context, `Ignored unsupported inline HTML tag </${parsed.tag}>; its text content was preserved where possible.`);
425
542
  }
426
543
  return;
427
544
  }
@@ -433,16 +550,23 @@ function handleHtmlInlineToken(token, context) {
433
550
  context.formattingStack.push({ backgroundColor: '#FFF2CC' });
434
551
  break;
435
552
  case 'span': {
436
- const formatting = parseStyleDeclarations(attrs.style);
553
+ const { formatting, unhandled } = parseStyleDeclarations(attrs.style);
554
+ addUnhandledStyleWarnings(context, 'span', unhandled);
437
555
  if (hasFormatting(formatting)) {
438
556
  formatting.richSpan = true;
439
557
  context.formattingStack.push(formatting);
558
+ context.htmlSpanPushStack.push(true);
559
+ }
560
+ else {
561
+ context.htmlSpanPushStack.push(false);
440
562
  }
441
563
  break;
442
564
  }
443
565
  case 'p':
444
566
  case 'div': {
445
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);
446
570
  if (alignment) {
447
571
  context.paragraphFormattingStack.push({ alignment });
448
572
  context.htmlParagraphPushStack.push(true);
@@ -461,13 +585,18 @@ function handleHtmlInlineToken(token, context) {
461
585
  case 'br':
462
586
  insertText('\n', context);
463
587
  break;
588
+ default:
589
+ addWarning(context, `Ignored unsupported inline HTML tag <${parsed.tag}>; its text content was preserved where possible.`);
464
590
  }
465
591
  }
466
592
  function handleHtmlBlockToken(token, context) {
467
593
  const content = token.content.trim();
468
594
  const match = content.match(/^<\s*(p|div|blockquote)\b([^>]*)>([\s\S]*)<\/\s*\1\s*>$/i);
469
- if (!match)
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)}".`);
470
598
  return;
599
+ }
471
600
  const tag = match[1].toLowerCase();
472
601
  const attrs = parseAttrs(match[2]);
473
602
  const inner = match[3];
@@ -481,6 +610,8 @@ function handleHtmlBlockToken(token, context) {
481
610
  }
482
611
  else {
483
612
  const alignment = alignmentToDocs(attrs.align ?? '') ?? parseAlignmentFromStyle(attrs.style);
613
+ const { unhandled } = parseStyleDeclarations(attrs.style, { allowAlignment: true, allowRunFormatting: false });
614
+ addUnhandledStyleWarnings(context, tag, unhandled);
484
615
  if (alignment) {
485
616
  context.paragraphFormattingStack.push({ alignment });
486
617
  pushedParagraphFormatting = true;
@@ -494,6 +625,22 @@ function handleHtmlBlockToken(token, context) {
494
625
  context.paragraphFormattingStack.pop();
495
626
  }
496
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
+ }
497
644
  // --- Paragraph Handlers ---
498
645
  function handleParagraphOpen(context) {
499
646
  if (context.listStack.length === 0) {
@@ -1051,7 +1198,18 @@ function finalizeFormatting(context) {
1051
1198
  },
1052
1199
  });
1053
1200
  }
1054
- // 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.
1055
1213
  for (const hrRange of context.hrRanges) {
1056
1214
  const range = {
1057
1215
  startIndex: hrRange.startIndex,
@@ -1066,7 +1224,7 @@ function finalizeFormatting(context) {
1066
1224
  paragraphStyle: {
1067
1225
  borderBottom: {
1068
1226
  color: {
1069
- color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
1227
+ color: { rgbColor: HR_BORDER_RGB },
1070
1228
  },
1071
1229
  width: { magnitude: 1, unit: 'PT' },
1072
1230
  padding: { magnitude: 6, unit: 'PT' },