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