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.
- package/LICENSE +21 -21
- package/README.md +537 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +128 -0
- package/dist/markdown-transformer/index.js +17 -7
- package/dist/markdown-transformer/markdownToDocs.js +185 -27
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +12 -0
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +197 -196
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +30 -6
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +71 -8
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- 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
|
|
26
|
-
if (phases
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
1227
|
+
color: { rgbColor: HR_BORDER_RGB },
|
|
1070
1228
|
},
|
|
1071
1229
|
width: { magnitude: 1, unit: 'PT' },
|
|
1072
1230
|
padding: { magnitude: 6, unit: 'PT' },
|