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.
- package/LICENSE +21 -21
- package/README.md +555 -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 +222 -21
- package/dist/markdown-transformer/index.js +19 -9
- package/dist/markdown-transformer/markdownToDocs.js +458 -28
- 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 +13 -1
- 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 +212 -206
- 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 +31 -7
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- 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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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:
|
|
1227
|
+
color: { rgbColor: HR_BORDER_RGB },
|
|
798
1228
|
},
|
|
799
1229
|
width: { magnitude: 1, unit: 'PT' },
|
|
800
1230
|
padding: { magnitude: 6, unit: 'PT' },
|