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
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Gmail message processing helpers.
|
|
2
|
+
// Ported from @shinzolabs/gmail-mcp with minimal changes.
|
|
3
|
+
|
|
4
|
+
const RESPONSE_HEADERS_LIST = [
|
|
5
|
+
'Date',
|
|
6
|
+
'From',
|
|
7
|
+
'To',
|
|
8
|
+
'Subject',
|
|
9
|
+
'Message-ID',
|
|
10
|
+
'In-Reply-To',
|
|
11
|
+
'References'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const decodedBody = (body) => {
|
|
15
|
+
if (!body?.data) return body;
|
|
16
|
+
const decodedData = Buffer.from(body.data, 'base64').toString('utf-8');
|
|
17
|
+
return {
|
|
18
|
+
data: decodedData,
|
|
19
|
+
size: body.data.length,
|
|
20
|
+
attachmentId: body.attachmentId
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const processMessagePart = (messagePart, includeBodyHtml = false) => {
|
|
25
|
+
if ((messagePart.mimeType !== 'text/html' || includeBodyHtml) && messagePart.body) {
|
|
26
|
+
messagePart.body = decodedBody(messagePart.body);
|
|
27
|
+
}
|
|
28
|
+
if (messagePart.parts) {
|
|
29
|
+
messagePart.parts = messagePart.parts.map(part => processMessagePart(part, includeBodyHtml));
|
|
30
|
+
}
|
|
31
|
+
if (messagePart.headers) {
|
|
32
|
+
messagePart.headers = messagePart.headers.filter(header => RESPONSE_HEADERS_LIST.includes(header.name || ''));
|
|
33
|
+
}
|
|
34
|
+
return messagePart;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const getNestedHistory = (messagePart, level = 1) => {
|
|
38
|
+
if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
|
|
39
|
+
const { data } = decodedBody(messagePart.body);
|
|
40
|
+
if (!data) return '';
|
|
41
|
+
return data.split('\n').map(line => '>' + (line.startsWith('>') ? '' : ' ') + line).join('\n');
|
|
42
|
+
}
|
|
43
|
+
return (messagePart.parts || []).map(p => getNestedHistory(p, level + 1)).filter(p => p).join('\n');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const findHeader = (headers, name) => {
|
|
47
|
+
if (!headers || !Array.isArray(headers) || !name) return undefined;
|
|
48
|
+
return headers.find(h => h?.name?.toLowerCase() === name.toLowerCase())?.value ?? undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const formatEmailList = (emailList) => {
|
|
52
|
+
if (!emailList) return [];
|
|
53
|
+
return emailList.split(',').map(email => email.trim());
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getQuotedContent = (thread) => {
|
|
57
|
+
if (!thread.messages?.length) return '';
|
|
58
|
+
const sentMessages = thread.messages.filter(msg =>
|
|
59
|
+
msg.labelIds?.includes('SENT') ||
|
|
60
|
+
(!msg.labelIds?.includes('DRAFT') && findHeader(msg.payload?.headers || [], 'date'))
|
|
61
|
+
);
|
|
62
|
+
if (!sentMessages.length) return '';
|
|
63
|
+
const lastMessage = sentMessages[sentMessages.length - 1];
|
|
64
|
+
if (!lastMessage?.payload) return '';
|
|
65
|
+
let quotedContent = [];
|
|
66
|
+
if (lastMessage.payload.headers) {
|
|
67
|
+
const fromHeader = findHeader(lastMessage.payload.headers || [], 'from');
|
|
68
|
+
const dateHeader = findHeader(lastMessage.payload.headers || [], 'date');
|
|
69
|
+
if (fromHeader && dateHeader) {
|
|
70
|
+
quotedContent.push('');
|
|
71
|
+
quotedContent.push(`On ${dateHeader} ${fromHeader} wrote:`);
|
|
72
|
+
quotedContent.push('');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const nestedHistory = getNestedHistory(lastMessage.payload);
|
|
76
|
+
if (nestedHistory) {
|
|
77
|
+
quotedContent.push(nestedHistory);
|
|
78
|
+
quotedContent.push('');
|
|
79
|
+
}
|
|
80
|
+
return quotedContent.join('\n');
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const getThreadHeaders = (thread) => {
|
|
84
|
+
let headers = [];
|
|
85
|
+
if (!thread.messages?.length) return headers;
|
|
86
|
+
const lastMessage = thread.messages[thread.messages.length - 1];
|
|
87
|
+
const references = [];
|
|
88
|
+
let subjectHeader = findHeader(lastMessage.payload?.headers || [], 'subject');
|
|
89
|
+
if (subjectHeader) {
|
|
90
|
+
if (!subjectHeader.toLowerCase().startsWith('re:')) {
|
|
91
|
+
subjectHeader = `Re: ${subjectHeader}`;
|
|
92
|
+
}
|
|
93
|
+
headers.push(`Subject: ${subjectHeader}`);
|
|
94
|
+
}
|
|
95
|
+
const messageIdHeader = findHeader(lastMessage.payload?.headers || [], 'message-id');
|
|
96
|
+
if (messageIdHeader) {
|
|
97
|
+
headers.push(`In-Reply-To: ${messageIdHeader}`);
|
|
98
|
+
references.push(messageIdHeader);
|
|
99
|
+
}
|
|
100
|
+
const referencesHeader = findHeader(lastMessage.payload?.headers || [], 'references');
|
|
101
|
+
if (referencesHeader) references.unshift(...referencesHeader.split(' '));
|
|
102
|
+
if (references.length > 0) headers.push(`References: ${references.join(' ')}`);
|
|
103
|
+
return headers;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const wrapTextBody = (text) => text.split('\n').map(line => {
|
|
107
|
+
if (line.length <= 76) return line;
|
|
108
|
+
const chunks = line.match(/.{1,76}/g) || [];
|
|
109
|
+
return chunks.join('=\n');
|
|
110
|
+
}).join('\n');
|
|
111
|
+
|
|
112
|
+
export const constructRawMessage = async (gmail, params) => {
|
|
113
|
+
let thread = null;
|
|
114
|
+
if (params.threadId) {
|
|
115
|
+
const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
|
|
116
|
+
thread = data;
|
|
117
|
+
}
|
|
118
|
+
const message = [];
|
|
119
|
+
if (params.to?.length) message.push(`To: ${wrapTextBody(params.to.join(', '))}`);
|
|
120
|
+
if (params.cc?.length) message.push(`Cc: ${wrapTextBody(params.cc.join(', '))}`);
|
|
121
|
+
if (params.bcc?.length) message.push(`Bcc: ${wrapTextBody(params.bcc.join(', '))}`);
|
|
122
|
+
if (thread) {
|
|
123
|
+
message.push(...getThreadHeaders(thread).map(header => wrapTextBody(header)));
|
|
124
|
+
} else if (params.subject) {
|
|
125
|
+
message.push(`Subject: ${wrapTextBody(params.subject)}`);
|
|
126
|
+
} else {
|
|
127
|
+
message.push('Subject: (No Subject)');
|
|
128
|
+
}
|
|
129
|
+
message.push('Content-Type: text/plain; charset="UTF-8"');
|
|
130
|
+
message.push('Content-Transfer-Encoding: quoted-printable');
|
|
131
|
+
message.push('MIME-Version: 1.0');
|
|
132
|
+
message.push('');
|
|
133
|
+
if (params.body) message.push(wrapTextBody(params.body));
|
|
134
|
+
if (thread) {
|
|
135
|
+
const quotedContent = getQuotedContent(thread);
|
|
136
|
+
if (quotedContent) {
|
|
137
|
+
message.push('');
|
|
138
|
+
message.push(wrapTextBody(quotedContent));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Buffer.from(message.join('\r\n')).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const constructRawMessageWithAttachments = async (gmail, params) => {
|
|
145
|
+
let thread = null;
|
|
146
|
+
if (params.threadId) {
|
|
147
|
+
const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
|
|
148
|
+
thread = data;
|
|
149
|
+
}
|
|
150
|
+
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
151
|
+
const headers = [];
|
|
152
|
+
if (params.to?.length) headers.push(`To: ${params.to.join(', ')}`);
|
|
153
|
+
if (params.cc?.length) headers.push(`Cc: ${params.cc.join(', ')}`);
|
|
154
|
+
if (params.bcc?.length) headers.push(`Bcc: ${params.bcc.join(', ')}`);
|
|
155
|
+
if (thread) {
|
|
156
|
+
headers.push(...getThreadHeaders(thread));
|
|
157
|
+
} else if (params.subject) {
|
|
158
|
+
headers.push(`Subject: ${params.subject}`);
|
|
159
|
+
} else {
|
|
160
|
+
headers.push('Subject: (No Subject)');
|
|
161
|
+
}
|
|
162
|
+
headers.push('MIME-Version: 1.0');
|
|
163
|
+
headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
164
|
+
const parts = [];
|
|
165
|
+
// Text body part
|
|
166
|
+
let bodyText = params.body || '';
|
|
167
|
+
if (thread) {
|
|
168
|
+
const quotedContent = getQuotedContent(thread);
|
|
169
|
+
if (quotedContent) bodyText += '\n\n' + quotedContent;
|
|
170
|
+
}
|
|
171
|
+
parts.push([
|
|
172
|
+
`--${boundary}`,
|
|
173
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
174
|
+
'Content-Transfer-Encoding: base64',
|
|
175
|
+
'',
|
|
176
|
+
Buffer.from(bodyText).toString('base64'),
|
|
177
|
+
].join('\r\n'));
|
|
178
|
+
// Attachment parts
|
|
179
|
+
for (const att of params.attachments) {
|
|
180
|
+
const attHeaders = [
|
|
181
|
+
`--${boundary}`,
|
|
182
|
+
`Content-Type: ${att.mimeType}; name="${att.filename}"`,
|
|
183
|
+
'Content-Transfer-Encoding: base64',
|
|
184
|
+
`Content-Disposition: attachment; filename="${att.filename}"`,
|
|
185
|
+
'',
|
|
186
|
+
att.base64Data,
|
|
187
|
+
];
|
|
188
|
+
parts.push(attHeaders.join('\r\n'));
|
|
189
|
+
}
|
|
190
|
+
const raw = [
|
|
191
|
+
headers.join('\r\n'),
|
|
192
|
+
'',
|
|
193
|
+
parts.join('\r\n'),
|
|
194
|
+
`--${boundary}--`,
|
|
195
|
+
].join('\r\n');
|
|
196
|
+
return Buffer.from(raw).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const getPlainTextBody = (messagePart) => {
|
|
200
|
+
if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
|
|
201
|
+
return Buffer.from(messagePart.body.data, 'base64').toString('utf-8');
|
|
202
|
+
}
|
|
203
|
+
if (messagePart.parts) {
|
|
204
|
+
for (const part of messagePart.parts) {
|
|
205
|
+
const text = getPlainTextBody(part);
|
|
206
|
+
if (text) return text;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return '';
|
|
210
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// google-tools-mcp — Combined Google Workspace MCP server
|
|
3
|
+
//
|
|
4
|
+
// Provides lazy-loaded tool categories for Drive, Docs, Sheets, and Gmail.
|
|
5
|
+
// Only a discovery tool is exposed at startup; individual tools are loaded on demand.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// google-tools-mcp Start the MCP server (default)
|
|
9
|
+
// google-tools-mcp auth Run the interactive OAuth flow
|
|
10
|
+
import { FastMCP } from 'fastmcp';
|
|
11
|
+
import { collectToolsWhileRegistering, installCachedToolsListHandler } from './cachedToolsList.js';
|
|
12
|
+
import { registerAllTools } from './tools/index.js';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
|
|
15
|
+
// --- Auth subcommand ---
|
|
16
|
+
if (process.argv[2] === 'auth') {
|
|
17
|
+
const { runAuthFlow } = await import('./auth.js');
|
|
18
|
+
try {
|
|
19
|
+
await runAuthFlow();
|
|
20
|
+
logger.info('Authorization complete. You can now start the MCP server.');
|
|
21
|
+
process.exit(0);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logger.error('Authorization failed:', error.message || error);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// --- Server startup ---
|
|
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
|
+
|
|
36
|
+
const server = new FastMCP({
|
|
37
|
+
name: 'google-tools-mcp',
|
|
38
|
+
version: '1.0.0',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const registeredTools = [];
|
|
42
|
+
collectToolsWhileRegistering(server, registeredTools);
|
|
43
|
+
registerAllTools(server);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
logger.info('Starting google-tools-mcp server...');
|
|
47
|
+
await server.start({ transportType: 'stdio' });
|
|
48
|
+
installCachedToolsListHandler(server, registeredTools);
|
|
49
|
+
logger.info('MCP Server running using stdio. Awaiting client connection...');
|
|
50
|
+
logger.info('Google auth will run automatically on first tool call.');
|
|
51
|
+
logger.info(`${registeredTools.length} tools registered at startup (discovery + logout). Call load_google_tools to load more.`);
|
|
52
|
+
} catch (startError) {
|
|
53
|
+
logger.error('FATAL: Server failed to start:', startError.message || startError);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Centralized logger with LOG_LEVEL support.
|
|
2
|
+
// All log output goes to stderr (stdout reserved for MCP protocol).
|
|
3
|
+
const LOG_LEVELS = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
silent: 4,
|
|
9
|
+
};
|
|
10
|
+
function resolveLevel() {
|
|
11
|
+
const env = process.env.LOG_LEVEL?.toLowerCase();
|
|
12
|
+
if (env && env in LOG_LEVELS) {
|
|
13
|
+
return env;
|
|
14
|
+
}
|
|
15
|
+
return 'info';
|
|
16
|
+
}
|
|
17
|
+
let currentLevel = resolveLevel();
|
|
18
|
+
export function refreshLogLevel() {
|
|
19
|
+
currentLevel = resolveLevel();
|
|
20
|
+
}
|
|
21
|
+
function shouldLog(level) {
|
|
22
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
23
|
+
}
|
|
24
|
+
export const logger = {
|
|
25
|
+
debug(...args) {
|
|
26
|
+
if (shouldLog('debug')) {
|
|
27
|
+
console.error('[DEBUG]', ...args);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
info(...args) {
|
|
31
|
+
if (shouldLog('info')) {
|
|
32
|
+
console.error('[INFO]', ...args);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
warn(...args) {
|
|
36
|
+
if (shouldLog('warn')) {
|
|
37
|
+
console.error('[WARN]', ...args);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
error(...args) {
|
|
41
|
+
if (shouldLog('error')) {
|
|
42
|
+
console.error('[ERROR]', ...args);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -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
|
+
}
|