mcp-google-extras 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/LICENSE +15 -0
- package/README.md +80 -0
- package/dist/auth.js +225 -0
- package/dist/cachedToolsList.js +38 -0
- package/dist/clients.js +92 -0
- package/dist/googleDocsApiHelpers.js +883 -0
- package/dist/googleSheetsApiHelpers.js +808 -0
- package/dist/index.js +54 -0
- package/dist/logger.js +58 -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 +26 -0
- package/dist/tools/drive/listFolderContents.js +82 -0
- package/dist/tools/drive/listGoogleDocs.js +67 -0
- package/dist/tools/drive/moveFile.js +54 -0
- package/dist/tools/drive/renameFile.js +39 -0
- package/dist/tools/drive/searchGoogleDocs.js +73 -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/index.js +15 -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 +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/index.ts
|
|
3
|
+
//
|
|
4
|
+
// Single entry point for the Google Docs MCP Server.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// mcp-google-extras Start the MCP server (default)
|
|
8
|
+
// mcp-google-extras auth Run the interactive OAuth flow
|
|
9
|
+
import { FastMCP } from 'fastmcp';
|
|
10
|
+
import { buildCachedToolsListPayload, collectToolsWhileRegistering, installCachedToolsListHandler, } from './cachedToolsList.js';
|
|
11
|
+
import { initializeGoogleClient } from './clients.js';
|
|
12
|
+
import { registerAllTools } from './tools/index.js';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
// --- Auth subcommand ---
|
|
15
|
+
if (process.argv[2] === 'auth') {
|
|
16
|
+
const { runAuthFlow } = await import('./auth.js');
|
|
17
|
+
try {
|
|
18
|
+
await runAuthFlow();
|
|
19
|
+
logger.info('Authorization complete. You can now start the MCP server.');
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
logger.error('Authorization failed:', error.message || error);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// --- Server startup ---
|
|
28
|
+
// Set up process-level unhandled error/rejection handlers to prevent crashes
|
|
29
|
+
process.on('uncaughtException', (error) => {
|
|
30
|
+
logger.error('Uncaught Exception:', error);
|
|
31
|
+
});
|
|
32
|
+
process.on('unhandledRejection', (reason, _promise) => {
|
|
33
|
+
logger.error('Unhandled Promise Rejection:', reason);
|
|
34
|
+
});
|
|
35
|
+
const server = new FastMCP({
|
|
36
|
+
name: 'mcp-google-extras',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
});
|
|
39
|
+
const registeredTools = [];
|
|
40
|
+
collectToolsWhileRegistering(server, registeredTools);
|
|
41
|
+
registerAllTools(server);
|
|
42
|
+
try {
|
|
43
|
+
await initializeGoogleClient();
|
|
44
|
+
logger.info('Starting Ultimate Google Docs & Sheets MCP server...');
|
|
45
|
+
const cachedToolsList = await buildCachedToolsListPayload(registeredTools);
|
|
46
|
+
await server.start({ transportType: 'stdio' });
|
|
47
|
+
installCachedToolsListHandler(server, cachedToolsList);
|
|
48
|
+
logger.info('MCP Server running using stdio. Awaiting client connection...');
|
|
49
|
+
logger.info('Process-level error handling configured to prevent crashes from timeout errors.');
|
|
50
|
+
}
|
|
51
|
+
catch (startError) {
|
|
52
|
+
logger.error('FATAL: Server failed to start:', startError.message || startError);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/logger.ts
|
|
2
|
+
//
|
|
3
|
+
// Centralized logger with LOG_LEVEL support.
|
|
4
|
+
//
|
|
5
|
+
// Log levels (from most to least verbose):
|
|
6
|
+
// debug - Detailed diagnostic info (text range mapping, request building, etc.)
|
|
7
|
+
// info - General operational messages (server started, auth succeeded, etc.)
|
|
8
|
+
// warn - Potentially harmful situations (missing content, fallback behavior)
|
|
9
|
+
// error - Error conditions (API failures, auth failures, etc.)
|
|
10
|
+
//
|
|
11
|
+
// Set via the LOG_LEVEL environment variable. Defaults to "info".
|
|
12
|
+
// Example: LOG_LEVEL=debug npm start
|
|
13
|
+
//
|
|
14
|
+
// MCP servers communicate over stdout, so all log output goes to stderr.
|
|
15
|
+
const LOG_LEVELS = {
|
|
16
|
+
debug: 0,
|
|
17
|
+
info: 1,
|
|
18
|
+
warn: 2,
|
|
19
|
+
error: 3,
|
|
20
|
+
silent: 4,
|
|
21
|
+
};
|
|
22
|
+
function resolveLevel() {
|
|
23
|
+
const env = process.env.LOG_LEVEL?.toLowerCase();
|
|
24
|
+
if (env && env in LOG_LEVELS) {
|
|
25
|
+
return env;
|
|
26
|
+
}
|
|
27
|
+
return 'info';
|
|
28
|
+
}
|
|
29
|
+
let currentLevel = resolveLevel();
|
|
30
|
+
/** Re-read LOG_LEVEL from the environment (useful for testing). */
|
|
31
|
+
export function refreshLogLevel() {
|
|
32
|
+
currentLevel = resolveLevel();
|
|
33
|
+
}
|
|
34
|
+
function shouldLog(level) {
|
|
35
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
36
|
+
}
|
|
37
|
+
export const logger = {
|
|
38
|
+
debug(...args) {
|
|
39
|
+
if (shouldLog('debug')) {
|
|
40
|
+
console.error('[DEBUG]', ...args);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
info(...args) {
|
|
44
|
+
if (shouldLog('info')) {
|
|
45
|
+
console.error('[INFO]', ...args);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
warn(...args) {
|
|
49
|
+
if (shouldLog('warn')) {
|
|
50
|
+
console.error('[WARN]', ...args);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
error(...args) {
|
|
54
|
+
if (shouldLog('error')) {
|
|
55
|
+
console.error('[ERROR]', ...args);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// src/markdown-transformer/docsToMarkdown.ts
|
|
2
|
+
/**
|
|
3
|
+
* Font families used by the markdown-to-docs direction for code styling.
|
|
4
|
+
* When these are detected on a text run, we render backtick code in markdown.
|
|
5
|
+
*/
|
|
6
|
+
const CODE_FONT_FAMILIES = new Set(['Roboto Mono', 'Courier New', 'Consolas', 'monospace']);
|
|
7
|
+
// --- Main Conversion ---
|
|
8
|
+
/**
|
|
9
|
+
* Converts Google Docs JSON structure to a markdown string.
|
|
10
|
+
*
|
|
11
|
+
* Accepts the raw response from `docs.documents.get()`, or a subset with
|
|
12
|
+
* `{ body, lists }` (e.g. when extracting a specific tab).
|
|
13
|
+
*
|
|
14
|
+
* Handles headings, paragraphs, text formatting (bold, italic, strikethrough,
|
|
15
|
+
* underline, links, code), ordered & unordered lists with nesting, tables,
|
|
16
|
+
* and section breaks.
|
|
17
|
+
*/
|
|
18
|
+
export function docsJsonToMarkdown(docData) {
|
|
19
|
+
const body = docData.body;
|
|
20
|
+
if (!body?.content) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const lists = docData.lists ?? {};
|
|
24
|
+
let markdown = '';
|
|
25
|
+
for (const element of body.content) {
|
|
26
|
+
if (element.paragraph) {
|
|
27
|
+
markdown += convertParagraph(element.paragraph, lists);
|
|
28
|
+
}
|
|
29
|
+
else if (element.table) {
|
|
30
|
+
markdown += convertTable(element.table);
|
|
31
|
+
}
|
|
32
|
+
else if (element.sectionBreak) {
|
|
33
|
+
markdown += '\n---\n\n';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return markdown.trim();
|
|
37
|
+
}
|
|
38
|
+
// --- Paragraph Conversion ---
|
|
39
|
+
function convertParagraph(paragraph, lists) {
|
|
40
|
+
// 1. Determine paragraph type
|
|
41
|
+
const headingLevel = getHeadingLevel(paragraph);
|
|
42
|
+
const listInfo = getListInfo(paragraph, lists);
|
|
43
|
+
// 2. Extract text content with inline formatting
|
|
44
|
+
const elements = paragraph.elements ?? [];
|
|
45
|
+
const text = extractFormattedText(elements);
|
|
46
|
+
// 3. Format based on type
|
|
47
|
+
if (headingLevel && text.trim()) {
|
|
48
|
+
const hashes = '#'.repeat(Math.min(headingLevel, 6));
|
|
49
|
+
return `${hashes} ${text.trim()}\n\n`;
|
|
50
|
+
}
|
|
51
|
+
if (listInfo && text.trim()) {
|
|
52
|
+
const indent = ' '.repeat(listInfo.nestingLevel);
|
|
53
|
+
const marker = listInfo.ordered ? `1.` : `-`;
|
|
54
|
+
return `${indent}${marker} ${text.trim()}\n`;
|
|
55
|
+
}
|
|
56
|
+
if (text.trim()) {
|
|
57
|
+
return `${text.trim()}\n\n`;
|
|
58
|
+
}
|
|
59
|
+
return '\n';
|
|
60
|
+
}
|
|
61
|
+
// --- Heading Detection ---
|
|
62
|
+
function getHeadingLevel(paragraph) {
|
|
63
|
+
const styleType = paragraph.paragraphStyle?.namedStyleType;
|
|
64
|
+
if (!styleType)
|
|
65
|
+
return null;
|
|
66
|
+
if (styleType === 'TITLE')
|
|
67
|
+
return 1;
|
|
68
|
+
if (styleType === 'SUBTITLE')
|
|
69
|
+
return 2;
|
|
70
|
+
const match = styleType.match(/^HEADING_(\d)$/);
|
|
71
|
+
return match ? parseInt(match[1], 10) : null;
|
|
72
|
+
}
|
|
73
|
+
function getListInfo(paragraph, lists) {
|
|
74
|
+
if (!paragraph.bullet)
|
|
75
|
+
return null;
|
|
76
|
+
const nestingLevel = paragraph.bullet.nestingLevel ?? 0;
|
|
77
|
+
const listId = paragraph.bullet.listId;
|
|
78
|
+
let ordered = false;
|
|
79
|
+
if (listId && lists[listId]?.listProperties?.nestingLevels) {
|
|
80
|
+
const nestingLevels = lists[listId].listProperties.nestingLevels;
|
|
81
|
+
const level = nestingLevels[nestingLevel];
|
|
82
|
+
if (level) {
|
|
83
|
+
// glyphType is set for ordered lists (e.g., DECIMAL, ALPHA, ROMAN)
|
|
84
|
+
// glyphSymbol is set for unordered lists (e.g., bullet characters)
|
|
85
|
+
// If glyphType is present and not empty, it's ordered
|
|
86
|
+
if (level.glyphType && level.glyphType !== 'GLYPH_TYPE_UNSPECIFIED') {
|
|
87
|
+
ordered = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ordered, nestingLevel };
|
|
92
|
+
}
|
|
93
|
+
// --- Text Run Conversion ---
|
|
94
|
+
function extractFormattedText(elements) {
|
|
95
|
+
let result = '';
|
|
96
|
+
for (const element of elements) {
|
|
97
|
+
if (element.textRun) {
|
|
98
|
+
result += convertTextRun(element.textRun);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
function convertTextRun(textRun) {
|
|
104
|
+
let text = textRun.content ?? '';
|
|
105
|
+
const style = textRun.textStyle;
|
|
106
|
+
if (!style)
|
|
107
|
+
return text;
|
|
108
|
+
// Detect code-styled text (monospace font) -- wrap in backticks and skip
|
|
109
|
+
// other formatting since markdown code spans don't support nested formatting.
|
|
110
|
+
if (isCodeStyled(style)) {
|
|
111
|
+
const trimmed = text.replace(/\n$/, '');
|
|
112
|
+
if (trimmed) {
|
|
113
|
+
return `\`${trimmed}\`${text.endsWith('\n') ? '\n' : ''}`;
|
|
114
|
+
}
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
// Strip trailing newline before applying formatting markers, then re-add.
|
|
118
|
+
// This prevents markers from wrapping the newline (e.g., "**text\n**").
|
|
119
|
+
const trailingNewline = text.endsWith('\n');
|
|
120
|
+
const content = trailingNewline ? text.slice(0, -1) : text;
|
|
121
|
+
if (!content)
|
|
122
|
+
return text;
|
|
123
|
+
let formatted = content;
|
|
124
|
+
// Apply inline formatting (bold + italic combined, or individually)
|
|
125
|
+
if (style.bold && style.italic) {
|
|
126
|
+
formatted = `***${formatted}***`;
|
|
127
|
+
}
|
|
128
|
+
else if (style.bold) {
|
|
129
|
+
formatted = `**${formatted}**`;
|
|
130
|
+
}
|
|
131
|
+
else if (style.italic) {
|
|
132
|
+
formatted = `*${formatted}*`;
|
|
133
|
+
}
|
|
134
|
+
if (style.strikethrough) {
|
|
135
|
+
formatted = `~~${formatted}~~`;
|
|
136
|
+
}
|
|
137
|
+
if (style.underline && !style.link) {
|
|
138
|
+
formatted = `<u>${formatted}</u>`;
|
|
139
|
+
}
|
|
140
|
+
if (style.link?.url) {
|
|
141
|
+
formatted = `[${formatted}](${style.link.url})`;
|
|
142
|
+
}
|
|
143
|
+
return formatted + (trailingNewline ? '\n' : '');
|
|
144
|
+
}
|
|
145
|
+
function isCodeStyled(style) {
|
|
146
|
+
const fontFamily = style.weightedFontFamily?.fontFamily;
|
|
147
|
+
return typeof fontFamily === 'string' && CODE_FONT_FAMILIES.has(fontFamily);
|
|
148
|
+
}
|
|
149
|
+
// --- Table Conversion ---
|
|
150
|
+
function convertTable(table) {
|
|
151
|
+
if (!table.tableRows || table.tableRows.length === 0) {
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
// Detect code block tables (1x1 table with monospace font or gray background)
|
|
155
|
+
if (isCodeBlockTable(table)) {
|
|
156
|
+
return convertCodeBlockTable(table);
|
|
157
|
+
}
|
|
158
|
+
let markdown = '\n';
|
|
159
|
+
let isFirstRow = true;
|
|
160
|
+
for (const row of table.tableRows) {
|
|
161
|
+
if (!row.tableCells)
|
|
162
|
+
continue;
|
|
163
|
+
let rowText = '|';
|
|
164
|
+
for (const cell of row.tableCells) {
|
|
165
|
+
const cellText = extractCellText(cell);
|
|
166
|
+
rowText += ` ${cellText} |`;
|
|
167
|
+
}
|
|
168
|
+
markdown += rowText + '\n';
|
|
169
|
+
// Add header separator after the first row
|
|
170
|
+
if (isFirstRow) {
|
|
171
|
+
let separator = '|';
|
|
172
|
+
for (let i = 0; i < row.tableCells.length; i++) {
|
|
173
|
+
separator += ' --- |';
|
|
174
|
+
}
|
|
175
|
+
markdown += separator + '\n';
|
|
176
|
+
isFirstRow = false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return markdown + '\n';
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Detects if a table is a code block (1x1 table with monospace font or gray background).
|
|
183
|
+
* Google Docs "Code Block" building blocks are represented as styled 1x1 tables.
|
|
184
|
+
*/
|
|
185
|
+
function isCodeBlockTable(table) {
|
|
186
|
+
// Must be a 1x1 table
|
|
187
|
+
if (!table.tableRows || table.tableRows.length !== 1)
|
|
188
|
+
return false;
|
|
189
|
+
const row = table.tableRows[0];
|
|
190
|
+
if (!row.tableCells || row.tableCells.length !== 1)
|
|
191
|
+
return false;
|
|
192
|
+
const cell = row.tableCells[0];
|
|
193
|
+
// Check for gray/colored background on the cell
|
|
194
|
+
const cellStyle = cell.tableCellStyle;
|
|
195
|
+
if (cellStyle?.backgroundColor?.color?.rgbColor) {
|
|
196
|
+
const bg = cellStyle.backgroundColor.color.rgbColor;
|
|
197
|
+
// Detect light gray backgrounds (typical of code blocks)
|
|
198
|
+
// Allow a range of light grays
|
|
199
|
+
const r = bg.red ?? 0;
|
|
200
|
+
const g = bg.green ?? 0;
|
|
201
|
+
const b = bg.blue ?? 0;
|
|
202
|
+
if (r > 0.85 && g > 0.85 && b > 0.85 && r < 1 && g < 1 && b < 1) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Check for monospace font in cell content
|
|
207
|
+
if (cell.content) {
|
|
208
|
+
for (const element of cell.content) {
|
|
209
|
+
if (element.paragraph?.elements) {
|
|
210
|
+
for (const pe of element.paragraph.elements) {
|
|
211
|
+
if (pe.textRun?.textStyle) {
|
|
212
|
+
if (isCodeStyled(pe.textRun.textStyle)) {
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Converts a code block table (1x1 table) to a fenced markdown code block.
|
|
224
|
+
*/
|
|
225
|
+
function convertCodeBlockTable(table) {
|
|
226
|
+
const cell = table.tableRows[0].tableCells[0];
|
|
227
|
+
let codeText = '';
|
|
228
|
+
if (cell.content) {
|
|
229
|
+
for (const element of cell.content) {
|
|
230
|
+
if (element.paragraph?.elements) {
|
|
231
|
+
for (const pe of element.paragraph.elements) {
|
|
232
|
+
if (pe.textRun?.content) {
|
|
233
|
+
codeText += pe.textRun.content;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Remove trailing newline (cells always end with one)
|
|
240
|
+
if (codeText.endsWith('\n')) {
|
|
241
|
+
codeText = codeText.slice(0, -1);
|
|
242
|
+
}
|
|
243
|
+
return '\n```\n' + codeText + '\n```\n\n';
|
|
244
|
+
}
|
|
245
|
+
function extractCellText(cell) {
|
|
246
|
+
let text = '';
|
|
247
|
+
if (!cell.content)
|
|
248
|
+
return text;
|
|
249
|
+
for (const element of cell.content) {
|
|
250
|
+
if (element.paragraph?.elements) {
|
|
251
|
+
for (const pe of element.paragraph.elements) {
|
|
252
|
+
if (pe.textRun?.content) {
|
|
253
|
+
text += pe.textRun.content.replace(/\n/g, ' ').trim();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return text;
|
|
259
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/markdown-transformer/index.ts
|
|
2
|
+
//
|
|
3
|
+
// Public API for bidirectional markdown <-> Google Docs conversion.
|
|
4
|
+
//
|
|
5
|
+
// Main methods:
|
|
6
|
+
// extractMarkdown() - Fetch a Google Doc and return its content as markdown
|
|
7
|
+
// insertMarkdown() - Convert markdown and insert it into a Google Doc
|
|
8
|
+
//
|
|
9
|
+
// Helper:
|
|
10
|
+
// docsJsonToMarkdown() - Convert already-fetched Docs JSON to markdown
|
|
11
|
+
//
|
|
12
|
+
import { docsJsonToMarkdown } from './docsToMarkdown.js';
|
|
13
|
+
import { convertMarkdownToRequests } from './markdownToDocs.js';
|
|
14
|
+
import { executeBatchUpdateWithSplitting, findTabById } from '../googleDocsApiHelpers.js';
|
|
15
|
+
export { docsJsonToMarkdown } from './docsToMarkdown.js';
|
|
16
|
+
/** Formats InsertMarkdownResult into a concise human-readable debug summary. */
|
|
17
|
+
export function formatInsertResult(result) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push(`Markdown insert completed in ${result.totalElapsedMs}ms`);
|
|
20
|
+
lines.push(` Parse: ${result.parseElapsedMs}ms`);
|
|
21
|
+
lines.push(` Requests: ${result.totalRequests} total (${Object.entries(result.requestsByType)
|
|
22
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
23
|
+
.join(', ')})`);
|
|
24
|
+
lines.push(` API calls: ${result.batchUpdate.totalApiCalls} batchUpdate calls in ${result.batchUpdate.totalElapsedMs}ms`);
|
|
25
|
+
const { phases } = result.batchUpdate;
|
|
26
|
+
if (phases.delete.requests > 0) {
|
|
27
|
+
lines.push(` Delete phase: ${phases.delete.requests} requests, ${phases.delete.apiCalls} calls, ${phases.delete.elapsedMs}ms`);
|
|
28
|
+
}
|
|
29
|
+
if (phases.insert.requests > 0) {
|
|
30
|
+
lines.push(` Insert phase: ${phases.insert.requests} requests, ${phases.insert.apiCalls} calls, ${phases.insert.elapsedMs}ms`);
|
|
31
|
+
}
|
|
32
|
+
if (phases.format.requests > 0) {
|
|
33
|
+
lines.push(` Format phase: ${phases.format.requests} requests, ${phases.format.apiCalls} calls, ${phases.format.elapsedMs}ms`);
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
// --- extractMarkdown ---
|
|
38
|
+
/**
|
|
39
|
+
* Fetches a Google Document and returns its content as a markdown string.
|
|
40
|
+
*
|
|
41
|
+
* @param docs - An authenticated Google Docs API client
|
|
42
|
+
* @param documentId - The document ID (from the URL)
|
|
43
|
+
* @param options - Optional: tabId to target a specific tab
|
|
44
|
+
* @returns The document content as markdown
|
|
45
|
+
*/
|
|
46
|
+
export async function extractMarkdown(docs, documentId, options) {
|
|
47
|
+
const tabId = options?.tabId;
|
|
48
|
+
const res = await docs.documents.get({
|
|
49
|
+
documentId,
|
|
50
|
+
includeTabsContent: !!tabId,
|
|
51
|
+
fields: tabId ? '*' : '*',
|
|
52
|
+
});
|
|
53
|
+
if (tabId) {
|
|
54
|
+
const targetTab = findTabById(res.data, tabId);
|
|
55
|
+
if (!targetTab) {
|
|
56
|
+
throw new Error(`Tab with ID "${tabId}" not found in document.`);
|
|
57
|
+
}
|
|
58
|
+
if (!targetTab.documentTab) {
|
|
59
|
+
throw new Error(`Tab "${tabId}" does not have content (may not be a document tab).`);
|
|
60
|
+
}
|
|
61
|
+
return docsJsonToMarkdown({
|
|
62
|
+
body: targetTab.documentTab.body,
|
|
63
|
+
lists: targetTab.documentTab.lists,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return docsJsonToMarkdown({
|
|
67
|
+
body: res.data.body,
|
|
68
|
+
lists: res.data.lists,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// --- insertMarkdown ---
|
|
72
|
+
/**
|
|
73
|
+
* Converts markdown to Google Docs formatting and inserts it into a document.
|
|
74
|
+
*
|
|
75
|
+
* Handles the full pipeline: markdown parsing, request generation, and batch
|
|
76
|
+
* execution against the Docs API. Callers never see raw API requests.
|
|
77
|
+
*
|
|
78
|
+
* @param docs - An authenticated Google Docs API client
|
|
79
|
+
* @param documentId - The document ID
|
|
80
|
+
* @param markdown - The markdown content to insert
|
|
81
|
+
* @param options - Optional: startIndex (default 1), tabId
|
|
82
|
+
* @returns Debug metadata about the operation (request counts, timing, API calls)
|
|
83
|
+
*/
|
|
84
|
+
export async function insertMarkdown(docs, documentId, markdown, options) {
|
|
85
|
+
const overallStart = performance.now();
|
|
86
|
+
const startIndex = options?.startIndex ?? 1;
|
|
87
|
+
const tabId = options?.tabId;
|
|
88
|
+
const parseStart = performance.now();
|
|
89
|
+
const conversionOptions = options?.firstHeadingAsTitle
|
|
90
|
+
? { firstHeadingAsTitle: true }
|
|
91
|
+
: undefined;
|
|
92
|
+
const requests = convertMarkdownToRequests(markdown, startIndex, tabId, conversionOptions);
|
|
93
|
+
const parseElapsedMs = Math.round(performance.now() - parseStart);
|
|
94
|
+
// Count requests by type
|
|
95
|
+
const requestsByType = {};
|
|
96
|
+
for (const r of requests) {
|
|
97
|
+
const type = Object.keys(r)[0];
|
|
98
|
+
requestsByType[type] = (requestsByType[type] || 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
if (requests.length === 0) {
|
|
101
|
+
return {
|
|
102
|
+
totalRequests: 0,
|
|
103
|
+
requestsByType,
|
|
104
|
+
parseElapsedMs,
|
|
105
|
+
batchUpdate: {
|
|
106
|
+
totalRequests: 0,
|
|
107
|
+
phases: {
|
|
108
|
+
delete: { requests: 0, apiCalls: 0, elapsedMs: 0 },
|
|
109
|
+
insert: { requests: 0, apiCalls: 0, elapsedMs: 0 },
|
|
110
|
+
format: { requests: 0, apiCalls: 0, elapsedMs: 0 },
|
|
111
|
+
},
|
|
112
|
+
totalApiCalls: 0,
|
|
113
|
+
totalElapsedMs: 0,
|
|
114
|
+
},
|
|
115
|
+
totalElapsedMs: Math.round(performance.now() - overallStart),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const batchUpdate = await executeBatchUpdateWithSplitting(docs, documentId, requests);
|
|
119
|
+
return {
|
|
120
|
+
totalRequests: requests.length,
|
|
121
|
+
requestsByType,
|
|
122
|
+
parseElapsedMs,
|
|
123
|
+
batchUpdate,
|
|
124
|
+
totalElapsedMs: Math.round(performance.now() - overallStart),
|
|
125
|
+
};
|
|
126
|
+
}
|