google-workspace-mcp 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +765 -0
- package/dist/accounts.d.ts +85 -0
- package/dist/accounts.d.ts.map +1 -0
- package/dist/accounts.js +520 -0
- package/dist/accounts.js.map +1 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +206 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +426 -0
- package/dist/cli.js.map +1 -0
- package/dist/errorHelpers.d.ts +40 -0
- package/dist/errorHelpers.d.ts.map +1 -0
- package/dist/errorHelpers.js +52 -0
- package/dist/errorHelpers.js.map +1 -0
- package/dist/googleDocsApiHelpers.d.ts +118 -0
- package/dist/googleDocsApiHelpers.d.ts.map +1 -0
- package/dist/googleDocsApiHelpers.js +850 -0
- package/dist/googleDocsApiHelpers.js.map +1 -0
- package/dist/googleSheetsApiHelpers.d.ts +75 -0
- package/dist/googleSheetsApiHelpers.d.ts.map +1 -0
- package/dist/googleSheetsApiHelpers.js +376 -0
- package/dist/googleSheetsApiHelpers.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +119 -0
- package/dist/server.js.map +1 -0
- package/dist/serverWrapper.d.ts +21 -0
- package/dist/serverWrapper.d.ts.map +1 -0
- package/dist/serverWrapper.js +74 -0
- package/dist/serverWrapper.js.map +1 -0
- package/dist/tools/accounts.tools.d.ts +3 -0
- package/dist/tools/accounts.tools.d.ts.map +1 -0
- package/dist/tools/accounts.tools.js +154 -0
- package/dist/tools/accounts.tools.js.map +1 -0
- package/dist/tools/calendar.tools.d.ts +3 -0
- package/dist/tools/calendar.tools.d.ts.map +1 -0
- package/dist/tools/calendar.tools.js +487 -0
- package/dist/tools/calendar.tools.js.map +1 -0
- package/dist/tools/docs.tools.d.ts +3 -0
- package/dist/tools/docs.tools.d.ts.map +1 -0
- package/dist/tools/docs.tools.js +1766 -0
- package/dist/tools/docs.tools.js.map +1 -0
- package/dist/tools/drive.tools.d.ts +3 -0
- package/dist/tools/drive.tools.d.ts.map +1 -0
- package/dist/tools/drive.tools.js +1001 -0
- package/dist/tools/drive.tools.js.map +1 -0
- package/dist/tools/forms.tools.d.ts +3 -0
- package/dist/tools/forms.tools.d.ts.map +1 -0
- package/dist/tools/forms.tools.js +370 -0
- package/dist/tools/forms.tools.js.map +1 -0
- package/dist/tools/gmail.tools.d.ts +3 -0
- package/dist/tools/gmail.tools.d.ts.map +1 -0
- package/dist/tools/gmail.tools.js +520 -0
- package/dist/tools/gmail.tools.js.map +1 -0
- package/dist/tools/sheets.tools.d.ts +3 -0
- package/dist/tools/sheets.tools.d.ts.map +1 -0
- package/dist/tools/sheets.tools.js +521 -0
- package/dist/tools/sheets.tools.js.map +1 -0
- package/dist/tools/slides.tools.d.ts +3 -0
- package/dist/tools/slides.tools.d.ts.map +1 -0
- package/dist/tools/slides.tools.js +323 -0
- package/dist/tools/slides.tools.js.map +1 -0
- package/dist/types.d.ts +507 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +185 -0
- package/dist/types.js.map +1 -0
- package/dist/urlHelpers.d.ts +60 -0
- package/dist/urlHelpers.d.ts.map +1 -0
- package/dist/urlHelpers.js +101 -0
- package/dist/urlHelpers.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { hexToRgbColor, } from './types.js';
|
|
3
|
+
import { isGoogleApiError, getErrorMessage } from './errorHelpers.js';
|
|
4
|
+
// --- Constants ---
|
|
5
|
+
const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
|
|
6
|
+
// --- Core Helper to Execute Batch Updates ---
|
|
7
|
+
/**
|
|
8
|
+
* Execute a single batch update with error handling
|
|
9
|
+
*/
|
|
10
|
+
async function executeSingleBatch(docs, documentId, requests) {
|
|
11
|
+
try {
|
|
12
|
+
const response = await docs.documents.batchUpdate({
|
|
13
|
+
documentId: documentId,
|
|
14
|
+
requestBody: { requests },
|
|
15
|
+
});
|
|
16
|
+
return response.data;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const message = getErrorMessage(error);
|
|
20
|
+
const apiError = isGoogleApiError(error) ? error : null;
|
|
21
|
+
const code = apiError?.code;
|
|
22
|
+
const responseData = apiError?.response?.data;
|
|
23
|
+
// Translate common API errors to UserErrors
|
|
24
|
+
if (code === 400 && message.includes('Invalid requests')) {
|
|
25
|
+
// Try to extract more specific info if available
|
|
26
|
+
const errorResponse = responseData;
|
|
27
|
+
const details = errorResponse?.error?.details;
|
|
28
|
+
let detailMsg = '';
|
|
29
|
+
if (details && Array.isArray(details)) {
|
|
30
|
+
detailMsg = details.map((d) => d.description ?? JSON.stringify(d)).join('; ');
|
|
31
|
+
}
|
|
32
|
+
throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || message}`);
|
|
33
|
+
}
|
|
34
|
+
if (code === 404)
|
|
35
|
+
throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
|
|
36
|
+
if (code === 403)
|
|
37
|
+
throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
|
|
38
|
+
// Generic internal error for others
|
|
39
|
+
throw new Error(`Google API Error (${code}): ${message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Execute batch updates, automatically splitting large request arrays into multiple batches.
|
|
44
|
+
* Returns the combined response from all batches.
|
|
45
|
+
*/
|
|
46
|
+
export async function executeBatchUpdate(docs, documentId, requests) {
|
|
47
|
+
if (requests.length === 0) {
|
|
48
|
+
return {}; // Nothing to do
|
|
49
|
+
}
|
|
50
|
+
// If within limits, execute as single batch
|
|
51
|
+
if (requests.length <= MAX_BATCH_UPDATE_REQUESTS) {
|
|
52
|
+
return executeSingleBatch(docs, documentId, requests);
|
|
53
|
+
}
|
|
54
|
+
// Split into multiple batches and execute sequentially
|
|
55
|
+
// Note: Sequential execution is required because document indices change after each batch
|
|
56
|
+
const allReplies = [];
|
|
57
|
+
let lastDocumentId = documentId;
|
|
58
|
+
for (let i = 0; i < requests.length; i += MAX_BATCH_UPDATE_REQUESTS) {
|
|
59
|
+
const batch = requests.slice(i, i + MAX_BATCH_UPDATE_REQUESTS);
|
|
60
|
+
const response = await executeSingleBatch(docs, documentId, batch);
|
|
61
|
+
if (response.replies) {
|
|
62
|
+
allReplies.push(...response.replies);
|
|
63
|
+
}
|
|
64
|
+
if (response.documentId) {
|
|
65
|
+
lastDocumentId = response.documentId;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
documentId: lastDocumentId,
|
|
70
|
+
replies: allReplies.length > 0 ? allReplies : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// --- Text Finding Helper ---
|
|
74
|
+
// This improved version is more robust in handling various text structure scenarios
|
|
75
|
+
export async function findTextRange(docs, documentId, textToFind, instance = 1) {
|
|
76
|
+
try {
|
|
77
|
+
// Request more detailed information about the document structure
|
|
78
|
+
const res = await docs.documents.get({
|
|
79
|
+
documentId,
|
|
80
|
+
// Request more fields to handle various container types (not just paragraphs)
|
|
81
|
+
fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
|
|
82
|
+
});
|
|
83
|
+
if (!res.data.body?.content) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// More robust text collection and index tracking
|
|
87
|
+
let fullText = '';
|
|
88
|
+
const segments = [];
|
|
89
|
+
// Process all content elements, including structural ones
|
|
90
|
+
const collectTextFromContent = (content) => {
|
|
91
|
+
content.forEach((element) => {
|
|
92
|
+
// Handle paragraph elements
|
|
93
|
+
if (element.paragraph?.elements) {
|
|
94
|
+
element.paragraph.elements.forEach((pe) => {
|
|
95
|
+
if (pe.textRun?.content &&
|
|
96
|
+
pe.startIndex !== null &&
|
|
97
|
+
pe.startIndex !== undefined &&
|
|
98
|
+
pe.endIndex !== null &&
|
|
99
|
+
pe.endIndex !== undefined) {
|
|
100
|
+
const textContent = pe.textRun.content;
|
|
101
|
+
fullText += textContent;
|
|
102
|
+
segments.push({
|
|
103
|
+
text: textContent,
|
|
104
|
+
start: pe.startIndex,
|
|
105
|
+
end: pe.endIndex,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// Handle table elements - this is simplified and might need expansion
|
|
111
|
+
if (element.table?.tableRows) {
|
|
112
|
+
element.table.tableRows.forEach((row) => {
|
|
113
|
+
if (row.tableCells) {
|
|
114
|
+
row.tableCells.forEach((cell) => {
|
|
115
|
+
if (cell.content) {
|
|
116
|
+
collectTextFromContent(cell.content);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Add handling for other structural elements as needed
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
collectTextFromContent(res.data.body.content);
|
|
126
|
+
// Sort segments by starting position to ensure correct ordering
|
|
127
|
+
segments.sort((a, b) => a.start - b.start);
|
|
128
|
+
// Find the specified instance of the text
|
|
129
|
+
let startIndex = -1;
|
|
130
|
+
let endIndex = -1;
|
|
131
|
+
let foundCount = 0;
|
|
132
|
+
let searchStartIndex = 0;
|
|
133
|
+
while (foundCount < instance) {
|
|
134
|
+
const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
|
|
135
|
+
if (currentIndex === -1) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
foundCount++;
|
|
139
|
+
if (foundCount === instance) {
|
|
140
|
+
const targetStartInFullText = currentIndex;
|
|
141
|
+
const targetEndInFullText = currentIndex + textToFind.length;
|
|
142
|
+
let currentPosInFullText = 0;
|
|
143
|
+
for (const seg of segments) {
|
|
144
|
+
const segStartInFullText = currentPosInFullText;
|
|
145
|
+
const segTextLength = seg.text.length;
|
|
146
|
+
const segEndInFullText = segStartInFullText + segTextLength;
|
|
147
|
+
// Map from reconstructed text position to actual document indices
|
|
148
|
+
if (startIndex === -1 &&
|
|
149
|
+
targetStartInFullText >= segStartInFullText &&
|
|
150
|
+
targetStartInFullText < segEndInFullText) {
|
|
151
|
+
startIndex = seg.start + (targetStartInFullText - segStartInFullText);
|
|
152
|
+
}
|
|
153
|
+
if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
|
|
154
|
+
endIndex = seg.start + (targetEndInFullText - segStartInFullText);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
currentPosInFullText = segEndInFullText;
|
|
158
|
+
}
|
|
159
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
160
|
+
// Reset and try next occurrence
|
|
161
|
+
startIndex = -1;
|
|
162
|
+
endIndex = -1;
|
|
163
|
+
searchStartIndex = currentIndex + 1;
|
|
164
|
+
foundCount--;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
return { startIndex, endIndex };
|
|
168
|
+
}
|
|
169
|
+
// Prepare for next search iteration
|
|
170
|
+
searchStartIndex = currentIndex + 1;
|
|
171
|
+
}
|
|
172
|
+
return null; // Instance not found or mapping failed for all attempts
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
const message = getErrorMessage(error);
|
|
176
|
+
const code = isGoogleApiError(error) ? error.code : undefined;
|
|
177
|
+
if (code === 404)
|
|
178
|
+
throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
|
|
179
|
+
if (code === 403)
|
|
180
|
+
throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
|
|
181
|
+
throw new Error(`Failed to retrieve doc for text searching: ${message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// --- Paragraph Boundary Helper ---
|
|
185
|
+
// Enhanced version to handle document structural elements more robustly
|
|
186
|
+
export async function getParagraphRange(docs, documentId, indexWithin) {
|
|
187
|
+
try {
|
|
188
|
+
// Request more detailed document structure to handle nested elements
|
|
189
|
+
const res = await docs.documents.get({
|
|
190
|
+
documentId,
|
|
191
|
+
// Request more comprehensive structure information
|
|
192
|
+
fields: 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
|
|
193
|
+
});
|
|
194
|
+
if (!res.data.body?.content) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
// Find paragraph containing the index
|
|
198
|
+
// We'll look at all structural elements recursively
|
|
199
|
+
const findParagraphInContent = (content) => {
|
|
200
|
+
for (const element of content) {
|
|
201
|
+
// Check if we have element boundaries defined (can be 0, so check both null and undefined)
|
|
202
|
+
if (element.startIndex !== null &&
|
|
203
|
+
element.startIndex !== undefined &&
|
|
204
|
+
element.endIndex !== null &&
|
|
205
|
+
element.endIndex !== undefined) {
|
|
206
|
+
// Check if index is within this element's range first
|
|
207
|
+
if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
|
|
208
|
+
// If it's a paragraph, we've found our target
|
|
209
|
+
if (element.paragraph) {
|
|
210
|
+
return {
|
|
211
|
+
startIndex: element.startIndex,
|
|
212
|
+
endIndex: element.endIndex,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// If it's a table, we need to check cells recursively
|
|
216
|
+
if (element.table?.tableRows) {
|
|
217
|
+
for (const row of element.table.tableRows) {
|
|
218
|
+
if (row.tableCells) {
|
|
219
|
+
for (const cell of row.tableCells) {
|
|
220
|
+
if (cell.content) {
|
|
221
|
+
const result = findParagraphInContent(cell.content);
|
|
222
|
+
if (result)
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
};
|
|
234
|
+
return findParagraphInContent(res.data.body.content);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const message = getErrorMessage(error);
|
|
238
|
+
const code = isGoogleApiError(error) ? error.code : undefined;
|
|
239
|
+
if (code === 404)
|
|
240
|
+
throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
|
|
241
|
+
if (code === 403)
|
|
242
|
+
throw new UserError(`Permission denied while accessing doc ${documentId}.`);
|
|
243
|
+
throw new Error(`Failed to find paragraph: ${message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// --- Style Request Builders ---
|
|
247
|
+
export function buildUpdateTextStyleRequest(startIndex, endIndex, style) {
|
|
248
|
+
const textStyle = {};
|
|
249
|
+
const fieldsToUpdate = [];
|
|
250
|
+
if (style.bold !== undefined) {
|
|
251
|
+
textStyle.bold = style.bold;
|
|
252
|
+
fieldsToUpdate.push('bold');
|
|
253
|
+
}
|
|
254
|
+
if (style.italic !== undefined) {
|
|
255
|
+
textStyle.italic = style.italic;
|
|
256
|
+
fieldsToUpdate.push('italic');
|
|
257
|
+
}
|
|
258
|
+
if (style.underline !== undefined) {
|
|
259
|
+
textStyle.underline = style.underline;
|
|
260
|
+
fieldsToUpdate.push('underline');
|
|
261
|
+
}
|
|
262
|
+
if (style.strikethrough !== undefined) {
|
|
263
|
+
textStyle.strikethrough = style.strikethrough;
|
|
264
|
+
fieldsToUpdate.push('strikethrough');
|
|
265
|
+
}
|
|
266
|
+
if (style.fontSize !== undefined) {
|
|
267
|
+
textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' };
|
|
268
|
+
fieldsToUpdate.push('fontSize');
|
|
269
|
+
}
|
|
270
|
+
if (style.fontFamily !== undefined) {
|
|
271
|
+
textStyle.weightedFontFamily = { fontFamily: style.fontFamily };
|
|
272
|
+
fieldsToUpdate.push('weightedFontFamily');
|
|
273
|
+
}
|
|
274
|
+
if (style.foregroundColor !== undefined) {
|
|
275
|
+
const rgbColor = hexToRgbColor(style.foregroundColor);
|
|
276
|
+
if (!rgbColor)
|
|
277
|
+
throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
|
|
278
|
+
textStyle.foregroundColor = { color: { rgbColor: rgbColor } };
|
|
279
|
+
fieldsToUpdate.push('foregroundColor');
|
|
280
|
+
}
|
|
281
|
+
if (style.backgroundColor !== undefined) {
|
|
282
|
+
const rgbColor = hexToRgbColor(style.backgroundColor);
|
|
283
|
+
if (!rgbColor)
|
|
284
|
+
throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
|
|
285
|
+
textStyle.backgroundColor = { color: { rgbColor: rgbColor } };
|
|
286
|
+
fieldsToUpdate.push('backgroundColor');
|
|
287
|
+
}
|
|
288
|
+
if (style.linkUrl !== undefined) {
|
|
289
|
+
textStyle.link = { url: style.linkUrl };
|
|
290
|
+
fieldsToUpdate.push('link');
|
|
291
|
+
}
|
|
292
|
+
if (style.removeLink === true) {
|
|
293
|
+
// To remove a link, we set link to an empty object and update the 'link' field
|
|
294
|
+
textStyle.link = {};
|
|
295
|
+
fieldsToUpdate.push('link');
|
|
296
|
+
}
|
|
297
|
+
if (fieldsToUpdate.length === 0)
|
|
298
|
+
return null; // No styles to apply
|
|
299
|
+
const request = {
|
|
300
|
+
updateTextStyle: {
|
|
301
|
+
range: { startIndex, endIndex },
|
|
302
|
+
textStyle: textStyle,
|
|
303
|
+
fields: fieldsToUpdate.join(','),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
return { request, fields: fieldsToUpdate };
|
|
307
|
+
}
|
|
308
|
+
export function buildUpdateParagraphStyleRequest(startIndex, endIndex, style) {
|
|
309
|
+
// Create style object and track which fields to update
|
|
310
|
+
const paragraphStyle = {};
|
|
311
|
+
const fieldsToUpdate = [];
|
|
312
|
+
// Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
|
|
313
|
+
if (style.alignment !== undefined) {
|
|
314
|
+
paragraphStyle.alignment = style.alignment;
|
|
315
|
+
fieldsToUpdate.push('alignment');
|
|
316
|
+
}
|
|
317
|
+
// Process indentation options
|
|
318
|
+
if (style.indentStart !== undefined) {
|
|
319
|
+
paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' };
|
|
320
|
+
fieldsToUpdate.push('indentStart');
|
|
321
|
+
}
|
|
322
|
+
if (style.indentEnd !== undefined) {
|
|
323
|
+
paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' };
|
|
324
|
+
fieldsToUpdate.push('indentEnd');
|
|
325
|
+
}
|
|
326
|
+
// Process spacing options
|
|
327
|
+
if (style.spaceAbove !== undefined) {
|
|
328
|
+
paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' };
|
|
329
|
+
fieldsToUpdate.push('spaceAbove');
|
|
330
|
+
}
|
|
331
|
+
if (style.spaceBelow !== undefined) {
|
|
332
|
+
paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' };
|
|
333
|
+
fieldsToUpdate.push('spaceBelow');
|
|
334
|
+
}
|
|
335
|
+
// Process named style types (headings, etc.)
|
|
336
|
+
if (style.namedStyleType !== undefined) {
|
|
337
|
+
paragraphStyle.namedStyleType = style.namedStyleType;
|
|
338
|
+
fieldsToUpdate.push('namedStyleType');
|
|
339
|
+
}
|
|
340
|
+
// Process page break control
|
|
341
|
+
if (style.keepWithNext !== undefined) {
|
|
342
|
+
paragraphStyle.keepWithNext = style.keepWithNext;
|
|
343
|
+
fieldsToUpdate.push('keepWithNext');
|
|
344
|
+
}
|
|
345
|
+
// Verify we have styles to apply
|
|
346
|
+
if (fieldsToUpdate.length === 0) {
|
|
347
|
+
return null; // No styles to apply
|
|
348
|
+
}
|
|
349
|
+
// Build the request object
|
|
350
|
+
const request = {
|
|
351
|
+
updateParagraphStyle: {
|
|
352
|
+
range: { startIndex, endIndex },
|
|
353
|
+
paragraphStyle: paragraphStyle,
|
|
354
|
+
fields: fieldsToUpdate.join(','),
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
return { request, fields: fieldsToUpdate };
|
|
358
|
+
}
|
|
359
|
+
// --- Specific Feature Helpers ---
|
|
360
|
+
export async function createTable(docs, documentId, rows, columns, index) {
|
|
361
|
+
if (rows < 1 || columns < 1) {
|
|
362
|
+
throw new UserError('Table must have at least 1 row and 1 column.');
|
|
363
|
+
}
|
|
364
|
+
const request = {
|
|
365
|
+
insertTable: {
|
|
366
|
+
location: { index },
|
|
367
|
+
rows: rows,
|
|
368
|
+
columns: columns,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Find a table cell's content range by navigating the document structure
|
|
375
|
+
* @param docs - Google Docs API client
|
|
376
|
+
* @param documentId - The document ID
|
|
377
|
+
* @param tableStartIndex - The start index of the table element
|
|
378
|
+
* @param rowIndex - 0-based row index
|
|
379
|
+
* @param columnIndex - 0-based column index
|
|
380
|
+
* @returns Object with startIndex and endIndex of the cell's content, or null if not found
|
|
381
|
+
*/
|
|
382
|
+
export async function findTableCellRange(docs, documentId, tableStartIndex, rowIndex, columnIndex) {
|
|
383
|
+
const response = await docs.documents.get({ documentId });
|
|
384
|
+
const document = response.data;
|
|
385
|
+
const body = document.body;
|
|
386
|
+
if (!body?.content) {
|
|
387
|
+
throw new UserError('Document has no content');
|
|
388
|
+
}
|
|
389
|
+
// Find the table at the given start index
|
|
390
|
+
for (const element of body.content) {
|
|
391
|
+
if (element.table && element.startIndex === tableStartIndex) {
|
|
392
|
+
const table = element.table;
|
|
393
|
+
const rows = table.tableRows;
|
|
394
|
+
if (!rows || rows.length === 0) {
|
|
395
|
+
throw new UserError('Table has no rows');
|
|
396
|
+
}
|
|
397
|
+
if (rowIndex < 0 || rowIndex >= rows.length) {
|
|
398
|
+
throw new UserError(`Row index ${rowIndex} out of bounds. Table has ${rows.length} rows (0-${rows.length - 1}).`);
|
|
399
|
+
}
|
|
400
|
+
const row = rows[rowIndex];
|
|
401
|
+
const cells = row.tableCells;
|
|
402
|
+
if (!cells || cells.length === 0) {
|
|
403
|
+
throw new UserError(`Row ${rowIndex} has no cells`);
|
|
404
|
+
}
|
|
405
|
+
if (columnIndex < 0 || columnIndex >= cells.length) {
|
|
406
|
+
throw new UserError(`Column index ${columnIndex} out of bounds. Row has ${cells.length} columns (0-${cells.length - 1}).`);
|
|
407
|
+
}
|
|
408
|
+
const cell = cells[columnIndex];
|
|
409
|
+
const cellContent = cell.content;
|
|
410
|
+
if (!cellContent || cellContent.length === 0) {
|
|
411
|
+
throw new UserError(`Cell (${rowIndex}, ${columnIndex}) has no content`);
|
|
412
|
+
}
|
|
413
|
+
// Find the content range of the cell
|
|
414
|
+
// Cell content is an array of structural elements (usually paragraphs)
|
|
415
|
+
const firstElement = cellContent[0];
|
|
416
|
+
const lastElement = cellContent[cellContent.length - 1];
|
|
417
|
+
const startIndex = firstElement.startIndex ?? 0;
|
|
418
|
+
const endIndex = lastElement.endIndex ?? startIndex;
|
|
419
|
+
return { startIndex, endIndex };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
throw new UserError(`No table found at index ${tableStartIndex}. Use readGoogleDoc to find table locations.`);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Edit the content of a specific table cell
|
|
426
|
+
* @param docs - Google Docs API client
|
|
427
|
+
* @param documentId - The document ID
|
|
428
|
+
* @param tableStartIndex - The start index of the table element
|
|
429
|
+
* @param rowIndex - 0-based row index
|
|
430
|
+
* @param columnIndex - 0-based column index
|
|
431
|
+
* @param newContent - New text content for the cell (replaces existing content)
|
|
432
|
+
* @returns Batch update response
|
|
433
|
+
*/
|
|
434
|
+
export async function editTableCellContent(docs, documentId, tableStartIndex, rowIndex, columnIndex, newContent) {
|
|
435
|
+
const cellRange = await findTableCellRange(docs, documentId, tableStartIndex, rowIndex, columnIndex);
|
|
436
|
+
if (!cellRange) {
|
|
437
|
+
throw new UserError(`Could not find cell at (${rowIndex}, ${columnIndex})`);
|
|
438
|
+
}
|
|
439
|
+
const requests = [];
|
|
440
|
+
// Delete existing content (but leave the cell structure - delete content inside the cell)
|
|
441
|
+
// The cell always has at least one paragraph, so we need to be careful
|
|
442
|
+
// We delete from startIndex to endIndex-1 (leave the trailing newline that marks end of cell paragraph)
|
|
443
|
+
const deleteEndIndex = cellRange.endIndex - 1; // Keep the newline at the end
|
|
444
|
+
if (deleteEndIndex > cellRange.startIndex) {
|
|
445
|
+
requests.push({
|
|
446
|
+
deleteContentRange: {
|
|
447
|
+
range: {
|
|
448
|
+
startIndex: cellRange.startIndex,
|
|
449
|
+
endIndex: deleteEndIndex,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// Insert new content at the start of the cell
|
|
455
|
+
if (newContent) {
|
|
456
|
+
requests.push({
|
|
457
|
+
insertText: {
|
|
458
|
+
location: { index: cellRange.startIndex },
|
|
459
|
+
text: newContent,
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (requests.length === 0) {
|
|
464
|
+
return {}; // Nothing to do
|
|
465
|
+
}
|
|
466
|
+
return executeBatchUpdate(docs, documentId, requests);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Find all tables in a document and return their locations and dimensions
|
|
470
|
+
* @param docs - Google Docs API client
|
|
471
|
+
* @param documentId - The document ID
|
|
472
|
+
* @returns Array of table info objects
|
|
473
|
+
*/
|
|
474
|
+
export async function findDocumentTables(docs, documentId) {
|
|
475
|
+
const response = await docs.documents.get({ documentId });
|
|
476
|
+
const document = response.data;
|
|
477
|
+
const body = document.body;
|
|
478
|
+
if (!body?.content) {
|
|
479
|
+
return [];
|
|
480
|
+
}
|
|
481
|
+
const tables = [];
|
|
482
|
+
for (const element of body.content) {
|
|
483
|
+
if (element.table) {
|
|
484
|
+
const table = element.table;
|
|
485
|
+
const rows = table.tableRows ?? [];
|
|
486
|
+
const columns = rows.length > 0 ? (rows[0].tableCells?.length ?? 0) : 0;
|
|
487
|
+
tables.push({
|
|
488
|
+
startIndex: element.startIndex ?? 0,
|
|
489
|
+
endIndex: element.endIndex ?? 0,
|
|
490
|
+
rows: rows.length,
|
|
491
|
+
columns: columns,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return tables;
|
|
496
|
+
}
|
|
497
|
+
export async function insertText(docs, documentId, text, index) {
|
|
498
|
+
if (!text)
|
|
499
|
+
return {}; // Nothing to insert
|
|
500
|
+
const request = {
|
|
501
|
+
insertText: {
|
|
502
|
+
location: { index },
|
|
503
|
+
text: text,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
507
|
+
}
|
|
508
|
+
export async function findParagraphsMatchingStyle(docs, documentId, styleCriteria) {
|
|
509
|
+
// Get document content
|
|
510
|
+
const response = await docs.documents.get({ documentId });
|
|
511
|
+
const document = response.data;
|
|
512
|
+
const body = document.body;
|
|
513
|
+
if (!body?.content) {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
const matchingRanges = [];
|
|
517
|
+
// Helper to check if a text style matches criteria
|
|
518
|
+
function styleMatches(textStyle) {
|
|
519
|
+
if (!textStyle)
|
|
520
|
+
return false;
|
|
521
|
+
if (styleCriteria.bold !== undefined && textStyle.bold !== styleCriteria.bold) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
if (styleCriteria.italic !== undefined && textStyle.italic !== styleCriteria.italic) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
if (styleCriteria.fontFamily !== undefined) {
|
|
528
|
+
const fontFamily = textStyle.weightedFontFamily?.fontFamily;
|
|
529
|
+
if (fontFamily !== styleCriteria.fontFamily) {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (styleCriteria.fontSize !== undefined) {
|
|
534
|
+
const fontSize = textStyle.fontSize?.magnitude;
|
|
535
|
+
if (fontSize !== styleCriteria.fontSize) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
// Iterate through structural elements to find paragraphs
|
|
542
|
+
for (const element of body.content) {
|
|
543
|
+
if (element.paragraph) {
|
|
544
|
+
const paragraph = element.paragraph;
|
|
545
|
+
const startIdx = element.startIndex ?? 0;
|
|
546
|
+
const endIdx = element.endIndex ?? startIdx;
|
|
547
|
+
// Check if any text run in this paragraph matches the criteria
|
|
548
|
+
let paragraphMatches = false;
|
|
549
|
+
if (paragraph.elements) {
|
|
550
|
+
for (const paraElement of paragraph.elements) {
|
|
551
|
+
if (paraElement.textRun?.textStyle) {
|
|
552
|
+
if (styleMatches(paraElement.textRun.textStyle)) {
|
|
553
|
+
paragraphMatches = true;
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (paragraphMatches) {
|
|
560
|
+
matchingRanges.push({ startIndex: startIdx, endIndex: endIdx });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return matchingRanges;
|
|
565
|
+
}
|
|
566
|
+
const LIST_PATTERNS = [
|
|
567
|
+
// Bullet patterns: -, *, •
|
|
568
|
+
{ regex: /^[\s]*[-*•]\s+/, bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE' },
|
|
569
|
+
// Numbered patterns: 1. 2. etc
|
|
570
|
+
{ regex: /^[\s]*\d+[.)]\s+/, bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' },
|
|
571
|
+
// Letter patterns: a) b) A) B)
|
|
572
|
+
{ regex: /^[\s]*[a-zA-Z][.)]\s+/, bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' },
|
|
573
|
+
];
|
|
574
|
+
export async function detectAndFormatLists(docs, documentId, startIndex, endIndex) {
|
|
575
|
+
// Get document content
|
|
576
|
+
const response = await docs.documents.get({ documentId });
|
|
577
|
+
const document = response.data;
|
|
578
|
+
const body = document.body;
|
|
579
|
+
if (!body?.content) {
|
|
580
|
+
return {};
|
|
581
|
+
}
|
|
582
|
+
const requests = [];
|
|
583
|
+
const potentialListItems = [];
|
|
584
|
+
// Iterate through structural elements to find paragraphs
|
|
585
|
+
for (const element of body.content) {
|
|
586
|
+
if (!element.paragraph)
|
|
587
|
+
continue;
|
|
588
|
+
const paraStart = element.startIndex ?? 0;
|
|
589
|
+
const paraEnd = element.endIndex ?? paraStart;
|
|
590
|
+
// Skip if outside specified range
|
|
591
|
+
if (startIndex !== undefined && paraEnd < startIndex)
|
|
592
|
+
continue;
|
|
593
|
+
if (endIndex !== undefined && paraStart > endIndex)
|
|
594
|
+
continue;
|
|
595
|
+
// Skip paragraphs that are already in a list
|
|
596
|
+
if (element.paragraph.bullet)
|
|
597
|
+
continue;
|
|
598
|
+
// Get the text content of the paragraph
|
|
599
|
+
let paragraphText = '';
|
|
600
|
+
if (element.paragraph.elements) {
|
|
601
|
+
for (const paraElement of element.paragraph.elements) {
|
|
602
|
+
if (paraElement.textRun?.content) {
|
|
603
|
+
paragraphText += paraElement.textRun.content;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Check if the paragraph starts with a list marker
|
|
608
|
+
for (const pattern of LIST_PATTERNS) {
|
|
609
|
+
const match = pattern.regex.exec(paragraphText);
|
|
610
|
+
if (match) {
|
|
611
|
+
potentialListItems.push({
|
|
612
|
+
startIndex: paraStart,
|
|
613
|
+
endIndex: paraEnd,
|
|
614
|
+
markerEndIndex: paraStart + match[0].length,
|
|
615
|
+
bulletPreset: pattern.bulletPreset,
|
|
616
|
+
});
|
|
617
|
+
break; // Only match one pattern per paragraph
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (potentialListItems.length === 0) {
|
|
622
|
+
return {}; // No list items detected
|
|
623
|
+
}
|
|
624
|
+
// Group consecutive paragraphs with the same bullet type into lists
|
|
625
|
+
// For now, just apply bullets to each detected item
|
|
626
|
+
// Process in reverse order to maintain correct indices when deleting markers
|
|
627
|
+
const sortedItems = [...potentialListItems].sort((a, b) => b.startIndex - a.startIndex);
|
|
628
|
+
for (const item of sortedItems) {
|
|
629
|
+
// First, delete the marker text
|
|
630
|
+
requests.push({
|
|
631
|
+
deleteContentRange: {
|
|
632
|
+
range: {
|
|
633
|
+
startIndex: item.startIndex,
|
|
634
|
+
endIndex: item.markerEndIndex,
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
// Then create the bullet
|
|
639
|
+
// Note: After deletion, the paragraph range shifts, but createParagraphBullets
|
|
640
|
+
// works on paragraph boundaries, so we use the original start index
|
|
641
|
+
requests.push({
|
|
642
|
+
createParagraphBullets: {
|
|
643
|
+
range: {
|
|
644
|
+
startIndex: item.startIndex,
|
|
645
|
+
endIndex: item.startIndex + 1, // Just needs to touch the paragraph
|
|
646
|
+
},
|
|
647
|
+
bulletPreset: item.bulletPreset,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
// Reverse to get correct execution order (Google Docs processes requests in order)
|
|
652
|
+
requests.reverse();
|
|
653
|
+
return executeBatchUpdate(docs, documentId, requests);
|
|
654
|
+
}
|
|
655
|
+
// --- Image Insertion Helpers ---
|
|
656
|
+
/**
|
|
657
|
+
* Inserts an inline image into a document from a publicly accessible URL
|
|
658
|
+
* @param docs - Google Docs API client
|
|
659
|
+
* @param documentId - The document ID
|
|
660
|
+
* @param imageUrl - Publicly accessible URL to the image
|
|
661
|
+
* @param index - Position in the document where image should be inserted (1-based)
|
|
662
|
+
* @param width - Optional width in points
|
|
663
|
+
* @param height - Optional height in points
|
|
664
|
+
* @returns Promise with batch update response
|
|
665
|
+
*/
|
|
666
|
+
export async function insertInlineImage(docs, documentId, imageUrl, index, width, height) {
|
|
667
|
+
// Validate URL format
|
|
668
|
+
try {
|
|
669
|
+
new URL(imageUrl);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
throw new UserError(`Invalid image URL format: ${imageUrl}`);
|
|
673
|
+
}
|
|
674
|
+
// Build the insertInlineImage request
|
|
675
|
+
const request = {
|
|
676
|
+
insertInlineImage: {
|
|
677
|
+
location: { index },
|
|
678
|
+
uri: imageUrl,
|
|
679
|
+
...(width &&
|
|
680
|
+
height && {
|
|
681
|
+
objectSize: {
|
|
682
|
+
height: { magnitude: height, unit: 'PT' },
|
|
683
|
+
width: { magnitude: width, unit: 'PT' },
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
return executeBatchUpdate(docs, documentId, [request]);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Uploads a local image file to Google Drive and returns its public URL
|
|
692
|
+
* @param drive - Google Drive API client
|
|
693
|
+
* @param localFilePath - Path to the local image file
|
|
694
|
+
* @param parentFolderId - Optional parent folder ID (defaults to root)
|
|
695
|
+
* @returns Promise with the public webContentLink URL
|
|
696
|
+
*/
|
|
697
|
+
export async function uploadImageToDrive(drive, localFilePath, parentFolderId) {
|
|
698
|
+
const fs = await import('fs');
|
|
699
|
+
const path = await import('path');
|
|
700
|
+
// Verify file exists
|
|
701
|
+
if (!fs.existsSync(localFilePath)) {
|
|
702
|
+
throw new UserError(`Image file not found: ${localFilePath}`);
|
|
703
|
+
}
|
|
704
|
+
// Get file name and mime type
|
|
705
|
+
const fileName = path.basename(localFilePath);
|
|
706
|
+
const mimeTypeMap = {
|
|
707
|
+
'.jpg': 'image/jpeg',
|
|
708
|
+
'.jpeg': 'image/jpeg',
|
|
709
|
+
'.png': 'image/png',
|
|
710
|
+
'.gif': 'image/gif',
|
|
711
|
+
'.bmp': 'image/bmp',
|
|
712
|
+
'.webp': 'image/webp',
|
|
713
|
+
'.svg': 'image/svg+xml',
|
|
714
|
+
};
|
|
715
|
+
const ext = path.extname(localFilePath).toLowerCase();
|
|
716
|
+
// eslint-disable-next-line security/detect-object-injection -- ext is from path.extname, limited to known file extensions
|
|
717
|
+
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
|
|
718
|
+
// Upload file to Drive
|
|
719
|
+
const fileMetadata = {
|
|
720
|
+
name: fileName,
|
|
721
|
+
mimeType: mimeType,
|
|
722
|
+
};
|
|
723
|
+
if (parentFolderId) {
|
|
724
|
+
fileMetadata.parents = [parentFolderId];
|
|
725
|
+
}
|
|
726
|
+
const media = {
|
|
727
|
+
mimeType: mimeType,
|
|
728
|
+
body: fs.createReadStream(localFilePath),
|
|
729
|
+
};
|
|
730
|
+
const uploadResponse = await drive.files.create({
|
|
731
|
+
requestBody: fileMetadata,
|
|
732
|
+
media: media,
|
|
733
|
+
fields: 'id,webViewLink,webContentLink',
|
|
734
|
+
});
|
|
735
|
+
const fileId = uploadResponse.data.id;
|
|
736
|
+
if (!fileId) {
|
|
737
|
+
throw new Error('Failed to upload image to Drive - no file ID returned');
|
|
738
|
+
}
|
|
739
|
+
// Make the file publicly readable
|
|
740
|
+
await drive.permissions.create({
|
|
741
|
+
fileId: fileId,
|
|
742
|
+
requestBody: {
|
|
743
|
+
role: 'reader',
|
|
744
|
+
type: 'anyone',
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
// Get the webContentLink
|
|
748
|
+
const fileInfo = await drive.files.get({
|
|
749
|
+
fileId: fileId,
|
|
750
|
+
fields: 'webContentLink',
|
|
751
|
+
});
|
|
752
|
+
const webContentLink = fileInfo.data.webContentLink;
|
|
753
|
+
if (!webContentLink) {
|
|
754
|
+
throw new Error('Failed to get public URL for uploaded image');
|
|
755
|
+
}
|
|
756
|
+
return webContentLink;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Recursively collect all tabs from a document in a flat list with hierarchy info
|
|
760
|
+
* @param doc - The Google Doc document object
|
|
761
|
+
* @returns Array of tabs with nesting level information
|
|
762
|
+
*/
|
|
763
|
+
export function getAllTabs(doc) {
|
|
764
|
+
const allTabs = [];
|
|
765
|
+
if (!doc.tabs || doc.tabs.length === 0) {
|
|
766
|
+
return allTabs;
|
|
767
|
+
}
|
|
768
|
+
for (const tab of doc.tabs) {
|
|
769
|
+
addCurrentAndChildTabs(tab, allTabs, 0);
|
|
770
|
+
}
|
|
771
|
+
return allTabs;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Recursive helper to add tabs with their nesting level
|
|
775
|
+
* @param tab - The tab to add
|
|
776
|
+
* @param allTabs - The accumulator array
|
|
777
|
+
* @param level - Current nesting level (0 for top-level)
|
|
778
|
+
*/
|
|
779
|
+
function addCurrentAndChildTabs(tab, allTabs, level) {
|
|
780
|
+
allTabs.push({ ...tab, level });
|
|
781
|
+
if (tab.childTabs && tab.childTabs.length > 0) {
|
|
782
|
+
for (const childTab of tab.childTabs) {
|
|
783
|
+
addCurrentAndChildTabs(childTab, allTabs, level + 1);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Get the text length from a DocumentTab
|
|
789
|
+
* @param documentTab - The DocumentTab object
|
|
790
|
+
* @returns Total character count
|
|
791
|
+
*/
|
|
792
|
+
export function getTabTextLength(documentTab) {
|
|
793
|
+
let totalLength = 0;
|
|
794
|
+
if (!documentTab?.body?.content) {
|
|
795
|
+
return 0;
|
|
796
|
+
}
|
|
797
|
+
documentTab.body.content.forEach((element) => {
|
|
798
|
+
// Handle paragraphs
|
|
799
|
+
if (element.paragraph?.elements) {
|
|
800
|
+
element.paragraph.elements.forEach((pe) => {
|
|
801
|
+
if (pe.textRun?.content) {
|
|
802
|
+
totalLength += pe.textRun.content.length;
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Handle tables
|
|
807
|
+
if (element.table?.tableRows) {
|
|
808
|
+
element.table.tableRows.forEach((row) => {
|
|
809
|
+
row.tableCells?.forEach((cell) => {
|
|
810
|
+
cell.content?.forEach((cellElement) => {
|
|
811
|
+
cellElement.paragraph?.elements?.forEach((pe) => {
|
|
812
|
+
if (pe.textRun?.content) {
|
|
813
|
+
totalLength += pe.textRun.content.length;
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
return totalLength;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Find a specific tab by ID in a document (searches recursively through child tabs)
|
|
825
|
+
* @param doc - The Google Doc document object
|
|
826
|
+
* @param tabId - The tab ID to search for
|
|
827
|
+
* @returns The tab object if found, null otherwise
|
|
828
|
+
*/
|
|
829
|
+
export function findTabById(doc, tabId) {
|
|
830
|
+
if (!doc.tabs || doc.tabs.length === 0) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
// Helper function to search through tabs recursively
|
|
834
|
+
const searchTabs = (tabs) => {
|
|
835
|
+
for (const tab of tabs) {
|
|
836
|
+
if (tab.tabProperties?.tabId === tabId) {
|
|
837
|
+
return tab;
|
|
838
|
+
}
|
|
839
|
+
// Recursively search child tabs
|
|
840
|
+
if (tab.childTabs && tab.childTabs.length > 0) {
|
|
841
|
+
const found = searchTabs(tab.childTabs);
|
|
842
|
+
if (found)
|
|
843
|
+
return found;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
};
|
|
848
|
+
return searchTabs(doc.tabs);
|
|
849
|
+
}
|
|
850
|
+
//# sourceMappingURL=googleDocsApiHelpers.js.map
|