Spaces:
Sleeping
Sleeping
| // src/googleDocsApiHelpers.ts | |
| import { google, docs_v1 } from 'googleapis'; | |
| import { OAuth2Client } from 'google-auth-library'; | |
| import { UserError } from 'fastmcp'; | |
| import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js'; | |
| import { logger } from './logger.js'; | |
| type Docs = docs_v1.Docs; // Alias for convenience | |
| // --- Constants --- | |
| const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size | |
| // --- Core Helper to Execute Batch Updates --- | |
| export async function executeBatchUpdate( | |
| docs: Docs, | |
| documentId: string, | |
| requests: docs_v1.Schema$Request[] | |
| ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { | |
| if (!requests || requests.length === 0) { | |
| // console.warn("executeBatchUpdate called with no requests."); | |
| return {}; // Nothing to do | |
| } | |
| // TODO: Consider splitting large request arrays into multiple batches if needed | |
| if (requests.length > MAX_BATCH_UPDATE_REQUESTS) { | |
| logger.warn( | |
| `Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.` | |
| ); | |
| } | |
| try { | |
| const response = await docs.documents.batchUpdate({ | |
| documentId: documentId, | |
| requestBody: { requests }, | |
| }); | |
| return response.data; | |
| } catch (error: any) { | |
| logger.error( | |
| `Google API batchUpdate Error for doc ${documentId}: ${error.message || 'Unknown error'}` | |
| ); | |
| // Translate common API errors to UserErrors | |
| if (error.code === 400 && error.message.includes('Invalid requests')) { | |
| // Try to extract more specific info if available | |
| const details = error.response?.data?.error?.details; | |
| let detailMsg = ''; | |
| if (details && Array.isArray(details)) { | |
| detailMsg = details.map((d) => d.description || JSON.stringify(d)).join('; '); | |
| } | |
| throw new UserError( | |
| `Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}` | |
| ); | |
| } | |
| if (error.code === 404) | |
| throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`); | |
| if (error.code === 403) | |
| throw new UserError( | |
| `Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.` | |
| ); | |
| // Generic internal error for others | |
| throw new Error(`Google API Error (${error.code}): ${error.message}`); | |
| } | |
| } | |
| // --- Batch Update with Automatic Splitting --- | |
| /** Metadata returned by executeBatchUpdateWithSplitting for debugging / observability. */ | |
| export interface BatchUpdateMetadata { | |
| /** Total number of individual requests across all phases. */ | |
| totalRequests: number; | |
| /** Breakdown of requests by phase. */ | |
| phases: { | |
| delete: { requests: number; apiCalls: number; elapsedMs: number }; | |
| insert: { requests: number; apiCalls: number; elapsedMs: number }; | |
| format: { requests: number; apiCalls: number; elapsedMs: number }; | |
| }; | |
| /** Total number of HTTP batchUpdate calls made to the Docs API. */ | |
| totalApiCalls: number; | |
| /** Wall-clock time for the entire operation (all phases), in milliseconds. */ | |
| totalElapsedMs: number; | |
| } | |
| /** | |
| * Executes batch updates with automatic splitting for large request arrays. | |
| * Separates insert and format operations, executing inserts first. | |
| * | |
| * @param docs - The Google Docs client | |
| * @param documentId - The document ID | |
| * @param requests - Array of requests to execute | |
| * @param log - Optional logger for progress tracking | |
| * @returns Metadata about the execution (request counts, API calls, timing) | |
| */ | |
| export async function executeBatchUpdateWithSplitting( | |
| docs: Docs, | |
| documentId: string, | |
| requests: docs_v1.Schema$Request[], | |
| log?: { info: (msg: string) => void } | |
| ): Promise<BatchUpdateMetadata> { | |
| const overallStart = performance.now(); | |
| if (!requests || requests.length === 0) { | |
| return { | |
| totalRequests: 0, | |
| phases: { | |
| delete: { requests: 0, apiCalls: 0, elapsedMs: 0 }, | |
| insert: { requests: 0, apiCalls: 0, elapsedMs: 0 }, | |
| format: { requests: 0, apiCalls: 0, elapsedMs: 0 }, | |
| }, | |
| totalApiCalls: 0, | |
| totalElapsedMs: 0, | |
| }; | |
| } | |
| const MAX_BATCH = MAX_BATCH_UPDATE_REQUESTS; | |
| // Separate requests into three categories | |
| // Order of execution: delete → insert → format | |
| const deleteRequests = requests.filter((r) => 'deleteContentRange' in r); | |
| const insertRequests = requests.filter( | |
| (r) => | |
| 'insertText' in r || | |
| 'insertTable' in r || | |
| 'insertPageBreak' in r || | |
| 'insertInlineImage' in r || | |
| 'insertSectionBreak' in r | |
| ); | |
| const formatRequests = requests.filter( | |
| (r) => | |
| !('deleteContentRange' in r) && | |
| !( | |
| 'insertText' in r || | |
| 'insertTable' in r || | |
| 'insertPageBreak' in r || | |
| 'insertInlineImage' in r || | |
| 'insertSectionBreak' in r | |
| ) | |
| ); | |
| let totalApiCalls = 0; | |
| // Execute delete batches first (must happen before inserts) | |
| const deleteStart = performance.now(); | |
| if (deleteRequests.length > 0) { | |
| if (log) { | |
| log.info(`Executing ${deleteRequests.length} delete requests FIRST (in separate API call)`); | |
| } | |
| for (let i = 0; i < deleteRequests.length; i += MAX_BATCH) { | |
| const batch = deleteRequests.slice(i, i + MAX_BATCH); | |
| if (log) { | |
| log.info(`Delete batch content: ${JSON.stringify(batch)}`); | |
| } | |
| await executeBatchUpdate(docs, documentId, batch); | |
| totalApiCalls++; | |
| if (log) { | |
| const batchNum = Math.floor(i / MAX_BATCH) + 1; | |
| const totalBatches = Math.ceil(deleteRequests.length / MAX_BATCH); | |
| log.info(`Executed delete batch ${batchNum}/${totalBatches} (${batch.length} requests)`); | |
| } | |
| } | |
| if (log) { | |
| log.info(`Delete batches complete. Document should now be empty (except section break).`); | |
| } | |
| } | |
| const deleteElapsed = performance.now() - deleteStart; | |
| // Then execute insert batches | |
| const insertStart = performance.now(); | |
| if (insertRequests.length > 0) { | |
| for (let i = 0; i < insertRequests.length; i += MAX_BATCH) { | |
| const batch = insertRequests.slice(i, i + MAX_BATCH); | |
| await executeBatchUpdate(docs, documentId, batch); | |
| totalApiCalls++; | |
| if (log) { | |
| const batchNum = Math.floor(i / MAX_BATCH) + 1; | |
| const totalBatches = Math.ceil(insertRequests.length / MAX_BATCH); | |
| log.info(`Executed insert batch ${batchNum}/${totalBatches} (${batch.length} requests)`); | |
| } | |
| } | |
| } | |
| const insertElapsed = performance.now() - insertStart; | |
| // Finally execute format batches | |
| const formatStart = performance.now(); | |
| if (formatRequests.length > 0) { | |
| for (let i = 0; i < formatRequests.length; i += MAX_BATCH) { | |
| const batch = formatRequests.slice(i, i + MAX_BATCH); | |
| await executeBatchUpdate(docs, documentId, batch); | |
| totalApiCalls++; | |
| if (log) { | |
| const batchNum = Math.floor(i / MAX_BATCH) + 1; | |
| const totalBatches = Math.ceil(formatRequests.length / MAX_BATCH); | |
| log.info(`Executed format batch ${batchNum}/${totalBatches} (${batch.length} requests)`); | |
| } | |
| } | |
| } | |
| const formatElapsed = performance.now() - formatStart; | |
| const totalElapsedMs = performance.now() - overallStart; | |
| return { | |
| totalRequests: requests.length, | |
| phases: { | |
| delete: { | |
| requests: deleteRequests.length, | |
| apiCalls: Math.ceil(deleteRequests.length / MAX_BATCH) || 0, | |
| elapsedMs: Math.round(deleteElapsed), | |
| }, | |
| insert: { | |
| requests: insertRequests.length, | |
| apiCalls: Math.ceil(insertRequests.length / MAX_BATCH) || 0, | |
| elapsedMs: Math.round(insertElapsed), | |
| }, | |
| format: { | |
| requests: formatRequests.length, | |
| apiCalls: Math.ceil(formatRequests.length / MAX_BATCH) || 0, | |
| elapsedMs: Math.round(formatElapsed), | |
| }, | |
| }, | |
| totalApiCalls, | |
| totalElapsedMs: Math.round(totalElapsedMs), | |
| }; | |
| } | |
| // --- Text Finding Helper --- | |
| // This improved version is more robust in handling various text structure scenarios | |
| export async function findTextRange( | |
| docs: Docs, | |
| documentId: string, | |
| textToFind: string, | |
| instance: number = 1, | |
| tabId?: string | |
| ): Promise<{ startIndex: number; endIndex: number } | null> { | |
| try { | |
| // Request more detailed information about the document structure | |
| // When tabId is specified, we need to use includeTabsContent to access tab-specific content | |
| const needsTabsContent = !!tabId; | |
| const res = await docs.documents.get({ | |
| documentId, | |
| ...(needsTabsContent && { includeTabsContent: true }), | |
| // Request more fields to handle various container types (not just paragraphs) | |
| fields: needsTabsContent | |
| ? 'tabs(tabProperties(tabId),documentTab(body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))))' | |
| : 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))', | |
| }); | |
| // Get body content from the correct tab or default | |
| let bodyContent: any[] | undefined; | |
| if (tabId) { | |
| const targetTab = findTabById(res.data, tabId); | |
| if (!targetTab) { | |
| throw new UserError(`Tab with ID "${tabId}" not found in document.`); | |
| } | |
| if (!targetTab.documentTab?.body?.content) { | |
| throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`); | |
| } | |
| bodyContent = targetTab.documentTab.body.content; | |
| } else { | |
| bodyContent = res.data.body?.content; | |
| } | |
| if (!bodyContent) { | |
| logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`); | |
| return null; | |
| } | |
| // More robust text collection and index tracking | |
| let fullText = ''; | |
| const segments: { text: string; start: number; end: number }[] = []; | |
| // Process all content elements, including structural ones | |
| const collectTextFromContent = (content: any[]) => { | |
| content.forEach((element) => { | |
| // Handle paragraph elements | |
| if (element.paragraph?.elements) { | |
| element.paragraph.elements.forEach((pe: any) => { | |
| if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) { | |
| const content = pe.textRun.content; | |
| fullText += content; | |
| segments.push({ | |
| text: content, | |
| start: pe.startIndex, | |
| end: pe.endIndex, | |
| }); | |
| } | |
| }); | |
| } | |
| // Handle table elements - this is simplified and might need expansion | |
| if (element.table && element.table.tableRows) { | |
| element.table.tableRows.forEach((row: any) => { | |
| if (row.tableCells) { | |
| row.tableCells.forEach((cell: any) => { | |
| if (cell.content) { | |
| collectTextFromContent(cell.content); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| // Add handling for other structural elements as needed | |
| }); | |
| }; | |
| collectTextFromContent(bodyContent); | |
| // Sort segments by starting position to ensure correct ordering | |
| segments.sort((a, b) => a.start - b.start); | |
| logger.debug( | |
| `Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.` | |
| ); | |
| // Find the specified instance of the text | |
| let startIndex = -1; | |
| let endIndex = -1; | |
| let foundCount = 0; | |
| let searchStartIndex = 0; | |
| while (foundCount < instance) { | |
| const currentIndex = fullText.indexOf(textToFind, searchStartIndex); | |
| if (currentIndex === -1) { | |
| logger.debug( | |
| `Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})` | |
| ); | |
| break; | |
| } | |
| foundCount++; | |
| logger.debug( | |
| `Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text` | |
| ); | |
| if (foundCount === instance) { | |
| const targetStartInFullText = currentIndex; | |
| const targetEndInFullText = currentIndex + textToFind.length; | |
| let currentPosInFullText = 0; | |
| logger.debug( | |
| `Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}` | |
| ); | |
| for (const seg of segments) { | |
| const segStartInFullText = currentPosInFullText; | |
| const segTextLength = seg.text.length; | |
| const segEndInFullText = segStartInFullText + segTextLength; | |
| // Map from reconstructed text position to actual document indices | |
| if ( | |
| startIndex === -1 && | |
| targetStartInFullText >= segStartInFullText && | |
| targetStartInFullText < segEndInFullText | |
| ) { | |
| startIndex = seg.start + (targetStartInFullText - segStartInFullText); | |
| logger.debug(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`); | |
| } | |
| if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) { | |
| endIndex = seg.start + (targetEndInFullText - segStartInFullText); | |
| logger.debug(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`); | |
| break; | |
| } | |
| currentPosInFullText = segEndInFullText; | |
| } | |
| if (startIndex === -1 || endIndex === -1) { | |
| logger.warn( | |
| `Failed to map text "${textToFind}" instance ${instance} to actual document indices` | |
| ); | |
| return { startIndex, endIndex }; | |
| } | |
| logger.debug( | |
| `Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}` | |
| ); | |
| return { startIndex, endIndex }; | |
| } | |
| // Prepare for next search iteration | |
| searchStartIndex = currentIndex + 1; | |
| } | |
| logger.warn( | |
| `Could not find instance ${instance} of text "${textToFind}" in document ${documentId}` | |
| ); | |
| return null; // Instance not found or mapping failed for all attempts | |
| } catch (error: any) { | |
| logger.error( | |
| `Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}` | |
| ); | |
| if (error.code === 404) | |
| throw new UserError(`Document not found while searching text (ID: ${documentId}).`); | |
| if (error.code === 403) | |
| throw new UserError(`Permission denied while searching text in doc ${documentId}.`); | |
| throw new Error( | |
| `Failed to retrieve doc for text searching: ${error.message || 'Unknown error'}` | |
| ); | |
| } | |
| } | |
| // --- Paragraph Boundary Helper --- | |
| // Enhanced version to handle document structural elements more robustly | |
| export async function getParagraphRange( | |
| docs: Docs, | |
| documentId: string, | |
| indexWithin: number, | |
| tabId?: string | |
| ): Promise<{ startIndex: number; endIndex: number } | null> { | |
| try { | |
| logger.debug( | |
| `Finding paragraph containing index ${indexWithin} in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}` | |
| ); | |
| // When tabId is specified, we need to use includeTabsContent to access tab-specific content | |
| const needsTabsContent = !!tabId; | |
| // Request more detailed document structure to handle nested elements | |
| const res = await docs.documents.get({ | |
| documentId, | |
| ...(needsTabsContent && { includeTabsContent: true }), | |
| // Request more comprehensive structure information | |
| fields: needsTabsContent | |
| ? 'tabs(tabProperties(tabId),documentTab(body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))))' | |
| : 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))', | |
| }); | |
| // Get body content from the correct tab or default | |
| let bodyContent: any[] | undefined; | |
| if (tabId) { | |
| const targetTab = findTabById(res.data, tabId); | |
| if (!targetTab) { | |
| throw new UserError(`Tab with ID "${tabId}" not found in document.`); | |
| } | |
| if (!targetTab.documentTab?.body?.content) { | |
| throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`); | |
| } | |
| bodyContent = targetTab.documentTab.body.content; | |
| } else { | |
| bodyContent = res.data.body?.content; | |
| } | |
| if (!bodyContent) { | |
| logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`); | |
| return null; | |
| } | |
| // Find paragraph containing the index | |
| // We'll look at all structural elements recursively | |
| const findParagraphInContent = ( | |
| content: any[] | |
| ): { startIndex: number; endIndex: number } | null => { | |
| for (const element of content) { | |
| // Check if we have element boundaries defined | |
| if (element.startIndex !== undefined && element.endIndex !== undefined) { | |
| // Check if index is within this element's range first | |
| if (indexWithin >= element.startIndex && indexWithin < element.endIndex) { | |
| // If it's a paragraph, we've found our target | |
| if (element.paragraph) { | |
| logger.debug( | |
| `Found paragraph containing index ${indexWithin}, range: ${element.startIndex}-${element.endIndex}` | |
| ); | |
| return { | |
| startIndex: element.startIndex, | |
| endIndex: element.endIndex, | |
| }; | |
| } | |
| // If it's a table, we need to check cells recursively | |
| if (element.table && element.table.tableRows) { | |
| logger.debug(`Index ${indexWithin} is within a table, searching cells...`); | |
| for (const row of element.table.tableRows) { | |
| if (row.tableCells) { | |
| for (const cell of row.tableCells) { | |
| if (cell.content) { | |
| const result = findParagraphInContent(cell.content); | |
| if (result) return result; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // For other structural elements, we didn't find a paragraph | |
| // but we know the index is within this element | |
| logger.warn( | |
| `Index ${indexWithin} is within element (${element.startIndex}-${element.endIndex}) but not in a paragraph` | |
| ); | |
| } | |
| } | |
| } | |
| return null; | |
| }; | |
| const paragraphRange = findParagraphInContent(bodyContent); | |
| if (!paragraphRange) { | |
| logger.warn(`Could not find paragraph containing index ${indexWithin}`); | |
| } else { | |
| logger.debug( | |
| `Returning paragraph range: ${paragraphRange.startIndex}-${paragraphRange.endIndex}` | |
| ); | |
| } | |
| return paragraphRange; | |
| } catch (error: any) { | |
| logger.error( | |
| `Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message || 'Unknown error'}` | |
| ); | |
| if (error.code === 404) | |
| throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`); | |
| if (error.code === 403) | |
| throw new UserError(`Permission denied while accessing doc ${documentId}.`); | |
| throw new Error(`Failed to find paragraph: ${error.message || 'Unknown error'}`); | |
| } | |
| } | |
| // --- Style Request Builders --- | |
| export function buildUpdateTextStyleRequest( | |
| startIndex: number, | |
| endIndex: number, | |
| style: TextStyleArgs, | |
| tabId?: string | |
| ): { request: docs_v1.Schema$Request; fields: string[] } | null { | |
| const textStyle: docs_v1.Schema$TextStyle = {}; | |
| const fieldsToUpdate: string[] = []; | |
| if (style.bold !== undefined) { | |
| textStyle.bold = style.bold; | |
| fieldsToUpdate.push('bold'); | |
| } | |
| if (style.italic !== undefined) { | |
| textStyle.italic = style.italic; | |
| fieldsToUpdate.push('italic'); | |
| } | |
| if (style.underline !== undefined) { | |
| textStyle.underline = style.underline; | |
| fieldsToUpdate.push('underline'); | |
| } | |
| if (style.strikethrough !== undefined) { | |
| textStyle.strikethrough = style.strikethrough; | |
| fieldsToUpdate.push('strikethrough'); | |
| } | |
| if (style.fontSize !== undefined) { | |
| textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; | |
| fieldsToUpdate.push('fontSize'); | |
| } | |
| if (style.fontFamily !== undefined) { | |
| textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; | |
| fieldsToUpdate.push('weightedFontFamily'); | |
| } | |
| if (style.foregroundColor !== undefined) { | |
| const rgbColor = hexToRgbColor(style.foregroundColor); | |
| if (!rgbColor) | |
| throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`); | |
| textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; | |
| fieldsToUpdate.push('foregroundColor'); | |
| } | |
| if (style.backgroundColor !== undefined) { | |
| const rgbColor = hexToRgbColor(style.backgroundColor); | |
| if (!rgbColor) | |
| throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`); | |
| textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; | |
| fieldsToUpdate.push('backgroundColor'); | |
| } | |
| if (style.linkUrl !== undefined) { | |
| textStyle.link = { url: style.linkUrl }; | |
| fieldsToUpdate.push('link'); | |
| } | |
| // TODO: Handle clearing formatting | |
| if (fieldsToUpdate.length === 0) return null; // No styles to apply | |
| const range: docs_v1.Schema$Range = { startIndex, endIndex }; | |
| if (tabId) { | |
| range.tabId = tabId; | |
| } | |
| const request: docs_v1.Schema$Request = { | |
| updateTextStyle: { | |
| range, | |
| textStyle: textStyle, | |
| fields: fieldsToUpdate.join(','), | |
| }, | |
| }; | |
| return { request, fields: fieldsToUpdate }; | |
| } | |
| export function buildUpdateParagraphStyleRequest( | |
| startIndex: number, | |
| endIndex: number, | |
| style: ParagraphStyleArgs, | |
| tabId?: string | |
| ): { request: docs_v1.Schema$Request; fields: string[] } | null { | |
| // Create style object and track which fields to update | |
| const paragraphStyle: docs_v1.Schema$ParagraphStyle = {}; | |
| const fieldsToUpdate: string[] = []; | |
| logger.debug( | |
| `Building paragraph style request for range ${startIndex}-${endIndex} with options:`, | |
| style | |
| ); | |
| // Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED) | |
| if (style.alignment !== undefined) { | |
| paragraphStyle.alignment = style.alignment; | |
| fieldsToUpdate.push('alignment'); | |
| logger.debug(`Setting alignment to ${style.alignment}`); | |
| } | |
| // Process indentation options | |
| if (style.indentStart !== undefined) { | |
| paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' }; | |
| fieldsToUpdate.push('indentStart'); | |
| logger.debug(`Setting left indent to ${style.indentStart}pt`); | |
| } | |
| if (style.indentEnd !== undefined) { | |
| paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' }; | |
| fieldsToUpdate.push('indentEnd'); | |
| logger.debug(`Setting right indent to ${style.indentEnd}pt`); | |
| } | |
| // Process spacing options | |
| if (style.spaceAbove !== undefined) { | |
| paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' }; | |
| fieldsToUpdate.push('spaceAbove'); | |
| logger.debug(`Setting space above to ${style.spaceAbove}pt`); | |
| } | |
| if (style.spaceBelow !== undefined) { | |
| paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' }; | |
| fieldsToUpdate.push('spaceBelow'); | |
| logger.debug(`Setting space below to ${style.spaceBelow}pt`); | |
| } | |
| // Process named style types (headings, etc.) | |
| if (style.namedStyleType !== undefined) { | |
| paragraphStyle.namedStyleType = style.namedStyleType; | |
| fieldsToUpdate.push('namedStyleType'); | |
| logger.debug(`Setting named style to ${style.namedStyleType}`); | |
| } | |
| // Process page break control | |
| if (style.keepWithNext !== undefined) { | |
| paragraphStyle.keepWithNext = style.keepWithNext; | |
| fieldsToUpdate.push('keepWithNext'); | |
| logger.debug(`Setting keepWithNext to ${style.keepWithNext}`); | |
| } | |
| // Verify we have styles to apply | |
| if (fieldsToUpdate.length === 0) { | |
| logger.warn('No paragraph styling options were provided'); | |
| return null; // No styles to apply | |
| } | |
| // Build the range with optional tabId | |
| const range: docs_v1.Schema$Range = { startIndex, endIndex }; | |
| if (tabId) { | |
| range.tabId = tabId; | |
| } | |
| // Build the request object | |
| const request: docs_v1.Schema$Request = { | |
| updateParagraphStyle: { | |
| range, | |
| paragraphStyle: paragraphStyle, | |
| fields: fieldsToUpdate.join(','), | |
| }, | |
| }; | |
| logger.debug(`Created paragraph style request with fields: ${fieldsToUpdate.join(', ')}`); | |
| return { request, fields: fieldsToUpdate }; | |
| } | |
| export function buildTableStartLocation( | |
| tableStartIndex: number, | |
| tabId?: string | |
| ): docs_v1.Schema$Location { | |
| const location: Record<string, unknown> = { index: tableStartIndex }; | |
| if (tabId) { | |
| location.tabId = tabId; | |
| } | |
| return location as docs_v1.Schema$Location; | |
| } | |
| export function buildInsertTableRowRequest( | |
| tableStartIndex: number, | |
| rowIndex: number, | |
| insertBelow: boolean, | |
| tabId?: string | |
| ): docs_v1.Schema$Request { | |
| return { | |
| insertTableRow: { | |
| tableCellLocation: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| rowIndex, | |
| columnIndex: 0, | |
| }, | |
| insertBelow, | |
| }, | |
| }; | |
| } | |
| export function buildDeleteTableRowRequest( | |
| tableStartIndex: number, | |
| rowIndex: number, | |
| tabId?: string | |
| ): docs_v1.Schema$Request { | |
| return { | |
| deleteTableRow: { | |
| tableCellLocation: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| rowIndex, | |
| columnIndex: 0, | |
| }, | |
| }, | |
| }; | |
| } | |
| type TableCellStyleArgs = { | |
| backgroundColor?: docs_v1.Schema$RgbColor; | |
| contentAlignment?: 'CONTENT_ALIGNMENT_UNSPECIFIED' | 'TOP' | 'MIDDLE' | 'BOTTOM'; | |
| rowSpan?: number; | |
| columnSpan?: number; | |
| paddingTopPt?: number; | |
| paddingBottomPt?: number; | |
| paddingLeftPt?: number; | |
| paddingRightPt?: number; | |
| borderTop?: docs_v1.Schema$TableCellBorder; | |
| borderBottom?: docs_v1.Schema$TableCellBorder; | |
| borderLeft?: docs_v1.Schema$TableCellBorder; | |
| borderRight?: docs_v1.Schema$TableCellBorder; | |
| }; | |
| function pointDimension(magnitude: number): docs_v1.Schema$Dimension { | |
| return { magnitude, unit: 'PT' }; | |
| } | |
| export function buildTableCellStyleRequest( | |
| tableStartIndex: number, | |
| rowIndex: number, | |
| columnIndex: number, | |
| style: TableCellStyleArgs, | |
| tabId?: string | |
| ): { request: docs_v1.Schema$Request; fields: string[] } | null { | |
| const tableCellStyle: Record<string, unknown> = {}; | |
| const fields: string[] = []; | |
| if (style.backgroundColor) { | |
| tableCellStyle.backgroundColor = { color: { rgbColor: style.backgroundColor } }; | |
| fields.push('backgroundColor'); | |
| } | |
| if (style.contentAlignment) { | |
| tableCellStyle.contentAlignment = style.contentAlignment; | |
| fields.push('contentAlignment'); | |
| } | |
| if (style.paddingTopPt !== undefined) { | |
| tableCellStyle.paddingTop = pointDimension(style.paddingTopPt); | |
| fields.push('paddingTop'); | |
| } | |
| if (style.paddingBottomPt !== undefined) { | |
| tableCellStyle.paddingBottom = pointDimension(style.paddingBottomPt); | |
| fields.push('paddingBottom'); | |
| } | |
| if (style.paddingLeftPt !== undefined) { | |
| tableCellStyle.paddingLeft = pointDimension(style.paddingLeftPt); | |
| fields.push('paddingLeft'); | |
| } | |
| if (style.paddingRightPt !== undefined) { | |
| tableCellStyle.paddingRight = pointDimension(style.paddingRightPt); | |
| fields.push('paddingRight'); | |
| } | |
| if (style.borderTop) { | |
| tableCellStyle.borderTop = style.borderTop; | |
| fields.push('borderTop'); | |
| } | |
| if (style.borderBottom) { | |
| tableCellStyle.borderBottom = style.borderBottom; | |
| fields.push('borderBottom'); | |
| } | |
| if (style.borderLeft) { | |
| tableCellStyle.borderLeft = style.borderLeft; | |
| fields.push('borderLeft'); | |
| } | |
| if (style.borderRight) { | |
| tableCellStyle.borderRight = style.borderRight; | |
| fields.push('borderRight'); | |
| } | |
| if (fields.length === 0) return null; | |
| const rowSpan = style.rowSpan ?? 1; | |
| const columnSpan = style.columnSpan ?? 1; | |
| return { | |
| request: { | |
| updateTableCellStyle: { | |
| tableRange: { | |
| tableCellLocation: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| rowIndex, | |
| columnIndex, | |
| }, | |
| rowSpan, | |
| columnSpan, | |
| }, | |
| tableCellStyle: tableCellStyle as docs_v1.Schema$TableCellStyle, | |
| fields: fields.join(','), | |
| }, | |
| }, | |
| fields, | |
| }; | |
| } | |
| export function buildTableBorder( | |
| color: docs_v1.Schema$RgbColor, | |
| widthPt: number, | |
| dashStyle: 'SOLID' | 'DASHED' | 'DOTTED' | |
| ): docs_v1.Schema$TableCellBorder { | |
| return { | |
| color: { color: { rgbColor: color } }, | |
| width: pointDimension(widthPt), | |
| dashStyle, | |
| }; | |
| } | |
| export function buildTableColumnWidthRequest( | |
| tableStartIndex: number, | |
| columnIndices: number[], | |
| widthPt: number, | |
| tabId?: string | |
| ): docs_v1.Schema$Request { | |
| const request: any = { | |
| updateTableColumnProperties: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| columnIndices, | |
| tableColumnProperties: { | |
| widthType: 'FIXED_WIDTH', | |
| width: pointDimension(widthPt), | |
| }, | |
| fields: 'widthType,width', | |
| }, | |
| }; | |
| return request as docs_v1.Schema$Request; | |
| } | |
| export function buildTableRowStyleRequest( | |
| tableStartIndex: number, | |
| rowIndices: number[], | |
| minRowHeightPt: number | undefined, | |
| preventOverflow: boolean | undefined, | |
| tabId?: string | |
| ): docs_v1.Schema$Request | null { | |
| const tableRowStyle: Record<string, unknown> = {}; | |
| const fields: string[] = []; | |
| if (minRowHeightPt !== undefined) { | |
| tableRowStyle.minRowHeight = pointDimension(minRowHeightPt); | |
| fields.push('minRowHeight'); | |
| } | |
| if (preventOverflow !== undefined) { | |
| tableRowStyle.preventOverflow = preventOverflow; | |
| fields.push('preventOverflow'); | |
| } | |
| if (fields.length === 0) return null; | |
| const request: any = { | |
| updateTableRowStyle: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| rowIndices, | |
| tableRowStyle, | |
| fields: fields.join(','), | |
| }, | |
| }; | |
| return request as docs_v1.Schema$Request; | |
| } | |
| export function buildPinTableHeaderRowsRequest( | |
| tableStartIndex: number, | |
| pinnedHeaderRowsCount: number, | |
| tabId?: string | |
| ): docs_v1.Schema$Request { | |
| const request: any = { | |
| pinTableHeaderRows: { | |
| tableStartLocation: buildTableStartLocation(tableStartIndex, tabId), | |
| pinnedHeaderRowsCount, | |
| }, | |
| }; | |
| return request as docs_v1.Schema$Request; | |
| } | |
| // --- Specific Feature Helpers --- | |
| export async function createTable( | |
| docs: Docs, | |
| documentId: string, | |
| rows: number, | |
| columns: number, | |
| index: number, | |
| tabId?: string | |
| ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { | |
| if (rows < 1 || columns < 1) { | |
| throw new UserError('Table must have at least 1 row and 1 column.'); | |
| } | |
| const location: any = { index }; | |
| if (tabId) { | |
| location.tabId = tabId; | |
| } | |
| const request: docs_v1.Schema$Request = { | |
| insertTable: { | |
| location, | |
| rows: rows, | |
| columns: columns, | |
| }, | |
| }; | |
| return executeBatchUpdate(docs, documentId, [request]); | |
| } | |
| export async function insertText( | |
| docs: Docs, | |
| documentId: string, | |
| text: string, | |
| index: number | |
| ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { | |
| if (!text) return {}; // Nothing to insert | |
| const request: docs_v1.Schema$Request = { | |
| insertText: { | |
| location: { index }, | |
| text: text, | |
| }, | |
| }; | |
| return executeBatchUpdate(docs, documentId, [request]); | |
| } | |
| // --- Table Cell Helper --- | |
| /** | |
| * Finds the content range of a specific table cell. | |
| * Returns the start and end indices of the cell's text content (excluding trailing newline). | |
| */ | |
| export async function getTableCellRange( | |
| docs: Docs, | |
| documentId: string, | |
| tableStartIndex: number, | |
| rowIndex: number, | |
| columnIndex: number, | |
| tabId?: string | |
| ): Promise<{ startIndex: number; endIndex: number }> { | |
| const res = await docs.documents.get({ | |
| documentId, | |
| ...(tabId && { includeTabsContent: true }), | |
| }); | |
| // Get body content from the correct tab or default | |
| let bodyContent: docs_v1.Schema$StructuralElement[] | undefined; | |
| if (tabId) { | |
| const allTabs = getAllTabs(res.data); | |
| const tab = allTabs.find((t) => t.tabProperties?.tabId === tabId); | |
| if (!tab) throw new UserError(`Tab with ID "${tabId}" not found.`); | |
| bodyContent = tab.documentTab?.body?.content; | |
| } else { | |
| bodyContent = res.data.body?.content; | |
| } | |
| if (!bodyContent) { | |
| throw new UserError(`No content found in document ${documentId}.`); | |
| } | |
| // Find the table element matching tableStartIndex | |
| const tableElement = bodyContent.find((el) => el.table && el.startIndex === tableStartIndex); | |
| if (!tableElement || !tableElement.table) { | |
| throw new UserError( | |
| `No table found at startIndex ${tableStartIndex}. Use readGoogleDoc with format='json' to find the correct table startIndex.` | |
| ); | |
| } | |
| const table = tableElement.table; | |
| const rows = table.tableRows; | |
| if (!rows || rowIndex < 0 || rowIndex >= rows.length) { | |
| throw new UserError( | |
| `Row index ${rowIndex} is out of range. Table has ${rows?.length ?? 0} rows (0-based).` | |
| ); | |
| } | |
| const cells = rows[rowIndex].tableCells; | |
| if (!cells || columnIndex < 0 || columnIndex >= cells.length) { | |
| throw new UserError( | |
| `Column index ${columnIndex} is out of range. Row ${rowIndex} has ${cells?.length ?? 0} columns (0-based).` | |
| ); | |
| } | |
| const cell = cells[columnIndex]; | |
| const cellContent = cell.content; | |
| if (!cellContent || cellContent.length === 0) { | |
| throw new UserError(`Cell (${rowIndex}, ${columnIndex}) has no content elements.`); | |
| } | |
| // Cell always has at least one paragraph with a trailing \n. | |
| // We want the range covering all content *before* that final \n. | |
| const firstParagraph = cellContent[0]; | |
| const lastParagraph = cellContent[cellContent.length - 1]; | |
| const cellStartIndex = firstParagraph.startIndex; | |
| // The endIndex of the last paragraph includes the trailing \n. | |
| // We subtract 1 to exclude it so delete operations don't remove the cell structure. | |
| const cellEndIndex = lastParagraph.endIndex; | |
| if (cellStartIndex == null || cellEndIndex == null) { | |
| throw new UserError( | |
| `Could not determine content range for cell (${rowIndex}, ${columnIndex}).` | |
| ); | |
| } | |
| return { startIndex: cellStartIndex, endIndex: cellEndIndex - 1 }; | |
| } | |
| // --- Complex / Stubbed Helpers --- | |
| export async function findParagraphsMatchingStyle( | |
| docs: Docs, | |
| documentId: string, | |
| styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true }) | |
| ): Promise<{ startIndex: number; endIndex: number }[]> { | |
| // TODO: Implement logic | |
| // 1. Get document content with paragraph elements and their styles. | |
| // 2. Iterate through paragraphs. | |
| // 3. For each paragraph, check if its computed style matches the criteria. | |
| // 4. Return ranges of matching paragraphs. | |
| logger.warn('findParagraphsMatchingStyle is not implemented.'); | |
| throw new NotImplementedError('Finding paragraphs by style criteria is not yet implemented.'); | |
| // return []; | |
| } | |
| export async function detectAndFormatLists( | |
| docs: Docs, | |
| documentId: string, | |
| startIndex?: number, | |
| endIndex?: number | |
| ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { | |
| // TODO: Implement complex logic | |
| // 1. Get document content (paragraphs, text runs) in the specified range (or whole doc). | |
| // 2. Iterate through paragraphs. | |
| // 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "*", "1.", "a)"). | |
| // 4. Determine nesting levels based on indentation or marker patterns. | |
| // 5. Generate CreateParagraphBulletsRequests for the identified sequences. | |
| // 6. Potentially delete the original marker text. | |
| // 7. Execute the batch update. | |
| logger.warn('detectAndFormatLists is not implemented.'); | |
| throw new NotImplementedError('Automatic list detection and formatting is not yet implemented.'); | |
| // return {}; | |
| } | |
| export async function addCommentHelper( | |
| docs: Docs, | |
| documentId: string, | |
| text: string, | |
| startIndex: number, | |
| endIndex: number | |
| ): Promise<void> { | |
| // NOTE: Adding comments typically requires the Google Drive API v3 and different scopes! | |
| // 'https://www.googleapis.com/auth/drive' or more specific comment scopes. | |
| // This helper is a placeholder assuming Drive API client (`drive`) is available and authorized. | |
| /* | |
| const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available | |
| await drive.comments.create({ | |
| fileId: documentId, | |
| requestBody: { | |
| content: text, | |
| anchor: JSON.stringify({ // Anchor format might need verification | |
| 'type': 'workbook#textAnchor', // Or appropriate type for Docs | |
| 'refs': [{ | |
| 'docRevisionId': 'head', // Or specific revision | |
| 'range': { | |
| 'start': startIndex, | |
| 'end': endIndex, | |
| } | |
| }] | |
| }) | |
| }, | |
| fields: 'id' | |
| }); | |
| */ | |
| logger.warn('addCommentHelper requires Google Drive API and is not implemented.'); | |
| throw new NotImplementedError( | |
| 'Adding comments requires Drive API setup and is not yet implemented.' | |
| ); | |
| } | |
| // --- Image Insertion Helpers --- | |
| /** | |
| * Inserts an inline image into a document from a publicly accessible URL | |
| * @param docs - Google Docs API client | |
| * @param documentId - The document ID | |
| * @param imageUrl - Publicly accessible URL to the image | |
| * @param index - Position in the document where image should be inserted (1-based) | |
| * @param width - Optional width in points | |
| * @param height - Optional height in points | |
| * @returns Promise with batch update response | |
| */ | |
| export async function insertInlineImage( | |
| docs: Docs, | |
| documentId: string, | |
| imageUrl: string, | |
| index: number, | |
| width?: number, | |
| height?: number, | |
| tabId?: string | |
| ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { | |
| // Validate URL format | |
| try { | |
| new URL(imageUrl); | |
| } catch (e) { | |
| throw new UserError(`Invalid image URL format: ${imageUrl}`); | |
| } | |
| // Build the insertInlineImage request | |
| const location: any = { index }; | |
| if (tabId) { | |
| location.tabId = tabId; | |
| } | |
| const request: docs_v1.Schema$Request = { | |
| insertInlineImage: { | |
| location, | |
| uri: imageUrl, | |
| ...(width && | |
| height && { | |
| objectSize: { | |
| height: { magnitude: height, unit: 'PT' }, | |
| width: { magnitude: width, unit: 'PT' }, | |
| }, | |
| }), | |
| }, | |
| }; | |
| return executeBatchUpdate(docs, documentId, [request]); | |
| } | |
| /** | |
| * Uploads a local image file to Google Drive. | |
| * | |
| * When `skipPublicSharing` is false (default), the file is made publicly | |
| * readable and its webContentLink is returned — required for the Docs API | |
| * insertInlineImage approach. | |
| * | |
| * When `skipPublicSharing` is true, only the Drive file ID is returned. | |
| * Use this with the Apps Script insertion path where no public URL is needed. | |
| */ | |
| export async function uploadImageToDrive( | |
| drive: any, // drive_v3.Drive type | |
| localFilePath: string, | |
| parentFolderId?: string, | |
| skipPublicSharing: boolean = true | |
| ): Promise<string> { | |
| const fs = await import('fs'); | |
| const path = await import('path'); | |
| if (!fs.existsSync(localFilePath)) { | |
| throw new UserError(`Image file not found: ${localFilePath}`); | |
| } | |
| const resolvedPath = path.resolve(localFilePath); | |
| const cwd = path.resolve(process.cwd()); | |
| if (!resolvedPath.startsWith(cwd + path.sep) && resolvedPath !== cwd) { | |
| throw new UserError('Image file path must be within the working directory.'); | |
| } | |
| const fileName = path.basename(localFilePath); | |
| const mimeTypeMap: { [key: string]: string } = { | |
| '.jpg': 'image/jpeg', | |
| '.jpeg': 'image/jpeg', | |
| '.png': 'image/png', | |
| '.gif': 'image/gif', | |
| '.bmp': 'image/bmp', | |
| '.webp': 'image/webp', | |
| '.svg': 'image/svg+xml', | |
| }; | |
| const ext = path.extname(localFilePath).toLowerCase(); | |
| const mimeType = mimeTypeMap[ext] || 'application/octet-stream'; | |
| const fileMetadata: any = { | |
| name: fileName, | |
| mimeType: mimeType, | |
| }; | |
| if (parentFolderId) { | |
| fileMetadata.parents = [parentFolderId]; | |
| } | |
| const media = { | |
| mimeType: mimeType, | |
| body: fs.createReadStream(localFilePath), | |
| }; | |
| const uploadResponse = await drive.files.create({ | |
| requestBody: fileMetadata, | |
| media: media, | |
| fields: 'id,webViewLink,webContentLink', | |
| supportsAllDrives: true, | |
| }); | |
| const fileId = uploadResponse.data.id; | |
| if (!fileId) { | |
| throw new Error('Failed to upload image to Drive - no file ID returned'); | |
| } | |
| if (skipPublicSharing) { | |
| return fileId; | |
| } | |
| await drive.permissions.create({ | |
| fileId: fileId, | |
| requestBody: { | |
| role: 'reader', | |
| type: 'anyone', | |
| }, | |
| supportsAllDrives: true, | |
| }); | |
| const fileInfo = await drive.files.get({ | |
| fileId: fileId, | |
| fields: 'webContentLink', | |
| supportsAllDrives: true, | |
| }); | |
| const webContentLink = fileInfo.data.webContentLink; | |
| if (!webContentLink) { | |
| throw new Error('Failed to get public URL for uploaded image'); | |
| } | |
| return webContentLink; | |
| } | |
| /** | |
| * Inserts an image into a Google Doc via Apps Script. | |
| * | |
| * Flow: | |
| * 1. Insert a unique marker string at the target index using the Docs API. | |
| * 2. Call the deployed Apps Script which finds the marker and replaces it | |
| * with the actual image blob from Drive (no public sharing needed). | |
| */ | |
| export async function insertImageViaAppsScript( | |
| docs: Docs, | |
| scriptClient: any, // script_v1.Script type | |
| deploymentId: string, | |
| documentId: string, | |
| driveFileId: string, | |
| charIndex: number, | |
| tabId?: string | |
| ): Promise<void> { | |
| const marker = `[mcp-img-${driveFileId}]`; | |
| // Step 1: Insert marker at the requested position via Docs API | |
| const location: any = { index: charIndex }; | |
| if (tabId) { | |
| location.tabId = tabId; | |
| } | |
| await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }]); | |
| // Step 2: Call Apps Script to replace the marker with the image | |
| const response = await scriptClient.scripts.run({ | |
| scriptId: deploymentId, | |
| requestBody: { | |
| function: 'insertImageByFileId', | |
| parameters: [documentId, driveFileId], | |
| }, | |
| }); | |
| const result = response.data?.response?.result; | |
| if (!result || !result.success) { | |
| const msg = result?.message || 'Unknown Apps Script error'; | |
| throw new Error(`Apps Script image insertion failed: ${msg}`); | |
| } | |
| } | |
| // --- Tab Management Helpers --- | |
| /** | |
| * Interface for a tab with hierarchy level information | |
| */ | |
| export interface TabWithLevel extends docs_v1.Schema$Tab { | |
| level: number; | |
| } | |
| /** | |
| * Recursively collect all tabs from a document in a flat list with hierarchy info | |
| * @param doc - The Google Doc document object | |
| * @returns Array of tabs with nesting level information | |
| */ | |
| export function getAllTabs(doc: docs_v1.Schema$Document): TabWithLevel[] { | |
| const allTabs: TabWithLevel[] = []; | |
| if (!doc.tabs || doc.tabs.length === 0) { | |
| return allTabs; | |
| } | |
| for (const tab of doc.tabs) { | |
| addCurrentAndChildTabs(tab, allTabs, 0); | |
| } | |
| return allTabs; | |
| } | |
| /** | |
| * Recursive helper to add tabs with their nesting level | |
| * @param tab - The tab to add | |
| * @param allTabs - The accumulator array | |
| * @param level - Current nesting level (0 for top-level) | |
| */ | |
| function addCurrentAndChildTabs( | |
| tab: docs_v1.Schema$Tab, | |
| allTabs: TabWithLevel[], | |
| level: number | |
| ): void { | |
| allTabs.push({ ...tab, level }); | |
| if (tab.childTabs && tab.childTabs.length > 0) { | |
| for (const childTab of tab.childTabs) { | |
| addCurrentAndChildTabs(childTab, allTabs, level + 1); | |
| } | |
| } | |
| } | |
| /** | |
| * Get the text length from a DocumentTab | |
| * @param documentTab - The DocumentTab object | |
| * @returns Total character count | |
| */ | |
| export function getTabTextLength(documentTab: docs_v1.Schema$DocumentTab | undefined): number { | |
| let totalLength = 0; | |
| if (!documentTab?.body?.content) { | |
| return 0; | |
| } | |
| documentTab.body.content.forEach((element: any) => { | |
| // Handle paragraphs | |
| if (element.paragraph?.elements) { | |
| element.paragraph.elements.forEach((pe: any) => { | |
| if (pe.textRun?.content) { | |
| totalLength += pe.textRun.content.length; | |
| } | |
| }); | |
| } | |
| // Handle tables | |
| if (element.table?.tableRows) { | |
| element.table.tableRows.forEach((row: any) => { | |
| row.tableCells?.forEach((cell: any) => { | |
| cell.content?.forEach((cellElement: any) => { | |
| cellElement.paragraph?.elements?.forEach((pe: any) => { | |
| if (pe.textRun?.content) { | |
| totalLength += pe.textRun.content.length; | |
| } | |
| }); | |
| }); | |
| }); | |
| }); | |
| } | |
| }); | |
| return totalLength; | |
| } | |
| /** | |
| * Find a specific tab by ID in a document (searches recursively through child tabs) | |
| * @param doc - The Google Doc document object | |
| * @param tabId - The tab ID to search for | |
| * @returns The tab object if found, null otherwise | |
| */ | |
| export function findTabById( | |
| doc: docs_v1.Schema$Document, | |
| tabId: string | |
| ): docs_v1.Schema$Tab | null { | |
| if (!doc.tabs || doc.tabs.length === 0) { | |
| return null; | |
| } | |
| // Helper function to search through tabs recursively | |
| const searchTabs = (tabs: docs_v1.Schema$Tab[]): docs_v1.Schema$Tab | null => { | |
| for (const tab of tabs) { | |
| if (tab.tabProperties?.tabId === tabId) { | |
| return tab; | |
| } | |
| // Recursively search child tabs | |
| if (tab.childTabs && tab.childTabs.length > 0) { | |
| const found = searchTabs(tab.childTabs); | |
| if (found) return found; | |
| } | |
| } | |
| return null; | |
| }; | |
| return searchTabs(doc.tabs); | |
| } | |