@brainwavesio/google-docs-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/.repomix/bundles.json +3 -0
- package/LICENSE +7 -0
- package/README.md +588 -0
- package/SAMPLE_TASKS.md +230 -0
- package/assets/google.docs.mcp.1.gif +0 -0
- package/claude.md +46 -0
- package/docs/index.html +228 -0
- package/google docs mcp.mp4 +0 -0
- package/index.js +6 -0
- package/package.json +40 -0
- package/pages/pages.md +0 -0
- package/repomix-output.txt.xml +4447 -0
- package/src/auth.ts +228 -0
- package/src/backup/auth.ts.bak +101 -0
- package/src/backup/server.ts.bak +481 -0
- package/src/googleDocsApiHelpers.ts +710 -0
- package/src/googleSheetsApiHelpers.ts +427 -0
- package/src/server.ts +2494 -0
- package/src/types.ts +136 -0
- package/tests/helpers.test.js +164 -0
- package/tests/types.test.js +69 -0
- package/tsconfig.json +17 -0
- package/vscode.md +168 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// src/googleDocsApiHelpers.ts
|
|
2
|
+
import { google, docs_v1 } from 'googleapis';
|
|
3
|
+
import { OAuth2Client } from 'google-auth-library';
|
|
4
|
+
import { UserError } from 'fastmcp';
|
|
5
|
+
import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js';
|
|
6
|
+
|
|
7
|
+
type Docs = docs_v1.Docs; // Alias for convenience
|
|
8
|
+
|
|
9
|
+
// --- Constants ---
|
|
10
|
+
const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
|
|
11
|
+
|
|
12
|
+
// --- Core Helper to Execute Batch Updates ---
|
|
13
|
+
export async function executeBatchUpdate(docs: Docs, documentId: string, requests: docs_v1.Schema$Request[]): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
|
|
14
|
+
if (!requests || requests.length === 0) {
|
|
15
|
+
// console.warn("executeBatchUpdate called with no requests.");
|
|
16
|
+
return {}; // Nothing to do
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// TODO: Consider splitting large request arrays into multiple batches if needed
|
|
20
|
+
if (requests.length > MAX_BATCH_UPDATE_REQUESTS) {
|
|
21
|
+
console.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const response = await docs.documents.batchUpdate({
|
|
26
|
+
documentId: documentId,
|
|
27
|
+
requestBody: { requests },
|
|
28
|
+
});
|
|
29
|
+
return response.data;
|
|
30
|
+
} catch (error: any) {
|
|
31
|
+
console.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
|
|
32
|
+
// Translate common API errors to UserErrors
|
|
33
|
+
if (error.code === 400 && error.message.includes('Invalid requests')) {
|
|
34
|
+
// Try to extract more specific info if available
|
|
35
|
+
const details = error.response?.data?.error?.details;
|
|
36
|
+
let detailMsg = '';
|
|
37
|
+
if (details && Array.isArray(details)) {
|
|
38
|
+
detailMsg = details.map(d => d.description || JSON.stringify(d)).join('; ');
|
|
39
|
+
}
|
|
40
|
+
throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`);
|
|
41
|
+
}
|
|
42
|
+
if (error.code === 404) throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
|
|
43
|
+
if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
|
|
44
|
+
// Generic internal error for others
|
|
45
|
+
throw new Error(`Google API Error (${error.code}): ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Text Finding Helper ---
|
|
51
|
+
// This improved version is more robust in handling various text structure scenarios
|
|
52
|
+
export async function findTextRange(docs: Docs, documentId: string, textToFind: string, instance: number = 1): Promise<{ startIndex: number; endIndex: number } | null> {
|
|
53
|
+
try {
|
|
54
|
+
// Request more detailed information about the document structure
|
|
55
|
+
const res = await docs.documents.get({
|
|
56
|
+
documentId,
|
|
57
|
+
// Request more fields to handle various container types (not just paragraphs)
|
|
58
|
+
fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!res.data.body?.content) {
|
|
62
|
+
console.warn(`No content found in document ${documentId}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// More robust text collection and index tracking
|
|
67
|
+
let fullText = '';
|
|
68
|
+
const segments: { text: string, start: number, end: number }[] = [];
|
|
69
|
+
|
|
70
|
+
// Process all content elements, including structural ones
|
|
71
|
+
const collectTextFromContent = (content: any[]) => {
|
|
72
|
+
content.forEach(element => {
|
|
73
|
+
// Handle paragraph elements
|
|
74
|
+
if (element.paragraph?.elements) {
|
|
75
|
+
element.paragraph.elements.forEach((pe: any) => {
|
|
76
|
+
if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
|
|
77
|
+
const content = pe.textRun.content;
|
|
78
|
+
fullText += content;
|
|
79
|
+
segments.push({
|
|
80
|
+
text: content,
|
|
81
|
+
start: pe.startIndex,
|
|
82
|
+
end: pe.endIndex
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Handle table elements - this is simplified and might need expansion
|
|
89
|
+
if (element.table && element.table.tableRows) {
|
|
90
|
+
element.table.tableRows.forEach((row: any) => {
|
|
91
|
+
if (row.tableCells) {
|
|
92
|
+
row.tableCells.forEach((cell: any) => {
|
|
93
|
+
if (cell.content) {
|
|
94
|
+
collectTextFromContent(cell.content);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add handling for other structural elements as needed
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
collectTextFromContent(res.data.body.content);
|
|
106
|
+
|
|
107
|
+
// Sort segments by starting position to ensure correct ordering
|
|
108
|
+
segments.sort((a, b) => a.start - b.start);
|
|
109
|
+
|
|
110
|
+
console.log(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
|
|
111
|
+
|
|
112
|
+
// Find the specified instance of the text
|
|
113
|
+
let startIndex = -1;
|
|
114
|
+
let endIndex = -1;
|
|
115
|
+
let foundCount = 0;
|
|
116
|
+
let searchStartIndex = 0;
|
|
117
|
+
|
|
118
|
+
while (foundCount < instance) {
|
|
119
|
+
const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
|
|
120
|
+
if (currentIndex === -1) {
|
|
121
|
+
console.log(`Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
foundCount++;
|
|
126
|
+
console.log(`Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text`);
|
|
127
|
+
|
|
128
|
+
if (foundCount === instance) {
|
|
129
|
+
const targetStartInFullText = currentIndex;
|
|
130
|
+
const targetEndInFullText = currentIndex + textToFind.length;
|
|
131
|
+
let currentPosInFullText = 0;
|
|
132
|
+
|
|
133
|
+
console.log(`Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}`);
|
|
134
|
+
|
|
135
|
+
for (const seg of segments) {
|
|
136
|
+
const segStartInFullText = currentPosInFullText;
|
|
137
|
+
const segTextLength = seg.text.length;
|
|
138
|
+
const segEndInFullText = segStartInFullText + segTextLength;
|
|
139
|
+
|
|
140
|
+
// Map from reconstructed text position to actual document indices
|
|
141
|
+
if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
|
|
142
|
+
startIndex = seg.start + (targetStartInFullText - segStartInFullText);
|
|
143
|
+
console.log(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
|
|
147
|
+
endIndex = seg.start + (targetEndInFullText - segStartInFullText);
|
|
148
|
+
console.log(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
currentPosInFullText = segEndInFullText;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
156
|
+
console.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
|
|
157
|
+
// Reset and try next occurrence
|
|
158
|
+
startIndex = -1;
|
|
159
|
+
endIndex = -1;
|
|
160
|
+
searchStartIndex = currentIndex + 1;
|
|
161
|
+
foundCount--;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
|
|
166
|
+
return { startIndex, endIndex };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Prepare for next search iteration
|
|
170
|
+
searchStartIndex = currentIndex + 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.warn(`Could not find instance ${instance} of text "${textToFind}" in document ${documentId}`);
|
|
174
|
+
return null; // Instance not found or mapping failed for all attempts
|
|
175
|
+
} catch (error: any) {
|
|
176
|
+
console.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
|
|
177
|
+
if (error.code === 404) throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
|
|
178
|
+
if (error.code === 403) throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
|
|
179
|
+
throw new Error(`Failed to retrieve doc for text searching: ${error.message || 'Unknown error'}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Paragraph Boundary Helper ---
|
|
184
|
+
// Enhanced version to handle document structural elements more robustly
|
|
185
|
+
export async function getParagraphRange(docs: Docs, documentId: string, indexWithin: number): Promise<{ startIndex: number; endIndex: number } | null> {
|
|
186
|
+
try {
|
|
187
|
+
console.log(`Finding paragraph containing index ${indexWithin} in document ${documentId}`);
|
|
188
|
+
|
|
189
|
+
// Request more detailed document structure to handle nested elements
|
|
190
|
+
const res = await docs.documents.get({
|
|
191
|
+
documentId,
|
|
192
|
+
// Request more comprehensive structure information
|
|
193
|
+
fields: 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.data.body?.content) {
|
|
197
|
+
console.warn(`No content found in document ${documentId}`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Find paragraph containing the index
|
|
202
|
+
// We'll look at all structural elements recursively
|
|
203
|
+
const findParagraphInContent = (content: any[]): { startIndex: number; endIndex: number } | null => {
|
|
204
|
+
for (const element of content) {
|
|
205
|
+
// Check if we have element boundaries defined
|
|
206
|
+
if (element.startIndex !== undefined && element.endIndex !== undefined) {
|
|
207
|
+
// Check if index is within this element's range first
|
|
208
|
+
if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
|
|
209
|
+
// If it's a paragraph, we've found our target
|
|
210
|
+
if (element.paragraph) {
|
|
211
|
+
console.log(`Found paragraph containing index ${indexWithin}, range: ${element.startIndex}-${element.endIndex}`);
|
|
212
|
+
return {
|
|
213
|
+
startIndex: element.startIndex,
|
|
214
|
+
endIndex: element.endIndex
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// If it's a table, we need to check cells recursively
|
|
219
|
+
if (element.table && element.table.tableRows) {
|
|
220
|
+
console.log(`Index ${indexWithin} is within a table, searching cells...`);
|
|
221
|
+
for (const row of element.table.tableRows) {
|
|
222
|
+
if (row.tableCells) {
|
|
223
|
+
for (const cell of row.tableCells) {
|
|
224
|
+
if (cell.content) {
|
|
225
|
+
const result = findParagraphInContent(cell.content);
|
|
226
|
+
if (result) return result;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// For other structural elements, we didn't find a paragraph
|
|
234
|
+
// but we know the index is within this element
|
|
235
|
+
console.warn(`Index ${indexWithin} is within element (${element.startIndex}-${element.endIndex}) but not in a paragraph`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const paragraphRange = findParagraphInContent(res.data.body.content);
|
|
244
|
+
|
|
245
|
+
if (!paragraphRange) {
|
|
246
|
+
console.warn(`Could not find paragraph containing index ${indexWithin}`);
|
|
247
|
+
} else {
|
|
248
|
+
console.log(`Returning paragraph range: ${paragraphRange.startIndex}-${paragraphRange.endIndex}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return paragraphRange;
|
|
252
|
+
|
|
253
|
+
} catch (error: any) {
|
|
254
|
+
console.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message || 'Unknown error'}`);
|
|
255
|
+
if (error.code === 404) throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
|
|
256
|
+
if (error.code === 403) throw new UserError(`Permission denied while accessing doc ${documentId}.`);
|
|
257
|
+
throw new Error(`Failed to find paragraph: ${error.message || 'Unknown error'}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Style Request Builders ---
|
|
262
|
+
|
|
263
|
+
export function buildUpdateTextStyleRequest(
|
|
264
|
+
startIndex: number,
|
|
265
|
+
endIndex: number,
|
|
266
|
+
style: TextStyleArgs
|
|
267
|
+
): { request: docs_v1.Schema$Request, fields: string[] } | null {
|
|
268
|
+
const textStyle: docs_v1.Schema$TextStyle = {};
|
|
269
|
+
const fieldsToUpdate: string[] = [];
|
|
270
|
+
|
|
271
|
+
if (style.bold !== undefined) { textStyle.bold = style.bold; fieldsToUpdate.push('bold'); }
|
|
272
|
+
if (style.italic !== undefined) { textStyle.italic = style.italic; fieldsToUpdate.push('italic'); }
|
|
273
|
+
if (style.underline !== undefined) { textStyle.underline = style.underline; fieldsToUpdate.push('underline'); }
|
|
274
|
+
if (style.strikethrough !== undefined) { textStyle.strikethrough = style.strikethrough; fieldsToUpdate.push('strikethrough'); }
|
|
275
|
+
if (style.fontSize !== undefined) { textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; fieldsToUpdate.push('fontSize'); }
|
|
276
|
+
if (style.fontFamily !== undefined) { textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; fieldsToUpdate.push('weightedFontFamily'); }
|
|
277
|
+
if (style.foregroundColor !== undefined) {
|
|
278
|
+
const rgbColor = hexToRgbColor(style.foregroundColor);
|
|
279
|
+
if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
|
|
280
|
+
textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('foregroundColor');
|
|
281
|
+
}
|
|
282
|
+
if (style.backgroundColor !== undefined) {
|
|
283
|
+
const rgbColor = hexToRgbColor(style.backgroundColor);
|
|
284
|
+
if (!rgbColor) throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
|
|
285
|
+
textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('backgroundColor');
|
|
286
|
+
}
|
|
287
|
+
if (style.linkUrl !== undefined) {
|
|
288
|
+
textStyle.link = { url: style.linkUrl }; fieldsToUpdate.push('link');
|
|
289
|
+
}
|
|
290
|
+
// TODO: Handle clearing formatting
|
|
291
|
+
|
|
292
|
+
if (fieldsToUpdate.length === 0) return null; // No styles to apply
|
|
293
|
+
|
|
294
|
+
const request: docs_v1.Schema$Request = {
|
|
295
|
+
updateTextStyle: {
|
|
296
|
+
range: { startIndex, endIndex },
|
|
297
|
+
textStyle: textStyle,
|
|
298
|
+
fields: fieldsToUpdate.join(','),
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return { request, fields: fieldsToUpdate };
|
|
302
|
+
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function buildUpdateParagraphStyleRequest(
|
|
306
|
+
startIndex: number,
|
|
307
|
+
endIndex: number,
|
|
308
|
+
style: ParagraphStyleArgs
|
|
309
|
+
): { request: docs_v1.Schema$Request, fields: string[] } | null {
|
|
310
|
+
// Create style object and track which fields to update
|
|
311
|
+
const paragraphStyle: docs_v1.Schema$ParagraphStyle = {};
|
|
312
|
+
const fieldsToUpdate: string[] = [];
|
|
313
|
+
|
|
314
|
+
console.log(`Building paragraph style request for range ${startIndex}-${endIndex} with options:`, style);
|
|
315
|
+
|
|
316
|
+
// Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
|
|
317
|
+
if (style.alignment !== undefined) {
|
|
318
|
+
paragraphStyle.alignment = style.alignment;
|
|
319
|
+
fieldsToUpdate.push('alignment');
|
|
320
|
+
console.log(`Setting alignment to ${style.alignment}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Process indentation options
|
|
324
|
+
if (style.indentStart !== undefined) {
|
|
325
|
+
paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' };
|
|
326
|
+
fieldsToUpdate.push('indentStart');
|
|
327
|
+
console.log(`Setting left indent to ${style.indentStart}pt`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (style.indentEnd !== undefined) {
|
|
331
|
+
paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' };
|
|
332
|
+
fieldsToUpdate.push('indentEnd');
|
|
333
|
+
console.log(`Setting right indent to ${style.indentEnd}pt`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Process spacing options
|
|
337
|
+
if (style.spaceAbove !== undefined) {
|
|
338
|
+
paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' };
|
|
339
|
+
fieldsToUpdate.push('spaceAbove');
|
|
340
|
+
console.log(`Setting space above to ${style.spaceAbove}pt`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (style.spaceBelow !== undefined) {
|
|
344
|
+
paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' };
|
|
345
|
+
fieldsToUpdate.push('spaceBelow');
|
|
346
|
+
console.log(`Setting space below to ${style.spaceBelow}pt`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Process named style types (headings, etc.)
|
|
350
|
+
if (style.namedStyleType !== undefined) {
|
|
351
|
+
paragraphStyle.namedStyleType = style.namedStyleType;
|
|
352
|
+
fieldsToUpdate.push('namedStyleType');
|
|
353
|
+
console.log(`Setting named style to ${style.namedStyleType}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Process page break control
|
|
357
|
+
if (style.keepWithNext !== undefined) {
|
|
358
|
+
paragraphStyle.keepWithNext = style.keepWithNext;
|
|
359
|
+
fieldsToUpdate.push('keepWithNext');
|
|
360
|
+
console.log(`Setting keepWithNext to ${style.keepWithNext}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Verify we have styles to apply
|
|
364
|
+
if (fieldsToUpdate.length === 0) {
|
|
365
|
+
console.warn("No paragraph styling options were provided");
|
|
366
|
+
return null; // No styles to apply
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Build the request object
|
|
370
|
+
const request: docs_v1.Schema$Request = {
|
|
371
|
+
updateParagraphStyle: {
|
|
372
|
+
range: { startIndex, endIndex },
|
|
373
|
+
paragraphStyle: paragraphStyle,
|
|
374
|
+
fields: fieldsToUpdate.join(','),
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
console.log(`Created paragraph style request with fields: ${fieldsToUpdate.join(', ')}`);
|
|
379
|
+
return { request, fields: fieldsToUpdate };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- Specific Feature Helpers ---
|
|
383
|
+
|
|
384
|
+
export async function createTable(docs: Docs, documentId: string, rows: number, columns: number, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
|
|
385
|
+
if (rows < 1 || columns < 1) {
|
|
386
|
+
throw new UserError("Table must have at least 1 row and 1 column.");
|
|
387
|
+
}
|
|
388
|
+
const request: docs_v1.Schema$Request = {
|
|
389
|
+
insertTable: {
|
|
390
|
+
location: { index },
|
|
391
|
+
rows: rows,
|
|
392
|
+
columns: columns,
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function insertText(docs: Docs, documentId: string, text: string, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
|
|
399
|
+
if (!text) return {}; // Nothing to insert
|
|
400
|
+
const request: docs_v1.Schema$Request = {
|
|
401
|
+
insertText: {
|
|
402
|
+
location: { index },
|
|
403
|
+
text: text,
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- Complex / Stubbed Helpers ---
|
|
410
|
+
|
|
411
|
+
export async function findParagraphsMatchingStyle(
|
|
412
|
+
docs: Docs,
|
|
413
|
+
documentId: string,
|
|
414
|
+
styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true })
|
|
415
|
+
): Promise<{ startIndex: number; endIndex: number }[]> {
|
|
416
|
+
// TODO: Implement logic
|
|
417
|
+
// 1. Get document content with paragraph elements and their styles.
|
|
418
|
+
// 2. Iterate through paragraphs.
|
|
419
|
+
// 3. For each paragraph, check if its computed style matches the criteria.
|
|
420
|
+
// 4. Return ranges of matching paragraphs.
|
|
421
|
+
console.warn("findParagraphsMatchingStyle is not implemented.");
|
|
422
|
+
throw new NotImplementedError("Finding paragraphs by style criteria is not yet implemented.");
|
|
423
|
+
// return [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function detectAndFormatLists(
|
|
427
|
+
docs: Docs,
|
|
428
|
+
documentId: string,
|
|
429
|
+
startIndex?: number,
|
|
430
|
+
endIndex?: number
|
|
431
|
+
): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
|
|
432
|
+
// TODO: Implement complex logic
|
|
433
|
+
// 1. Get document content (paragraphs, text runs) in the specified range (or whole doc).
|
|
434
|
+
// 2. Iterate through paragraphs.
|
|
435
|
+
// 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "*", "1.", "a)").
|
|
436
|
+
// 4. Determine nesting levels based on indentation or marker patterns.
|
|
437
|
+
// 5. Generate CreateParagraphBulletsRequests for the identified sequences.
|
|
438
|
+
// 6. Potentially delete the original marker text.
|
|
439
|
+
// 7. Execute the batch update.
|
|
440
|
+
console.warn("detectAndFormatLists is not implemented.");
|
|
441
|
+
throw new NotImplementedError("Automatic list detection and formatting is not yet implemented.");
|
|
442
|
+
// return {};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function addCommentHelper(docs: Docs, documentId: string, text: string, startIndex: number, endIndex: number): Promise<void> {
|
|
446
|
+
// NOTE: Adding comments typically requires the Google Drive API v3 and different scopes!
|
|
447
|
+
// 'https://www.googleapis.com/auth/drive' or more specific comment scopes.
|
|
448
|
+
// This helper is a placeholder assuming Drive API client (`drive`) is available and authorized.
|
|
449
|
+
/*
|
|
450
|
+
const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available
|
|
451
|
+
await drive.comments.create({
|
|
452
|
+
fileId: documentId,
|
|
453
|
+
requestBody: {
|
|
454
|
+
content: text,
|
|
455
|
+
anchor: JSON.stringify({ // Anchor format might need verification
|
|
456
|
+
'type': 'workbook#textAnchor', // Or appropriate type for Docs
|
|
457
|
+
'refs': [{
|
|
458
|
+
'docRevisionId': 'head', // Or specific revision
|
|
459
|
+
'range': {
|
|
460
|
+
'start': startIndex,
|
|
461
|
+
'end': endIndex,
|
|
462
|
+
}
|
|
463
|
+
}]
|
|
464
|
+
})
|
|
465
|
+
},
|
|
466
|
+
fields: 'id'
|
|
467
|
+
});
|
|
468
|
+
*/
|
|
469
|
+
console.warn("addCommentHelper requires Google Drive API and is not implemented.");
|
|
470
|
+
throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented.");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- Image Insertion Helpers ---
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Inserts an inline image into a document from a publicly accessible URL
|
|
477
|
+
* @param docs - Google Docs API client
|
|
478
|
+
* @param documentId - The document ID
|
|
479
|
+
* @param imageUrl - Publicly accessible URL to the image
|
|
480
|
+
* @param index - Position in the document where image should be inserted (1-based)
|
|
481
|
+
* @param width - Optional width in points
|
|
482
|
+
* @param height - Optional height in points
|
|
483
|
+
* @returns Promise with batch update response
|
|
484
|
+
*/
|
|
485
|
+
export async function insertInlineImage(
|
|
486
|
+
docs: Docs,
|
|
487
|
+
documentId: string,
|
|
488
|
+
imageUrl: string,
|
|
489
|
+
index: number,
|
|
490
|
+
width?: number,
|
|
491
|
+
height?: number
|
|
492
|
+
): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
|
|
493
|
+
// Validate URL format
|
|
494
|
+
try {
|
|
495
|
+
new URL(imageUrl);
|
|
496
|
+
} catch (e) {
|
|
497
|
+
throw new UserError(`Invalid image URL format: ${imageUrl}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Build the insertInlineImage request
|
|
501
|
+
const request: docs_v1.Schema$Request = {
|
|
502
|
+
insertInlineImage: {
|
|
503
|
+
location: { index },
|
|
504
|
+
uri: imageUrl,
|
|
505
|
+
...(width && height && {
|
|
506
|
+
objectSize: {
|
|
507
|
+
height: { magnitude: height, unit: 'PT' },
|
|
508
|
+
width: { magnitude: width, unit: 'PT' }
|
|
509
|
+
}
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Uploads a local image file to Google Drive and returns its public URL
|
|
519
|
+
* @param drive - Google Drive API client
|
|
520
|
+
* @param localFilePath - Path to the local image file
|
|
521
|
+
* @param parentFolderId - Optional parent folder ID (defaults to root)
|
|
522
|
+
* @returns Promise with the public webContentLink URL
|
|
523
|
+
*/
|
|
524
|
+
export async function uploadImageToDrive(
|
|
525
|
+
drive: any, // drive_v3.Drive type
|
|
526
|
+
localFilePath: string,
|
|
527
|
+
parentFolderId?: string
|
|
528
|
+
): Promise<string> {
|
|
529
|
+
const fs = await import('fs');
|
|
530
|
+
const path = await import('path');
|
|
531
|
+
|
|
532
|
+
// Verify file exists
|
|
533
|
+
if (!fs.existsSync(localFilePath)) {
|
|
534
|
+
throw new UserError(`Image file not found: ${localFilePath}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Get file name and mime type
|
|
538
|
+
const fileName = path.basename(localFilePath);
|
|
539
|
+
const mimeTypeMap: { [key: string]: string } = {
|
|
540
|
+
'.jpg': 'image/jpeg',
|
|
541
|
+
'.jpeg': 'image/jpeg',
|
|
542
|
+
'.png': 'image/png',
|
|
543
|
+
'.gif': 'image/gif',
|
|
544
|
+
'.bmp': 'image/bmp',
|
|
545
|
+
'.webp': 'image/webp',
|
|
546
|
+
'.svg': 'image/svg+xml'
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const ext = path.extname(localFilePath).toLowerCase();
|
|
550
|
+
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
|
|
551
|
+
|
|
552
|
+
// Upload file to Drive
|
|
553
|
+
const fileMetadata: any = {
|
|
554
|
+
name: fileName,
|
|
555
|
+
mimeType: mimeType
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
if (parentFolderId) {
|
|
559
|
+
fileMetadata.parents = [parentFolderId];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const media = {
|
|
563
|
+
mimeType: mimeType,
|
|
564
|
+
body: fs.createReadStream(localFilePath)
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const uploadResponse = await drive.files.create({
|
|
568
|
+
requestBody: fileMetadata,
|
|
569
|
+
media: media,
|
|
570
|
+
fields: 'id,webViewLink,webContentLink'
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const fileId = uploadResponse.data.id;
|
|
574
|
+
if (!fileId) {
|
|
575
|
+
throw new Error('Failed to upload image to Drive - no file ID returned');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Make the file publicly readable
|
|
579
|
+
await drive.permissions.create({
|
|
580
|
+
fileId: fileId,
|
|
581
|
+
requestBody: {
|
|
582
|
+
role: 'reader',
|
|
583
|
+
type: 'anyone'
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Get the webContentLink
|
|
588
|
+
const fileInfo = await drive.files.get({
|
|
589
|
+
fileId: fileId,
|
|
590
|
+
fields: 'webContentLink'
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const webContentLink = fileInfo.data.webContentLink;
|
|
594
|
+
if (!webContentLink) {
|
|
595
|
+
throw new Error('Failed to get public URL for uploaded image');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return webContentLink;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// --- Tab Management Helpers ---
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Interface for a tab with hierarchy level information
|
|
605
|
+
*/
|
|
606
|
+
export interface TabWithLevel extends docs_v1.Schema$Tab {
|
|
607
|
+
level: number;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Recursively collect all tabs from a document in a flat list with hierarchy info
|
|
612
|
+
* @param doc - The Google Doc document object
|
|
613
|
+
* @returns Array of tabs with nesting level information
|
|
614
|
+
*/
|
|
615
|
+
export function getAllTabs(doc: docs_v1.Schema$Document): TabWithLevel[] {
|
|
616
|
+
const allTabs: TabWithLevel[] = [];
|
|
617
|
+
if (!doc.tabs || doc.tabs.length === 0) {
|
|
618
|
+
return allTabs;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const tab of doc.tabs) {
|
|
622
|
+
addCurrentAndChildTabs(tab, allTabs, 0);
|
|
623
|
+
}
|
|
624
|
+
return allTabs;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Recursive helper to add tabs with their nesting level
|
|
629
|
+
* @param tab - The tab to add
|
|
630
|
+
* @param allTabs - The accumulator array
|
|
631
|
+
* @param level - Current nesting level (0 for top-level)
|
|
632
|
+
*/
|
|
633
|
+
function addCurrentAndChildTabs(tab: docs_v1.Schema$Tab, allTabs: TabWithLevel[], level: number): void {
|
|
634
|
+
allTabs.push({ ...tab, level });
|
|
635
|
+
if (tab.childTabs && tab.childTabs.length > 0) {
|
|
636
|
+
for (const childTab of tab.childTabs) {
|
|
637
|
+
addCurrentAndChildTabs(childTab, allTabs, level + 1);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Get the text length from a DocumentTab
|
|
644
|
+
* @param documentTab - The DocumentTab object
|
|
645
|
+
* @returns Total character count
|
|
646
|
+
*/
|
|
647
|
+
export function getTabTextLength(documentTab: docs_v1.Schema$DocumentTab | undefined): number {
|
|
648
|
+
let totalLength = 0;
|
|
649
|
+
|
|
650
|
+
if (!documentTab?.body?.content) {
|
|
651
|
+
return 0;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
documentTab.body.content.forEach((element: any) => {
|
|
655
|
+
// Handle paragraphs
|
|
656
|
+
if (element.paragraph?.elements) {
|
|
657
|
+
element.paragraph.elements.forEach((pe: any) => {
|
|
658
|
+
if (pe.textRun?.content) {
|
|
659
|
+
totalLength += pe.textRun.content.length;
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Handle tables
|
|
665
|
+
if (element.table?.tableRows) {
|
|
666
|
+
element.table.tableRows.forEach((row: any) => {
|
|
667
|
+
row.tableCells?.forEach((cell: any) => {
|
|
668
|
+
cell.content?.forEach((cellElement: any) => {
|
|
669
|
+
cellElement.paragraph?.elements?.forEach((pe: any) => {
|
|
670
|
+
if (pe.textRun?.content) {
|
|
671
|
+
totalLength += pe.textRun.content.length;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
return totalLength;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Find a specific tab by ID in a document (searches recursively through child tabs)
|
|
685
|
+
* @param doc - The Google Doc document object
|
|
686
|
+
* @param tabId - The tab ID to search for
|
|
687
|
+
* @returns The tab object if found, null otherwise
|
|
688
|
+
*/
|
|
689
|
+
export function findTabById(doc: docs_v1.Schema$Document, tabId: string): docs_v1.Schema$Tab | null {
|
|
690
|
+
if (!doc.tabs || doc.tabs.length === 0) {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Helper function to search through tabs recursively
|
|
695
|
+
const searchTabs = (tabs: docs_v1.Schema$Tab[]): docs_v1.Schema$Tab | null => {
|
|
696
|
+
for (const tab of tabs) {
|
|
697
|
+
if (tab.tabProperties?.tabId === tabId) {
|
|
698
|
+
return tab;
|
|
699
|
+
}
|
|
700
|
+
// Recursively search child tabs
|
|
701
|
+
if (tab.childTabs && tab.childTabs.length > 0) {
|
|
702
|
+
const found = searchTabs(tab.childTabs);
|
|
703
|
+
if (found) return found;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
return searchTabs(doc.tabs);
|
|
710
|
+
}
|