Skip to main content
Glama
documents.py78.2 kB
""" Document operations for Google Docs MCP Server. Handles reading, writing, and formatting document content. """ from typing import Any import re from fastmcp.exceptions import ToolError from google_docs_mcp.auth import get_docs_client, get_drive_client from google_docs_mcp.types import TextStyleArgs, ParagraphStyleArgs from google_docs_mcp.api import helpers from google_docs_mcp.utils import log def _export_document_as_markdown( document_id: str, tab_id: str | None = None, max_length: int | None = None ) -> str: """ Export a Google Document as markdown using Drive API's native export. Uses Google Drive API's native markdown export (July 2024+). Args: document_id: The ID of the Google Document tab_id: Optional tab ID (warning: Drive API exports entire document) max_length: Maximum character limit for output Returns: Markdown string content Raises: ToolError: For API errors """ drive = get_drive_client() # Warn if tab_id is specified since Drive API exports entire document if tab_id: log(f"Warning: tab_id '{tab_id}' specified but Drive API markdown export will export the entire document") try: # Export document as markdown using Drive API markdown_bytes = ( drive.files() .export(fileId=document_id, mimeType='text/markdown') .execute() ) markdown_content = markdown_bytes.decode('utf-8') total_length = len(markdown_content) log(f"Exported document {document_id} as markdown using native Drive API: {total_length} characters") # Apply max_length truncation if needed if max_length and total_length > max_length: truncated = markdown_content[:max_length] return ( f"{truncated}\n\n... [Markdown truncated to {max_length} chars " f"of {total_length} total. Use maxLength parameter to adjust limit " f"or remove it to get full content.]" ) return markdown_content except Exception as e: error_message = str(e) log(f"Error exporting document as markdown: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError("Permission denied. Make sure you have read access to the document.") raise ToolError(f"Failed to export document as markdown: {error_message}") def read_document( document_id: str, format: str = "text", max_length: int | None = None, tab_id: str | None = None, ) -> str: """ Read the content of a Google Document. Args: document_id: The ID of the Google Document format: Output format ('text', 'json', 'markdown') max_length: Maximum character limit for output tab_id: Specific tab ID to read from Returns: Document content in the specified format Raises: UserError: For permission/not found errors """ import json docs = get_docs_client() log( f"Reading Google Doc: {document_id}, Format: {format}" f"{f', Tab: {tab_id}' if tab_id else ''}" ) try: needs_tabs_content = bool(tab_id) if format in ("json", "markdown"): fields = "*" else: fields = "body(content(paragraph(elements(textRun(content)))))" res = ( docs.documents() .get( documentId=document_id, includeTabsContent=needs_tabs_content, fields="*" if needs_tabs_content else fields, ) .execute() ) log(f"Fetched doc: {document_id}{f' (tab: {tab_id})' if tab_id else ''}") # Determine content source content_source: dict if tab_id: target_tab = helpers.find_tab_by_id(res, tab_id) if not target_tab: raise ToolError(f'Tab with ID "{tab_id}" not found in document.') if not target_tab.get("documentTab"): raise ToolError( f'Tab "{tab_id}" does not have content (may not be a document tab).' ) content_source = {"body": target_tab["documentTab"].get("body", {})} tab_title = target_tab.get("tabProperties", {}).get("title", "Untitled") log(f"Using content from tab: {tab_title}") else: content_source = res if format == "json": json_content = json.dumps(content_source, indent=2) if max_length and len(json_content) > max_length: return ( json_content[:max_length] + f"\n... [JSON truncated: {len(json_content)} total chars]" ) return json_content if format == "markdown": # Use native Drive API export for markdown return _export_document_as_markdown(document_id, tab_id, max_length) # Default: Text format text_content = "" element_count = 0 body = content_source.get("body", {}) for element in body.get("content", []): element_count += 1 # Handle paragraphs paragraph = element.get("paragraph", {}) for pe in paragraph.get("elements", []): text_run = pe.get("textRun", {}) if text_run.get("content"): text_content += text_run["content"] # Handle tables table = element.get("table", {}) for row in table.get("tableRows", []): for cell in row.get("tableCells", []): for cell_element in cell.get("content", []): cell_para = cell_element.get("paragraph", {}) for pe in cell_para.get("elements", []): text_run = pe.get("textRun", {}) if text_run.get("content"): text_content += text_run["content"] if not text_content.strip(): return "Document found, but appears empty." total_length = len(text_content) log( f"Document contains {total_length} characters across {element_count} elements" ) if max_length and total_length > max_length: truncated = text_content[:max_length] log(f"Truncating content from {total_length} to {max_length} characters") return ( f"Content (truncated to {max_length} chars of {total_length} total):\n" f"---\n{truncated}\n\n... [Document continues for " f"{total_length - max_length} more characters. Use maxLength parameter " f"to adjust limit or remove it to get full content.]" ) return f"Content ({total_length} characters):\n---\n{text_content}" except ToolError: raise except Exception as e: error_message = str(e) log(f"Error reading doc {document_id}: {error_message}") if "404" in error_message: raise ToolError(f"Doc not found (ID: {document_id}).") if "403" in error_message: raise ToolError(f"Permission denied for doc (ID: {document_id}).") raise ToolError(f"Failed to read doc: {error_message}") def list_document_tabs(document_id: str, include_content: bool = False) -> str: """ List all tabs in a Google Document. Args: document_id: The ID of the Google Document include_content: Whether to include content summary for each tab Returns: Formatted string with tab information Raises: UserError: For permission/not found errors """ docs = get_docs_client() log(f"Listing tabs for document: {document_id}") try: fields = ( "title,tabs" if include_content else "title,tabs(tabProperties,childTabs)" ) res = ( docs.documents() .get(documentId=document_id, includeTabsContent=True, fields=fields) .execute() ) doc_title = res.get("title", "Untitled Document") all_tabs = helpers.get_all_tabs(res) if not all_tabs: return f'Document "{doc_title}" appears to have no tabs (unexpected).' is_single_tab = len(all_tabs) == 1 result = f'**Document:** "{doc_title}"\n' result += f"**Total tabs:** {len(all_tabs)}" result += " (single-tab document)\n\n" if is_single_tab else "\n\n" if not is_single_tab: result += "**Tab Structure:**\n" result += "-" * 50 + "\n\n" for index, tab in enumerate(all_tabs): level = tab.level indent = " " * level if is_single_tab: result += "**Default Tab:**\n" result += f"- Tab ID: {tab.tab_id}\n" result += f"- Title: {tab.title or '(Untitled)'}\n" else: prefix = "└─ " if level > 0 else "" result += f'{indent}{prefix}**Tab {index + 1}:** "{tab.title}"\n' result += f"{indent} - ID: {tab.tab_id}\n" result += f"{indent} - Index: {tab.index if tab.index is not None else 'N/A'}\n" if tab.parent_tab_id: result += f"{indent} - Parent Tab ID: {tab.parent_tab_id}\n" if include_content and tab.text_length is not None: content_info = ( f"{tab.text_length:,} characters" if tab.text_length > 0 else "Empty" ) result += f"{indent} - Content: {content_info}\n" if not is_single_tab: result += "\n" if not is_single_tab: result += "\nTip: Use tab IDs with other tools to target specific tabs." return result except ToolError: raise except Exception as e: error_message = str(e) log(f"Error listing tabs for doc {document_id}: {error_message}") if "404" in error_message: raise ToolError(f"Document not found (ID: {document_id}).") if "403" in error_message: raise ToolError(f"Permission denied for document (ID: {document_id}).") raise ToolError(f"Failed to list tabs: {error_message}") def append_to_document( document_id: str, text_to_append: str, add_newline_if_needed: bool = True, tab_id: str | None = None, ) -> str: """ Append text to the end of a Google Document. Args: document_id: The ID of the Google Document text_to_append: The text to add to the end add_newline_if_needed: Whether to add a newline before appended text tab_id: Specific tab ID to append to Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log( f"Appending to Google Doc: {document_id}" f"{f' (tab: {tab_id})' if tab_id else ''}" ) try: needs_tabs_content = bool(tab_id) doc_info = ( docs.documents() .get( documentId=document_id, includeTabsContent=needs_tabs_content, fields="tabs" if needs_tabs_content else "body(content(endIndex)),documentStyle(pageSize)", ) .execute() ) end_index = 1 body_content: list = [] if tab_id: target_tab = helpers.find_tab_by_id(doc_info, tab_id) if not target_tab: raise ToolError(f'Tab with ID "{tab_id}" not found in document.') if not target_tab.get("documentTab"): raise ToolError( f'Tab "{tab_id}" does not have content (may not be a document tab).' ) body_content = ( target_tab.get("documentTab", {}).get("body", {}).get("content", []) ) else: body_content = doc_info.get("body", {}).get("content", []) if body_content: last_element = body_content[-1] if last_element.get("endIndex"): end_index = last_element["endIndex"] - 1 text_to_insert = ( ("\n" if add_newline_if_needed and end_index > 1 else "") + text_to_append ) if not text_to_insert: return "Nothing to append." location: dict[str, Any] = {"index": end_index} if tab_id: location["tabId"] = tab_id request = {"insertText": {"location": location, "text": text_to_insert}} helpers.execute_batch_update_sync(docs, document_id, [request]) log( f"Successfully appended to doc: {document_id}" f"{f' (tab: {tab_id})' if tab_id else ''}" ) return ( f"Successfully appended text to " f"{f'tab {tab_id} in ' if tab_id else ''}document {document_id}." ) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error appending to doc {document_id}: {error_message}") raise ToolError(f"Failed to append to doc: {error_message}") def insert_text( document_id: str, text_to_insert: str, index: int, tab_id: str | None = None, ) -> str: """ Insert text at a specific index in a document. Args: document_id: The ID of the Google Document text_to_insert: The text to insert index: The index where text should be inserted (1-based) tab_id: Specific tab ID to insert into Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log( f"Inserting text in doc {document_id} at index {index}" f"{f' (tab: {tab_id})' if tab_id else ''}" ) try: if tab_id: doc_info = ( docs.documents() .get( documentId=document_id, includeTabsContent=True, fields="tabs(tabProperties,documentTab)", ) .execute() ) target_tab = helpers.find_tab_by_id(doc_info, tab_id) if not target_tab: raise ToolError(f'Tab with ID "{tab_id}" not found in document.') if not target_tab.get("documentTab"): raise ToolError( f'Tab "{tab_id}" does not have content (may not be a document tab).' ) location: dict[str, Any] = {"index": index, "tabId": tab_id} request = {"insertText": {"location": location, "text": text_to_insert}} helpers.execute_batch_update_sync(docs, document_id, [request]) else: helpers.insert_text(docs, document_id, text_to_insert, index) return ( f"Successfully inserted text at index {index}" f"{f' in tab {tab_id}' if tab_id else ''}." ) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting text in doc {document_id}: {error_message}") raise ToolError(f"Failed to insert text: {error_message}") def delete_range( document_id: str, start_index: int, end_index: int, tab_id: str | None = None, ) -> str: """ Delete content within a specified range. Args: document_id: The ID of the Google Document start_index: Starting index (inclusive, 1-based) end_index: Ending index (exclusive) tab_id: Specific tab ID to delete from Returns: Success message Raises: UserError: For permission/not found errors or invalid range """ docs = get_docs_client() log( f"Deleting range {start_index}-{end_index} in doc {document_id}" f"{f' (tab: {tab_id})' if tab_id else ''}" ) if end_index <= start_index: raise ToolError("End index must be greater than start index for deletion.") try: if tab_id: doc_info = ( docs.documents() .get( documentId=document_id, includeTabsContent=True, fields="tabs(tabProperties,documentTab)", ) .execute() ) target_tab = helpers.find_tab_by_id(doc_info, tab_id) if not target_tab: raise ToolError(f'Tab with ID "{tab_id}" not found in document.') if not target_tab.get("documentTab"): raise ToolError( f'Tab "{tab_id}" does not have content (may not be a document tab).' ) range_dict: dict[str, Any] = { "startIndex": start_index, "endIndex": end_index, } if tab_id: range_dict["tabId"] = tab_id request = {"deleteContentRange": {"range": range_dict}} helpers.execute_batch_update_sync(docs, document_id, [request]) return ( f"Successfully deleted content in range {start_index}-{end_index}" f"{f' in tab {tab_id}' if tab_id else ''}." ) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error deleting range in doc {document_id}: {error_message}") raise ToolError(f"Failed to delete range: {error_message}") def apply_text_style( document_id: str, style: TextStyleArgs, start_index: int | None = None, end_index: int | None = None, text_to_find: str | None = None, match_instance: int = 1, ) -> str: """ Apply text formatting to a range or found text. Args: document_id: The ID of the Google Document style: Text style arguments to apply start_index: Starting index (if targeting by range) end_index: Ending index (if targeting by range) text_to_find: Text to find and format (if targeting by text) match_instance: Which instance of text to target (default 1) Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log( f"Applying text style in doc {document_id}. " f"Target: range={start_index}-{end_index}, text='{text_to_find}'" ) try: # Determine target range if text_to_find: text_range = helpers.find_text_range( docs, document_id, text_to_find, match_instance ) if not text_range: raise ToolError( f'Could not find instance {match_instance} of text "{text_to_find}".' ) start_index = text_range.start_index end_index = text_range.end_index log( f'Found text "{text_to_find}" (instance {match_instance}) ' f"at range {start_index}-{end_index}" ) if start_index is None or end_index is None: raise ToolError("Target range could not be determined.") if end_index <= start_index: raise ToolError("End index must be greater than start index for styling.") # Build the request request_info = helpers.build_update_text_style_request( start_index, end_index, style ) if not request_info: return "No valid text styling options were provided." helpers.execute_batch_update_sync(docs, document_id, [request_info["request"]]) return ( f"Successfully applied text style ({', '.join(request_info['fields'])}) " f"to range {start_index}-{end_index}." ) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error applying text style in doc {document_id}: {error_message}") raise ToolError(f"Failed to apply text style: {error_message}") def apply_paragraph_style( document_id: str, style: ParagraphStyleArgs, start_index: int | None = None, end_index: int | None = None, text_to_find: str | None = None, match_instance: int = 1, index_within_paragraph: int | None = None, ) -> str: """ Apply paragraph formatting to a paragraph. Args: document_id: The ID of the Google Document style: Paragraph style arguments to apply start_index: Starting index (if targeting by range) end_index: Ending index (if targeting by range) text_to_find: Text to find (styles paragraph containing it) match_instance: Which instance of text to target (default 1) index_within_paragraph: Index within target paragraph Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log(f"Applying paragraph style to document {document_id}") try: # Determine target range if text_to_find: log(f'Finding text "{text_to_find}" (instance {match_instance})') text_range = helpers.find_text_range( docs, document_id, text_to_find, match_instance ) if not text_range: raise ToolError(f'Could not find "{text_to_find}" in the document.') log( f"Found text at range {text_range.start_index}-{text_range.end_index}, " f"now locating containing paragraph" ) paragraph_range = helpers.get_paragraph_range( docs, document_id, text_range.start_index ) if not paragraph_range: raise ToolError( "Found the text but could not determine the paragraph boundaries." ) start_index = paragraph_range.start_index end_index = paragraph_range.end_index log(f"Text is contained within paragraph at range {start_index}-{end_index}") elif index_within_paragraph is not None: log(f"Finding paragraph containing index {index_within_paragraph}") paragraph_range = helpers.get_paragraph_range( docs, document_id, index_within_paragraph ) if not paragraph_range: raise ToolError( f"Could not find paragraph containing index {index_within_paragraph}." ) start_index = paragraph_range.start_index end_index = paragraph_range.end_index log(f"Located paragraph at range {start_index}-{end_index}") if start_index is None or end_index is None: raise ToolError( "Could not determine target paragraph range from the provided information." ) if end_index <= start_index: raise ToolError( f"Invalid paragraph range: end index ({end_index}) must be " f"greater than start index ({start_index})." ) # Build and apply the paragraph style request log(f"Building paragraph style request for range {start_index}-{end_index}") request_info = helpers.build_update_paragraph_style_request( start_index, end_index, style ) if not request_info: return "No valid paragraph styling options were provided." log(f"Applying styles: {', '.join(request_info['fields'])}") helpers.execute_batch_update_sync(docs, document_id, [request_info["request"]]) return ( f"Successfully applied paragraph styles " f"({', '.join(request_info['fields'])}) to the paragraph." ) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error applying paragraph style in doc {document_id}: {error_message}") raise ToolError(f"Failed to apply paragraph style: {error_message}") def insert_table(document_id: str, rows: int, columns: int, index: int) -> str: """ Insert a new table into a document. Args: document_id: The ID of the Google Document rows: Number of rows columns: Number of columns index: Position to insert the table (1-based) Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting {rows}x{columns} table in doc {document_id} at index {index}") try: helpers.create_table(docs, document_id, rows, columns, index) return f"Successfully inserted a {rows}x{columns} table at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting table in doc {document_id}: {error_message}") raise ToolError(f"Failed to insert table: {error_message}") def insert_page_break(document_id: str, index: int) -> str: """ Insert a page break at a specific position. Args: document_id: The ID of the Google Document index: Position to insert the page break (1-based) Returns: Success message Raises: UserError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting page break in doc {document_id} at index {index}") try: request = {"insertPageBreak": {"location": {"index": index}}} helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully inserted page break at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting page break in doc {document_id}: {error_message}") raise ToolError(f"Failed to insert page break: {error_message}") def insert_image_from_url( document_id: str, image_url: str, index: int, width: float | None = None, height: float | None = None, ) -> str: """ Insert an inline image from a URL. Args: document_id: The ID of the Google Document image_url: Publicly accessible URL to the image index: Position to insert the image (1-based) width: Optional width in points height: Optional height in points Returns: Success message Raises: UserError: For permission/not found errors or invalid URL """ docs = get_docs_client() log(f"Inserting image from URL {image_url} at index {index} in doc {document_id}") try: helpers.insert_inline_image(docs, document_id, image_url, index, width, height) size_info = "" if width and height: size_info = f" with size {width}x{height}pt" return f"Successfully inserted image from URL at index {index}{size_info}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting image in doc {document_id}: {error_message}") raise ToolError(f"Failed to insert image: {error_message}") def bulk_update_document( document_id: str, operations: list[dict], default_tab_id: str | None = None ) -> str: """ Execute multiple document operations in batched API calls. Args: document_id: Target document ID operations: List of operation dictionaries with 'type' field default_tab_id: Optional default tab for operations without explicit tab_id Returns: Human-readable summary of operations performed Raises: ToolError: If operations are invalid or execution fails """ docs = get_docs_client() log( f"Processing bulk update with {len(operations)} operations for doc {document_id}" ) if not operations: return "No operations to execute." if len(operations) > 500: raise ToolError( f"Too many operations ({len(operations)}). Maximum is 500 operations per call." ) try: # Step 1: Determine if we need to fetch the document for text-finding operations needs_document = any( op.get("text_to_find") or op.get("index_within_paragraph") for op in operations ) document = None if needs_document: log(f"Fetching document {document_id} for text-finding operations") document = ( docs.documents() .get(documentId=document_id, includeTabsContent=True, fields="*") .execute() ) # Step 2: Parse and validate operations, preparing requests requests = [] operation_summaries = [] for i, op_dict in enumerate(operations): op_type = op_dict.get("type") if not op_type: raise ToolError(f"Operation {i + 1} missing 'type' field") try: if op_type == "insert_text": request = _prepare_insert_text_request(op_dict, default_tab_id) requests.append(request) operation_summaries.append(f"insert_text at index {op_dict.get('index', 1)}") elif op_type == "delete_range": request = _prepare_delete_range_request(op_dict, default_tab_id) requests.append(request) operation_summaries.append( f"delete_range {op_dict.get('start_index')}-{op_dict.get('end_index')}" ) elif op_type == "apply_text_style": request = _prepare_apply_text_style_request( op_dict, document, default_tab_id ) requests.append(request) operation_summaries.append("apply_text_style") elif op_type == "apply_paragraph_style": request = _prepare_apply_paragraph_style_request( op_dict, document, default_tab_id ) requests.append(request) operation_summaries.append("apply_paragraph_style") elif op_type == "insert_table": request = _prepare_insert_table_request(op_dict) requests.append(request) operation_summaries.append( f"insert_table {op_dict.get('rows')}x{op_dict.get('columns')}" ) elif op_type == "insert_page_break": request = _prepare_insert_page_break_request(op_dict) requests.append(request) operation_summaries.append(f"insert_page_break at index {op_dict.get('index')}") elif op_type == "insert_image_from_url": request = _prepare_insert_image_request(op_dict) requests.append(request) operation_summaries.append(f"insert_image at index {op_dict.get('index')}") elif op_type == "create_bullet_list": request = _prepare_create_bullet_list_request(op_dict, default_tab_id) requests.append(request) operation_summaries.append(f"create_bullet_list {op_dict.get('list_type', 'UNORDERED')}") elif op_type == "replace_all_text": request = _prepare_replace_all_text_request(op_dict, default_tab_id) requests.append(request) operation_summaries.append(f"replace_all_text '{op_dict.get('find_text')}'") elif op_type == "insert_table_row": request = _prepare_insert_table_row_request(op_dict) requests.append(request) operation_summaries.append("insert_table_row") elif op_type == "delete_table_row": request = _prepare_delete_table_row_request(op_dict) requests.append(request) operation_summaries.append("delete_table_row") elif op_type == "insert_table_column": request = _prepare_insert_table_column_request(op_dict) requests.append(request) operation_summaries.append("insert_table_column") elif op_type == "delete_table_column": request = _prepare_delete_table_column_request(op_dict) requests.append(request) operation_summaries.append("delete_table_column") elif op_type == "update_table_cell_style": request = _prepare_update_table_cell_style_request(op_dict) if request: # May be None if no styles provided requests.append(request) operation_summaries.append("update_table_cell_style") elif op_type == "merge_table_cells": request = _prepare_merge_table_cells_request(op_dict) requests.append(request) operation_summaries.append("merge_table_cells") elif op_type == "unmerge_table_cells": request = _prepare_unmerge_table_cells_request(op_dict) requests.append(request) operation_summaries.append("unmerge_table_cells") elif op_type == "create_named_range": request = _prepare_create_named_range_request(op_dict, default_tab_id) requests.append(request) operation_summaries.append(f"create_named_range '{op_dict.get('name')}'") elif op_type == "delete_named_range": request = _prepare_delete_named_range_request(op_dict) requests.append(request) operation_summaries.append("delete_named_range") elif op_type == "insert_footnote": request = _prepare_insert_footnote_request(op_dict) requests.append(request) operation_summaries.append("insert_footnote") elif op_type == "insert_table_of_contents": request = _prepare_insert_table_of_contents_request(op_dict) requests.append(request) operation_summaries.append("insert_table_of_contents") elif op_type == "insert_horizontal_rule": request = _prepare_insert_horizontal_rule_request(op_dict) requests.append(request) operation_summaries.append("insert_horizontal_rule") elif op_type == "insert_section_break": request = _prepare_insert_section_break_request(op_dict) requests.append(request) operation_summaries.append(f"insert_section_break {op_dict.get('section_type', 'CONTINUOUS')}") else: raise ToolError( f"Unknown operation type '{op_type}' in operation {i + 1}" ) except Exception as e: raise ToolError(f"Error preparing operation {i + 1} ({op_type}): {str(e)}") # Step 3: Chunk requests into batches of 50 request_chunks = helpers.chunk_requests(requests, chunk_size=50) log( f"Executing {len(requests)} requests in {len(request_chunks)} batch(es)" ) # Step 4: Execute batches sequentially for chunk_idx, chunk in enumerate(request_chunks): log(f"Executing batch {chunk_idx + 1}/{len(request_chunks)} with {len(chunk)} requests") helpers.execute_batch_update_sync(docs, document_id, chunk) # Step 5: Return summary summary_lines = [ f"✓ Successfully executed {len(operations)} operations in {len(request_chunks)} batch(es):", "", ] # Group operation summaries by type operation_counts: dict[str, int] = {} for summary in operation_summaries: op_type = summary.split()[0] operation_counts[op_type] = operation_counts.get(op_type, 0) + 1 for op_type, count in sorted(operation_counts.items()): summary_lines.append(f" - {count}× {op_type}") return "\n".join(summary_lines) except ToolError: raise except Exception as e: error_message = str(e) log(f"Error in bulk update for doc {document_id}: {error_message}") raise ToolError(f"Bulk update failed: {error_message}") # --- Helper functions for preparing bulk operation requests --- def _prepare_insert_text_request(op_dict: dict, default_tab_id: str | None) -> dict: """Prepare insertText request from operation dict.""" text = op_dict.get("text", "") index = op_dict.get("index", 1) tab_id = op_dict.get("tab_id", default_tab_id) location: dict[str, Any] = {"index": index} if tab_id: location["tabId"] = tab_id return {"insertText": {"text": text, "location": location}} def _prepare_delete_range_request(op_dict: dict, default_tab_id: str | None) -> dict: """Prepare deleteContentRange request from operation dict.""" start_index = op_dict.get("start_index", 1) end_index = op_dict.get("end_index", 1) tab_id = op_dict.get("tab_id", default_tab_id) if end_index <= start_index: raise ToolError( f"Invalid range: end_index ({end_index}) must be greater than start_index ({start_index})" ) range_obj: dict[str, Any] = {"startIndex": start_index, "endIndex": end_index} if tab_id: range_obj["tabId"] = tab_id return {"deleteContentRange": {"range": range_obj}} def _prepare_apply_text_style_request( op_dict: dict, document: dict | None, default_tab_id: str | None ) -> dict: """Prepare updateTextStyle request from operation dict.""" # Determine the range to apply styling to start_index = op_dict.get("start_index") end_index = op_dict.get("end_index") text_to_find = op_dict.get("text_to_find") match_instance = op_dict.get("match_instance", 1) if text_to_find: # Text-based targeting - find the text first if not document: raise ToolError("Document data required for text-finding operations") text_range = helpers.find_text_range( get_docs_client(), document["documentId"], text_to_find, match_instance ) if not text_range: raise ToolError( f"Text '{text_to_find}' (instance {match_instance}) not found in document" ) start_index = text_range.start_index end_index = text_range.end_index elif start_index is None or end_index is None: raise ToolError( "Either (start_index, end_index) or text_to_find must be provided for apply_text_style" ) # Build text style args from operation dict style_args = TextStyleArgs( bold=op_dict.get("bold"), italic=op_dict.get("italic"), underline=op_dict.get("underline"), strikethrough=op_dict.get("strikethrough"), font_size=op_dict.get("font_size"), font_family=op_dict.get("font_family"), foreground_color=op_dict.get("foreground_color"), background_color=op_dict.get("background_color"), link_url=op_dict.get("link_url"), ) tab_id = op_dict.get("tab_id", default_tab_id) result = helpers.build_update_text_style_request( start_index, end_index, style_args ) request = result["request"] # Add tab_id to the range if specified if tab_id: request["updateTextStyle"]["range"]["tabId"] = tab_id return request def _prepare_apply_paragraph_style_request( op_dict: dict, document: dict | None, default_tab_id: str | None ) -> dict: """Prepare updateParagraphStyle request from operation dict.""" # Determine the range to apply styling to start_index = op_dict.get("start_index") end_index = op_dict.get("end_index") text_to_find = op_dict.get("text_to_find") match_instance = op_dict.get("match_instance", 1) index_within_paragraph = op_dict.get("index_within_paragraph") if text_to_find: # Text-based targeting if not document: raise ToolError("Document data required for text-finding operations") text_range = helpers.find_text_range( get_docs_client(), document["documentId"], text_to_find, match_instance ) if not text_range: raise ToolError( f"Text '{text_to_find}' (instance {match_instance}) not found in document" ) # Find paragraph containing the text tab_id = op_dict.get("tab_id", default_tab_id) para_range = helpers.get_paragraph_range_from_document( document, text_range.start_index, tab_id ) if not para_range: raise ToolError( f"Could not find paragraph containing text '{text_to_find}'" ) start_index = para_range.start_index end_index = para_range.end_index elif index_within_paragraph is not None: # Index-based targeting if not document: raise ToolError("Document data required for index_within_paragraph operations") tab_id = op_dict.get("tab_id", default_tab_id) para_range = helpers.get_paragraph_range_from_document( document, index_within_paragraph, tab_id ) if not para_range: raise ToolError( f"Could not find paragraph containing index {index_within_paragraph}" ) start_index = para_range.start_index end_index = para_range.end_index elif start_index is None or end_index is None: raise ToolError( "Either (start_index, end_index), text_to_find, or index_within_paragraph " "must be provided for apply_paragraph_style" ) # Build paragraph style args from operation dict style_args = ParagraphStyleArgs( alignment=op_dict.get("alignment"), indent_start=op_dict.get("indent_start"), indent_end=op_dict.get("indent_end"), space_above=op_dict.get("space_above"), space_below=op_dict.get("space_below"), named_style_type=op_dict.get("named_style_type"), keep_with_next=op_dict.get("keep_with_next"), ) tab_id = op_dict.get("tab_id", default_tab_id) result = helpers.build_update_paragraph_style_request( start_index, end_index, style_args ) request = result["request"] # Add tab_id to the range if specified if tab_id: request["updateParagraphStyle"]["range"]["tabId"] = tab_id return request def _prepare_insert_table_request(op_dict: dict) -> dict: """Prepare insertTable request from operation dict.""" rows = op_dict.get("rows", 1) columns = op_dict.get("columns", 1) index = op_dict.get("index", 1) if rows < 1 or columns < 1: raise ToolError(f"Table must have at least 1 row and 1 column (got {rows}x{columns})") return { "insertTable": { "rows": rows, "columns": columns, "location": {"index": index}, } } def _prepare_insert_page_break_request(op_dict: dict) -> dict: """Prepare insertPageBreak request from operation dict.""" index = op_dict.get("index", 1) return {"insertPageBreak": {"location": {"index": index}}} def _prepare_insert_image_request(op_dict: dict) -> dict: """Prepare insertInlineImage request from operation dict.""" image_url = op_dict.get("image_url", "") index = op_dict.get("index", 1) width = op_dict.get("width") height = op_dict.get("height") if not image_url: raise ToolError("image_url is required for insert_image_from_url operation") # Validate URL is accessible (imported from helpers) helpers._validate_image_url(image_url) # If this is a Google Drive URL, ensure it has public permissions if 'drive.google.com' in image_url: # Extract file ID from various Drive URL formats file_id_match = re.search(r'[?&]id=([^&]+)', image_url) if file_id_match: file_id = file_id_match.group(1) log(f"Setting public permissions for Google Drive file {file_id}") try: drive = get_drive_client() # Make the file publicly readable so Google Docs can access it permission = { "type": "anyone", "role": "reader" } drive.permissions().create( fileId=file_id, body=permission ).execute() log(f"Successfully set public permissions for Drive file {file_id}") except Exception as e: log(f"Warning: Could not set public permissions for Drive file {file_id}: {e}") raise ToolError( f"Failed to set public permissions on Google Drive file {file_id}. " "Please ensure the file is publicly accessible or share it with 'Anyone with the link'." ) location = {"index": index} uri = image_url object_size = None if width is not None and height is not None: object_size = { "height": {"magnitude": height, "unit": "PT"}, "width": {"magnitude": width, "unit": "PT"}, } request: dict[str, Any] = { "insertInlineImage": {"uri": uri, "location": location} } if object_size: request["insertInlineImage"]["objectSize"] = object_size return request # --- New Document Operations --- def create_bullet_list( document_id: str, start_index: int, end_index: int, list_type: str = "UNORDERED", nesting_level: int = 0, tab_id: str | None = None, ) -> str: """ Create a bulleted or numbered list from a range of paragraphs. Args: document_id: The ID of the Google Document start_index: Starting index of the range (inclusive, 1-based) end_index: Ending index of the range (exclusive) list_type: Type of list ("UNORDERED", "ORDERED_DECIMAL", etc.) nesting_level: Nesting level (0-8) tab_id: Specific tab ID to target Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Creating {list_type} list in range {start_index}-{end_index}") try: request = helpers.build_create_paragraph_bullets_request( start_index, end_index, list_type, nesting_level, tab_id ) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully created {list_type} list at range {start_index}-{end_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error creating list: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to create list: {error_message}") def replace_all_text( document_id: str, find_text: str, replace_text: str, match_case: bool = True, tab_id: str | None = None, ) -> str: """ Find and replace all instances of text in the document. Args: document_id: The ID of the Google Document find_text: Text to find replace_text: Text to replace it with match_case: Whether to match case when finding tab_id: Optional tab ID to limit replacement to specific tab Returns: Success message with replacement count Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Replacing all '{find_text}' with '{replace_text}' (match_case={match_case})") try: request = helpers.build_replace_all_text_request( find_text, replace_text, match_case, tab_id ) result = helpers.execute_batch_update_sync(docs, document_id, [request]) # Extract replacement count from response if available replacements = 0 if result and "replies" in result: for reply in result.get("replies", []): if "replaceAllText" in reply: replacements = reply["replaceAllText"].get("occurrencesChanged", 0) return f"Successfully replaced {replacements} occurrence(s) of '{find_text}' with '{replace_text}'." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error replacing text: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to replace text: {error_message}") def insert_table_row( document_id: str, table_start_index: int, row_index: int, insert_below: bool = False, ) -> str: """ Insert a new row into an existing table. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts row_index: The row index (0-based) where to insert insert_below: True to insert below the row, False to insert above Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting table row at table {table_start_index}, row {row_index}") try: request = helpers.build_insert_table_row_request( table_start_index, row_index, insert_below ) helpers.execute_batch_update_sync(docs, document_id, [request]) position = "below" if insert_below else "above" return f"Successfully inserted row {position} row {row_index} in table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting table row: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and row index {row_index} is valid." ) raise ToolError(f"Failed to insert table row: {error_message}") def delete_table_row( document_id: str, table_start_index: int, row_index: int, ) -> str: """ Delete a row from an existing table. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts row_index: The row index (0-based) to delete Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Deleting table row at table {table_start_index}, row {row_index}") try: request = helpers.build_delete_table_row_request(table_start_index, row_index) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully deleted row {row_index} from table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error deleting table row: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and row index {row_index} is valid." ) raise ToolError(f"Failed to delete table row: {error_message}") def insert_table_column( document_id: str, table_start_index: int, column_index: int, insert_right: bool = False, ) -> str: """ Insert a new column into an existing table. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts column_index: The column index (0-based) where to insert insert_right: True to insert right, False to insert left Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting table column at table {table_start_index}, column {column_index}") try: request = helpers.build_insert_table_column_request( table_start_index, column_index, insert_right ) helpers.execute_batch_update_sync(docs, document_id, [request]) position = "right of" if insert_right else "left of" return f"Successfully inserted column {position} column {column_index} in table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting table column: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and column index {column_index} is valid." ) raise ToolError(f"Failed to insert table column: {error_message}") def delete_table_column( document_id: str, table_start_index: int, column_index: int, ) -> str: """ Delete a column from an existing table. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts column_index: The column index (0-based) to delete Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Deleting table column at table {table_start_index}, column {column_index}") try: request = helpers.build_delete_table_column_request(table_start_index, column_index) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully deleted column {column_index} from table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error deleting table column: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and column index {column_index} is valid." ) raise ToolError(f"Failed to delete table column: {error_message}") def update_table_cell_style( document_id: str, table_start_index: int, row_index: int, column_index: int, background_color: str | None = None, padding_top: float | None = None, padding_bottom: float | None = None, padding_left: float | None = None, padding_right: float | None = None, border_top_color: str | None = None, border_top_width: float | None = None, border_bottom_color: str | None = None, border_bottom_width: float | None = None, border_left_color: str | None = None, border_left_width: float | None = None, border_right_color: str | None = None, border_right_width: float | None = None, ) -> str: """ Style a table cell (background, padding, borders). Args: document_id: The ID of the Google Document table_start_index: The index where the table starts row_index: Row index (0-based) column_index: Column index (0-based) background_color: Background color hex (e.g., "#FF0000") padding_top: Top padding in points padding_bottom: Bottom padding in points padding_left: Left padding in points padding_right: Right padding in points border_top_color: Top border color hex border_top_width: Top border width in points border_bottom_color: Bottom border color hex border_bottom_width: Bottom border width in points border_left_color: Left border color hex border_left_width: Left border width in points border_right_color: Right border color hex border_right_width: Right border width in points Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Styling table cell at table {table_start_index}, row {row_index}, column {column_index}") try: request = helpers.build_update_table_cell_style_request( table_start_index, row_index, column_index, background_color, padding_top, padding_bottom, padding_left, padding_right, border_top_color, border_top_width, border_bottom_color, border_bottom_width, border_left_color, border_left_width, border_right_color, border_right_width, ) if request is None: return "No style properties provided. No changes made." helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully styled cell at row {row_index}, column {column_index} in table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error styling table cell: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and cell position ({row_index}, {column_index}) is valid." ) raise ToolError(f"Failed to style table cell: {error_message}") def merge_table_cells( document_id: str, table_start_index: int, start_row: int, start_column: int, row_span: int, column_span: int, ) -> str: """ Merge table cells. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts start_row: Starting row index (0-based) start_column: Starting column index (0-based) row_span: Number of rows to merge column_span: Number of columns to merge Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Merging table cells at table {table_start_index}, from ({start_row},{start_column}) spanning {row_span}x{column_span}") try: request = helpers.build_merge_table_cells_request( table_start_index, start_row, start_column, row_span, column_span ) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully merged {row_span}x{column_span} cells starting at ({start_row},{start_column}) in table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error merging table cells: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and the merge range is valid." ) raise ToolError(f"Failed to merge table cells: {error_message}") def unmerge_table_cells( document_id: str, table_start_index: int, row_index: int, column_index: int, ) -> str: """ Unmerge previously merged table cells. Args: document_id: The ID of the Google Document table_start_index: The index where the table starts row_index: Row index (0-based) of the merged cell column_index: Column index (0-based) of the merged cell Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Unmerging table cells at table {table_start_index}, cell ({row_index},{column_index})") try: request = helpers.build_unmerge_table_cells_request( table_start_index, row_index, column_index ) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully unmerged cells at ({row_index},{column_index}) in table at index {table_start_index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error unmerging table cells: {error_message}") if "404" in error_message: raise ToolError("Document or table not found. Check the document ID and table index.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) if "400" in error_message: raise ToolError( f"Invalid request: {error_message}. Check that the table exists at index {table_start_index} " f"and cell ({row_index},{column_index}) is merged." ) raise ToolError(f"Failed to unmerge table cells: {error_message}") def create_named_range( document_id: str, name: str, start_index: int, end_index: int, tab_id: str | None = None, ) -> str: """ Create a named range for cross-referencing. Args: document_id: The ID of the Google Document name: Name for the range start_index: Starting index (inclusive, 1-based) end_index: Ending index (exclusive) tab_id: Optional tab ID Returns: Success message with named range ID Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Creating named range '{name}' at range {start_index}-{end_index}") try: request = helpers.build_create_named_range_request( name, start_index, end_index, tab_id ) result = helpers.execute_batch_update_sync(docs, document_id, [request]) # Extract named range ID from response named_range_id = "" if result and "replies" in result: for reply in result.get("replies", []): if "createNamedRange" in reply: named_range_id = reply["createNamedRange"].get("namedRangeId", "") return f"Successfully created named range '{name}' at range {start_index}-{end_index}. Range ID: {named_range_id}" except ToolError: raise except Exception as e: error_message = str(e) log(f"Error creating named range: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to create named range: {error_message}") def delete_named_range( document_id: str, named_range_id: str, ) -> str: """ Delete a named range. Args: document_id: The ID of the Google Document named_range_id: ID of the named range to delete Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Deleting named range {named_range_id}") try: request = helpers.build_delete_named_range_request(named_range_id) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully deleted named range {named_range_id}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error deleting named range: {error_message}") if "404" in error_message: raise ToolError("Document or named range not found. Check the document ID and named range ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to delete named range: {error_message}") def insert_footnote( document_id: str, index: int, footnote_text: str, ) -> str: """ Insert a footnote at the specified index. Args: document_id: The ID of the Google Document index: Index where to insert footnote (1-based) footnote_text: Text content of the footnote Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting footnote at index {index}") try: request = helpers.build_insert_footnote_request(index, footnote_text) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully inserted footnote at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting footnote: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to insert footnote: {error_message}") def insert_table_of_contents( document_id: str, index: int, ) -> str: """ Insert a table of contents at the specified index. The table of contents is auto-generated from document headings. Args: document_id: The ID of the Google Document index: Index where to insert TOC (1-based) Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting table of contents at index {index}") try: request = helpers.build_insert_table_of_contents_request(index) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully inserted table of contents at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting table of contents: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to insert table of contents: {error_message}") def insert_horizontal_rule( document_id: str, index: int, ) -> str: """ Insert a horizontal rule (divider line) at the specified index. Args: document_id: The ID of the Google Document index: Index where to insert rule (1-based) Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting horizontal rule at index {index}") try: request = helpers.build_insert_horizontal_rule_request(index) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully inserted horizontal rule at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting horizontal rule: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to insert horizontal rule: {error_message}") def insert_section_break( document_id: str, index: int, section_type: str = "CONTINUOUS", ) -> str: """ Insert a section break at the specified index. Args: document_id: The ID of the Google Document index: Index where to insert section break (1-based) section_type: Type of section break (CONTINUOUS, NEXT_PAGE, EVEN_PAGE, ODD_PAGE) Returns: Success message Raises: ToolError: For permission/not found errors """ docs = get_docs_client() log(f"Inserting {section_type} section break at index {index}") try: request = helpers.build_insert_section_break_request(index, section_type) helpers.execute_batch_update_sync(docs, document_id, [request]) return f"Successfully inserted {section_type} section break at index {index}." except ToolError: raise except Exception as e: error_message = str(e) log(f"Error inserting section break: {error_message}") if "404" in error_message: raise ToolError("Document not found. Check the document ID.") if "403" in error_message: raise ToolError( "Permission denied. Ensure you have edit access to the document." ) raise ToolError(f"Failed to insert section break: {error_message}") # --- Bulk Operation Preparation Functions for New Operations --- def _prepare_create_bullet_list_request(op_dict: dict, default_tab_id: str | None) -> dict: """Prepare createParagraphBullets request from operation dict.""" start_index = op_dict.get("start_index", 1) end_index = op_dict.get("end_index", 1) list_type = op_dict.get("list_type", "UNORDERED") nesting_level = op_dict.get("nesting_level", 0) tab_id = op_dict.get("tab_id", default_tab_id) return helpers.build_create_paragraph_bullets_request( start_index, end_index, list_type, nesting_level, tab_id ) def _prepare_replace_all_text_request(op_dict: dict, default_tab_id: str | None) -> dict: """Prepare replaceAllText request from operation dict.""" find_text = op_dict.get("find_text", "") replace_text = op_dict.get("replace_text", "") match_case = op_dict.get("match_case", True) tab_id = op_dict.get("tab_id", default_tab_id) if not find_text: raise ToolError("find_text is required for replace_all_text operation") return helpers.build_replace_all_text_request( find_text, replace_text, match_case, tab_id ) def _prepare_insert_table_row_request(op_dict: dict) -> dict: """Prepare insertTableRow request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) row_index = op_dict.get("row_index", 0) insert_below = op_dict.get("insert_below", False) return helpers.build_insert_table_row_request( table_start_index, row_index, insert_below ) def _prepare_delete_table_row_request(op_dict: dict) -> dict: """Prepare deleteTableRow request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) row_index = op_dict.get("row_index", 0) return helpers.build_delete_table_row_request(table_start_index, row_index) def _prepare_insert_table_column_request(op_dict: dict) -> dict: """Prepare insertTableColumn request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) column_index = op_dict.get("column_index", 0) insert_right = op_dict.get("insert_right", False) return helpers.build_insert_table_column_request( table_start_index, column_index, insert_right ) def _prepare_delete_table_column_request(op_dict: dict) -> dict: """Prepare deleteTableColumn request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) column_index = op_dict.get("column_index", 0) return helpers.build_delete_table_column_request(table_start_index, column_index) def _prepare_update_table_cell_style_request(op_dict: dict) -> dict | None: """Prepare updateTableCellStyle request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) row_index = op_dict.get("row_index", 0) column_index = op_dict.get("column_index", 0) return helpers.build_update_table_cell_style_request( table_start_index, row_index, column_index, background_color=op_dict.get("background_color"), padding_top=op_dict.get("padding_top"), padding_bottom=op_dict.get("padding_bottom"), padding_left=op_dict.get("padding_left"), padding_right=op_dict.get("padding_right"), border_top_color=op_dict.get("border_top_color"), border_top_width=op_dict.get("border_top_width"), border_bottom_color=op_dict.get("border_bottom_color"), border_bottom_width=op_dict.get("border_bottom_width"), border_left_color=op_dict.get("border_left_color"), border_left_width=op_dict.get("border_left_width"), border_right_color=op_dict.get("border_right_color"), border_right_width=op_dict.get("border_right_width"), ) def _prepare_merge_table_cells_request(op_dict: dict) -> dict: """Prepare mergeTableCells request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) start_row = op_dict.get("start_row", 0) start_column = op_dict.get("start_column", 0) row_span = op_dict.get("row_span", 1) column_span = op_dict.get("column_span", 1) return helpers.build_merge_table_cells_request( table_start_index, start_row, start_column, row_span, column_span ) def _prepare_unmerge_table_cells_request(op_dict: dict) -> dict: """Prepare unmergeTableCells request from operation dict.""" table_start_index = op_dict.get("table_start_index", 1) row_index = op_dict.get("row_index", 0) column_index = op_dict.get("column_index", 0) return helpers.build_unmerge_table_cells_request( table_start_index, row_index, column_index ) def _prepare_create_named_range_request(op_dict: dict, default_tab_id: str | None) -> dict: """Prepare createNamedRange request from operation dict.""" name = op_dict.get("name", "") start_index = op_dict.get("start_index", 1) end_index = op_dict.get("end_index", 1) tab_id = op_dict.get("tab_id", default_tab_id) if not name: raise ToolError("name is required for create_named_range operation") return helpers.build_create_named_range_request( name, start_index, end_index, tab_id ) def _prepare_delete_named_range_request(op_dict: dict) -> dict: """Prepare deleteNamedRange request from operation dict.""" named_range_id = op_dict.get("named_range_id", "") if not named_range_id: raise ToolError("named_range_id is required for delete_named_range operation") return helpers.build_delete_named_range_request(named_range_id) def _prepare_insert_footnote_request(op_dict: dict) -> dict: """Prepare insertFootnote request from operation dict.""" index = op_dict.get("index", 1) footnote_text = op_dict.get("footnote_text", "") return helpers.build_insert_footnote_request(index, footnote_text) def _prepare_insert_table_of_contents_request(op_dict: dict) -> dict: """Prepare insertTableOfContents request from operation dict.""" index = op_dict.get("index", 1) return helpers.build_insert_table_of_contents_request(index) def _prepare_insert_horizontal_rule_request(op_dict: dict) -> dict: """Prepare insertHorizontalRule request from operation dict.""" index = op_dict.get("index", 1) return helpers.build_insert_horizontal_rule_request(index) def _prepare_insert_section_break_request(op_dict: dict) -> dict: """Prepare insertSectionBreak request from operation dict.""" index = op_dict.get("index", 1) section_type = op_dict.get("section_type", "CONTINUOUS") return helpers.build_insert_section_break_request(index, section_type)

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nickweedon/google-docs-mcp-docker'

If you have feedback or need assistance with the MCP directory API, please join our Discord server