feat: initial commit from workspace-mcp
Some checks failed
Check Maintainer Edits Enabled / check-maintainer-edits (pull_request) Has been cancelled
Check Maintainer Edits Enabled / check-maintainer-edits-internal (pull_request) Has been cancelled
Docker Build and Push to GHCR / build-and-push (pull_request) Has been cancelled
Ruff / ruff (pull_request) Has been cancelled

This commit is contained in:
2026-03-17 19:23:33 -05:00
commit 395f0e2029
138 changed files with 41691 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
"""
Google Docs Operation Managers
This package provides high-level manager classes for complex Google Docs operations,
extracting business logic from the main tools module to improve maintainability.
"""
from .table_operation_manager import TableOperationManager
from .header_footer_manager import HeaderFooterManager
from .validation_manager import ValidationManager
from .batch_operation_manager import BatchOperationManager
__all__ = [
"TableOperationManager",
"HeaderFooterManager",
"ValidationManager",
"BatchOperationManager",
]

View File

@@ -0,0 +1,534 @@
"""
Batch Operation Manager
This module provides high-level batch operation management for Google Docs,
extracting complex validation and request building logic.
"""
import logging
import asyncio
from typing import Any, Union, Dict, List, Tuple
from gdocs.docs_helpers import (
create_insert_text_request,
create_delete_range_request,
create_format_text_request,
create_update_paragraph_style_request,
create_find_replace_request,
create_insert_table_request,
create_insert_page_break_request,
create_bullet_list_request,
create_delete_bullet_list_request,
create_insert_doc_tab_request,
create_delete_doc_tab_request,
create_update_doc_tab_request,
validate_operation,
)
logger = logging.getLogger(__name__)
class BatchOperationManager:
"""
High-level manager for Google Docs batch operations.
Handles complex multi-operation requests including:
- Operation validation and request building
- Batch execution with proper error handling
- Operation result processing and reporting
"""
def __init__(self, service):
"""
Initialize the batch operation manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def execute_batch_operations(
self, document_id: str, operations: list[dict[str, Any]]
) -> tuple[bool, str, dict[str, Any]]:
"""
Execute multiple document operations in a single atomic batch.
This method extracts the complex logic from batch_update_doc tool function.
Args:
document_id: ID of the document to update
operations: List of operation dictionaries
Returns:
Tuple of (success, message, metadata)
"""
logger.info(f"Executing batch operations on document {document_id}")
logger.info(f"Operations count: {len(operations)}")
if not operations:
return (
False,
"No operations provided. Please provide at least one operation.",
{},
)
try:
# Validate and build requests
requests, operation_descriptions = await self._validate_and_build_requests(
operations
)
if not requests:
return False, "No valid requests could be built from operations", {}
# Execute the batch
result = await self._execute_batch_requests(document_id, requests)
# Process results
metadata = {
"operations_count": len(operations),
"requests_count": len(requests),
"replies_count": len(result.get("replies", [])),
"operation_summary": operation_descriptions[:5], # First 5 operations
}
# Extract new tab IDs from insert_doc_tab replies
created_tabs = self._extract_created_tabs(result)
if created_tabs:
metadata["created_tabs"] = created_tabs
summary = self._build_operation_summary(operation_descriptions)
msg = f"Successfully executed {len(operations)} operations ({summary})"
if created_tabs:
tab_info = ", ".join(
f"'{t['title']}' (tab_id: {t['tab_id']})" for t in created_tabs
)
msg += f". Created tabs: {tab_info}"
return True, msg, metadata
except Exception as e:
logger.error(f"Failed to execute batch operations: {str(e)}")
return False, f"Batch operation failed: {str(e)}", {}
async def _validate_and_build_requests(
self, operations: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[str]]:
"""
Validate operations and build API requests.
Args:
operations: List of operation dictionaries
Returns:
Tuple of (requests, operation_descriptions)
"""
requests = []
operation_descriptions = []
for i, op in enumerate(operations):
# Validate operation structure
is_valid, error_msg = validate_operation(op)
if not is_valid:
raise ValueError(f"Operation {i + 1}: {error_msg}")
op_type = op.get("type")
try:
# Build request based on operation type
result = self._build_operation_request(op, op_type)
# Handle both single request and list of requests
if isinstance(result[0], list):
# Multiple requests (e.g., replace_text)
for req in result[0]:
requests.append(req)
operation_descriptions.append(result[1])
elif result[0]:
# Single request
requests.append(result[0])
operation_descriptions.append(result[1])
except KeyError as e:
raise ValueError(
f"Operation {i + 1} ({op_type}) missing required field: {e}"
)
except Exception as e:
raise ValueError(
f"Operation {i + 1} ({op_type}) failed validation: {str(e)}"
)
return requests, operation_descriptions
def _build_operation_request(
self, op: dict[str, Any], op_type: str
) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], str]:
"""
Build a single operation request.
Args:
op: Operation dictionary
op_type: Operation type
Returns:
Tuple of (request, description)
"""
tab_id = op.get("tab_id")
if op_type == "insert_text":
request = create_insert_text_request(op["index"], op["text"], tab_id)
description = f"insert text at {op['index']}"
elif op_type == "delete_text":
request = create_delete_range_request(
op["start_index"], op["end_index"], tab_id
)
description = f"delete text {op['start_index']}-{op['end_index']}"
elif op_type == "replace_text":
# Replace is delete + insert (must be done in this order)
delete_request = create_delete_range_request(
op["start_index"], op["end_index"], tab_id
)
insert_request = create_insert_text_request(
op["start_index"], op["text"], tab_id
)
# Return both requests as a list
request = [delete_request, insert_request]
description = f"replace text {op['start_index']}-{op['end_index']} with '{op['text'][:20]}{'...' if len(op['text']) > 20 else ''}'"
elif op_type == "format_text":
request = create_format_text_request(
op["start_index"],
op["end_index"],
op.get("bold"),
op.get("italic"),
op.get("underline"),
op.get("font_size"),
op.get("font_family"),
op.get("text_color"),
op.get("background_color"),
op.get("link_url"),
tab_id,
)
if not request:
raise ValueError("No formatting options provided")
# Build format description
format_changes = []
for param, name in [
("bold", "bold"),
("italic", "italic"),
("underline", "underline"),
("font_size", "font size"),
("font_family", "font family"),
("text_color", "text color"),
("background_color", "background color"),
("link_url", "link"),
]:
if op.get(param) is not None:
value = f"{op[param]}pt" if param == "font_size" else op[param]
format_changes.append(f"{name}: {value}")
description = f"format text {op['start_index']}-{op['end_index']} ({', '.join(format_changes)})"
elif op_type == "update_paragraph_style":
request = create_update_paragraph_style_request(
op["start_index"],
op["end_index"],
op.get("heading_level"),
op.get("alignment"),
op.get("line_spacing"),
op.get("indent_first_line"),
op.get("indent_start"),
op.get("indent_end"),
op.get("space_above"),
op.get("space_below"),
tab_id,
op.get("named_style_type"),
)
if not request:
raise ValueError("No paragraph style options provided")
_PT_PARAMS = {
"indent_first_line",
"indent_start",
"indent_end",
"space_above",
"space_below",
}
_SUFFIX = {
"heading_level": lambda v: f"H{v}",
"line_spacing": lambda v: f"{v}x",
}
style_changes = []
for param, name in [
("heading_level", "heading"),
("alignment", "alignment"),
("line_spacing", "line spacing"),
("indent_first_line", "first line indent"),
("indent_start", "start indent"),
("indent_end", "end indent"),
("space_above", "space above"),
("space_below", "space below"),
]:
if op.get(param) is not None:
raw = op[param]
fmt = _SUFFIX.get(param)
if fmt:
value = fmt(raw)
elif param in _PT_PARAMS:
value = f"{raw}pt"
else:
value = raw
style_changes.append(f"{name}: {value}")
description = f"paragraph style {op['start_index']}-{op['end_index']} ({', '.join(style_changes)})"
elif op_type == "insert_table":
request = create_insert_table_request(
op["index"], op["rows"], op["columns"], tab_id
)
description = f"insert {op['rows']}x{op['columns']} table at {op['index']}"
elif op_type == "insert_page_break":
request = create_insert_page_break_request(op["index"], tab_id)
description = f"insert page break at {op['index']}"
elif op_type == "find_replace":
request = create_find_replace_request(
op["find_text"], op["replace_text"], op.get("match_case", False), tab_id
)
description = f"find/replace '{op['find_text']}''{op['replace_text']}'"
elif op_type == "create_bullet_list":
list_type = op.get("list_type", "UNORDERED")
if list_type not in ("UNORDERED", "ORDERED", "NONE"):
raise ValueError(
f"Invalid list_type '{list_type}'. Must be 'UNORDERED', 'ORDERED', or 'NONE'"
)
if list_type == "NONE":
request = create_delete_bullet_list_request(
op["start_index"], op["end_index"], tab_id
)
description = f"remove bullets {op['start_index']}-{op['end_index']}"
else:
request = create_bullet_list_request(
op["start_index"],
op["end_index"],
list_type,
op.get("nesting_level"),
op.get("paragraph_start_indices"),
tab_id,
)
style = "bulleted" if list_type == "UNORDERED" else "numbered"
description = (
f"create {style} list {op['start_index']}-{op['end_index']}"
)
if op.get("nesting_level"):
description += f" (nesting level {op['nesting_level']})"
elif op_type == "insert_doc_tab":
request = create_insert_doc_tab_request(
op["title"], op["index"], op.get("parent_tab_id")
)
description = f"insert tab '{op['title']}' at {op['index']}"
if op.get("parent_tab_id"):
description += f" under parent tab {op['parent_tab_id']}"
elif op_type == "delete_doc_tab":
request = create_delete_doc_tab_request(op["tab_id"])
description = f"delete tab '{op['tab_id']}'"
elif op_type == "update_doc_tab":
request = create_update_doc_tab_request(op["tab_id"], op["title"])
description = f"rename tab '{op['tab_id']}' to '{op['title']}'"
else:
supported_types = [
"insert_text",
"delete_text",
"replace_text",
"format_text",
"update_paragraph_style",
"insert_table",
"insert_page_break",
"find_replace",
"create_bullet_list",
"insert_doc_tab",
"delete_doc_tab",
"update_doc_tab",
]
raise ValueError(
f"Unsupported operation type '{op_type}'. Supported: {', '.join(supported_types)}"
)
return request, description
async def _execute_batch_requests(
self, document_id: str, requests: list[dict[str, Any]]
) -> dict[str, Any]:
"""
Execute the batch requests against the Google Docs API.
Args:
document_id: Document ID
requests: List of API requests
Returns:
API response
"""
return await asyncio.to_thread(
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
def _extract_created_tabs(self, result: dict[str, Any]) -> list[dict[str, str]]:
"""
Extract tab IDs from insert_doc_tab replies in the batchUpdate response.
Args:
result: The batchUpdate API response
Returns:
List of dicts with tab_id and title for each created tab
"""
created_tabs = []
for reply in result.get("replies", []):
if "createDocumentTab" in reply:
props = reply["createDocumentTab"].get("tabProperties", {})
tab_id = props.get("tabId")
title = props.get("title", "")
if tab_id:
created_tabs.append({"tab_id": tab_id, "title": title})
return created_tabs
def _build_operation_summary(self, operation_descriptions: list[str]) -> str:
"""
Build a concise summary of operations performed.
Args:
operation_descriptions: List of operation descriptions
Returns:
Summary string
"""
if not operation_descriptions:
return "no operations"
summary_items = operation_descriptions[:3] # Show first 3 operations
summary = ", ".join(summary_items)
if len(operation_descriptions) > 3:
remaining = len(operation_descriptions) - 3
summary += f" and {remaining} more operation{'s' if remaining > 1 else ''}"
return summary
def get_supported_operations(self) -> dict[str, Any]:
"""
Get information about supported batch operations.
Returns:
Dictionary with supported operation types and their required parameters
"""
return {
"supported_operations": {
"insert_text": {
"required": ["index", "text"],
"description": "Insert text at specified index",
},
"delete_text": {
"required": ["start_index", "end_index"],
"description": "Delete text in specified range",
},
"replace_text": {
"required": ["start_index", "end_index", "text"],
"description": "Replace text in range with new text",
},
"format_text": {
"required": ["start_index", "end_index"],
"optional": [
"bold",
"italic",
"underline",
"font_size",
"font_family",
"text_color",
"background_color",
"link_url",
],
"description": "Apply formatting to text range",
},
"update_paragraph_style": {
"required": ["start_index", "end_index"],
"optional": [
"heading_level",
"alignment",
"line_spacing",
"indent_first_line",
"indent_start",
"indent_end",
"space_above",
"space_below",
"named_style_type",
],
"description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)",
},
"insert_table": {
"required": ["index", "rows", "columns"],
"description": "Insert table at specified index",
},
"insert_page_break": {
"required": ["index"],
"description": "Insert page break at specified index",
},
"find_replace": {
"required": ["find_text", "replace_text"],
"optional": ["match_case"],
"description": "Find and replace text throughout document",
},
"create_bullet_list": {
"required": ["start_index", "end_index"],
"optional": [
"list_type",
"nesting_level",
"paragraph_start_indices",
],
"description": "Apply or remove native bullet/numbered list formatting (list_type: UNORDERED, ORDERED, or NONE to remove; nesting_level: 0-8)",
},
"insert_doc_tab": {
"required": ["title", "index"],
"description": "Insert a new document tab with given title at specified index",
},
"delete_doc_tab": {
"required": ["tab_id"],
"description": "Delete a document tab by its ID",
},
"update_doc_tab": {
"required": ["tab_id", "title"],
"description": "Rename a document tab",
},
},
"example_operations": [
{"type": "insert_text", "index": 1, "text": "Hello World"},
{
"type": "format_text",
"start_index": 1,
"end_index": 12,
"bold": True,
},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3},
{
"type": "update_paragraph_style",
"start_index": 1,
"end_index": 20,
"heading_level": 1,
"alignment": "CENTER",
},
],
}

View File

@@ -0,0 +1,339 @@
"""
Header Footer Manager
This module provides high-level operations for managing headers and footers
in Google Docs, extracting complex logic from the main tools module.
"""
import logging
import asyncio
from typing import Any, Optional
logger = logging.getLogger(__name__)
class HeaderFooterManager:
"""
High-level manager for Google Docs header and footer operations.
Handles complex header/footer operations including:
- Finding and updating existing headers/footers
- Content replacement with proper range calculation
- Section type management
"""
def __init__(self, service):
"""
Initialize the header footer manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def update_header_footer_content(
self,
document_id: str,
section_type: str,
content: str,
header_footer_type: str = "DEFAULT",
) -> tuple[bool, str]:
"""
Updates header or footer content in a document.
This method extracts the complex logic from update_doc_headers_footers tool function.
Args:
document_id: ID of the document to update
section_type: Type of section ("header" or "footer")
content: New content for the section
header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE")
Returns:
Tuple of (success, message)
"""
logger.info(f"Updating {section_type} in document {document_id}")
# Validate section type
if section_type not in ["header", "footer"]:
return False, "section_type must be 'header' or 'footer'"
# Validate header/footer type
if header_footer_type not in ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"]:
return (
False,
"header_footer_type must be 'DEFAULT', 'FIRST_PAGE_ONLY', or 'EVEN_PAGE'",
)
try:
# Get document structure
doc = await self._get_document(document_id)
# Find the target section
target_section, section_id = await self._find_target_section(
doc, section_type, header_footer_type
)
if not target_section:
return (
False,
f"No {section_type} found in document. Please create a {section_type} first in Google Docs.",
)
# Update the content
success = await self._replace_section_content(
document_id, target_section, content
)
if success:
return True, f"Updated {section_type} content in document {document_id}"
else:
return (
False,
f"Could not find content structure in {section_type} to update",
)
except Exception as e:
logger.error(f"Failed to update {section_type}: {str(e)}")
return False, f"Failed to update {section_type}: {str(e)}"
async def _get_document(self, document_id: str) -> dict[str, Any]:
"""Get the full document data."""
return await asyncio.to_thread(
self.service.documents().get(documentId=document_id).execute
)
async def _find_target_section(
self, doc: dict[str, Any], section_type: str, header_footer_type: str
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
"""
Find the target header or footer section.
Args:
doc: Document data
section_type: "header" or "footer"
header_footer_type: Type of header/footer
Returns:
Tuple of (section_data, section_id) or (None, None) if not found
"""
if section_type == "header":
sections = doc.get("headers", {})
else:
sections = doc.get("footers", {})
# Try to match section based on header_footer_type
# Google Docs API typically uses section IDs that correspond to types
# First, try to find an exact match based on common patterns
for section_id, section_data in sections.items():
# Check if section_data contains type information
if "type" in section_data and section_data["type"] == header_footer_type:
return section_data, section_id
# If no exact match, try pattern matching on section ID
# Google Docs often uses predictable section ID patterns
target_patterns = {
"DEFAULT": ["default", "kix"], # DEFAULT headers often have these patterns
"FIRST_PAGE": ["first", "firstpage"],
"EVEN_PAGE": ["even", "evenpage"],
"FIRST_PAGE_ONLY": ["first", "firstpage"], # Legacy support
}
patterns = target_patterns.get(header_footer_type, [])
for pattern in patterns:
for section_id, section_data in sections.items():
if pattern.lower() in section_id.lower():
return section_data, section_id
# If still no match, return the first available section as fallback
# This maintains backward compatibility
for section_id, section_data in sections.items():
return section_data, section_id
return None, None
async def _replace_section_content(
self, document_id: str, section: dict[str, Any], new_content: str
) -> bool:
"""
Replace the content in a header or footer section.
Args:
document_id: Document ID
section: Section data containing content elements
new_content: New content to insert
Returns:
True if successful, False otherwise
"""
content_elements = section.get("content", [])
if not content_elements:
return False
# Find the first paragraph to replace content
first_para = self._find_first_paragraph(content_elements)
if not first_para:
return False
# Calculate content range
start_index = first_para.get("startIndex", 0)
end_index = first_para.get("endIndex", 0)
# Build requests to replace content
requests = []
# Delete existing content if any (preserve paragraph structure)
if end_index > start_index:
requests.append(
{
"deleteContentRange": {
"range": {
"startIndex": start_index,
"endIndex": end_index - 1, # Keep the paragraph end marker
}
}
}
)
# Insert new content
requests.append(
{"insertText": {"location": {"index": start_index}, "text": new_content}}
)
try:
await asyncio.to_thread(
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
return True
except Exception as e:
logger.error(f"Failed to replace section content: {str(e)}")
return False
def _find_first_paragraph(
self, content_elements: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""Find the first paragraph element in content."""
for element in content_elements:
if "paragraph" in element:
return element
return None
async def get_header_footer_info(self, document_id: str) -> dict[str, Any]:
"""
Get information about all headers and footers in the document.
Args:
document_id: Document ID
Returns:
Dictionary with header and footer information
"""
try:
doc = await self._get_document(document_id)
headers_info = {}
for header_id, header_data in doc.get("headers", {}).items():
headers_info[header_id] = self._extract_section_info(header_data)
footers_info = {}
for footer_id, footer_data in doc.get("footers", {}).items():
footers_info[footer_id] = self._extract_section_info(footer_data)
return {
"headers": headers_info,
"footers": footers_info,
"has_headers": bool(headers_info),
"has_footers": bool(footers_info),
}
except Exception as e:
logger.error(f"Failed to get header/footer info: {str(e)}")
return {"error": str(e)}
def _extract_section_info(self, section_data: dict[str, Any]) -> dict[str, Any]:
"""Extract useful information from a header/footer section."""
content_elements = section_data.get("content", [])
# Extract text content
text_content = ""
for element in content_elements:
if "paragraph" in element:
para = element["paragraph"]
for para_element in para.get("elements", []):
if "textRun" in para_element:
text_content += para_element["textRun"].get("content", "")
return {
"content_preview": text_content[:100] if text_content else "(empty)",
"element_count": len(content_elements),
"start_index": content_elements[0].get("startIndex", 0)
if content_elements
else 0,
"end_index": content_elements[-1].get("endIndex", 0)
if content_elements
else 0,
}
async def create_header_footer(
self, document_id: str, section_type: str, header_footer_type: str = "DEFAULT"
) -> tuple[bool, str]:
"""
Create a new header or footer section.
Args:
document_id: Document ID
section_type: "header" or "footer"
header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE", or "EVEN_PAGE")
Returns:
Tuple of (success, message)
"""
if section_type not in ["header", "footer"]:
return False, "section_type must be 'header' or 'footer'"
# Map our type names to API type names
type_mapping = {
"DEFAULT": "DEFAULT",
"FIRST_PAGE": "FIRST_PAGE",
"EVEN_PAGE": "EVEN_PAGE",
"FIRST_PAGE_ONLY": "FIRST_PAGE", # Support legacy name
}
api_type = type_mapping.get(header_footer_type, header_footer_type)
if api_type not in ["DEFAULT", "FIRST_PAGE", "EVEN_PAGE"]:
return (
False,
"header_footer_type must be 'DEFAULT', 'FIRST_PAGE', or 'EVEN_PAGE'",
)
try:
# Build the request
request = {"type": api_type}
# Create the appropriate request type
if section_type == "header":
batch_request = {"createHeader": request}
else:
batch_request = {"createFooter": request}
# Execute the request
await asyncio.to_thread(
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": [batch_request]})
.execute
)
return True, f"Successfully created {section_type} with type {api_type}"
except Exception as e:
error_msg = str(e)
if "already exists" in error_msg.lower():
return (
False,
f"A {section_type} of type {api_type} already exists in the document",
)
return False, f"Failed to create {section_type}: {error_msg}"

View File

@@ -0,0 +1,405 @@
"""
Table Operation Manager
This module provides high-level table operations that orchestrate
multiple Google Docs API calls for complex table manipulations.
"""
import logging
import asyncio
from typing import List, Dict, Any, Tuple
from gdocs.docs_helpers import create_insert_table_request
from gdocs.docs_structure import find_tables
from gdocs.docs_tables import validate_table_data
logger = logging.getLogger(__name__)
class TableOperationManager:
"""
High-level manager for Google Docs table operations.
Handles complex multi-step table operations including:
- Creating tables with data population
- Populating existing tables
- Managing cell-by-cell operations with proper index refreshing
"""
def __init__(self, service):
"""
Initialize the table operation manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def create_and_populate_table(
self,
document_id: str,
table_data: List[List[str]],
index: int,
bold_headers: bool = True,
tab_id: str = None,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Creates a table and populates it with data in a reliable multi-step process.
This method extracts the complex logic from create_table_with_data tool function.
Args:
document_id: ID of the document to update
table_data: 2D list of strings for table content
index: Position to insert the table
bold_headers: Whether to make the first row bold
tab_id: Optional tab ID for targeting a specific tab
Returns:
Tuple of (success, message, metadata)
"""
logger.debug(
f"Creating table at index {index}, dimensions: {len(table_data)}x{len(table_data[0]) if table_data and len(table_data) > 0 else 0}"
)
# Validate input data
is_valid, error_msg = validate_table_data(table_data)
if not is_valid:
return False, f"Invalid table data: {error_msg}", {}
rows = len(table_data)
cols = len(table_data[0])
try:
# Step 1: Create empty table
await self._create_empty_table(document_id, index, rows, cols, tab_id)
# Step 2: Get fresh document structure to find actual cell positions
fresh_tables = await self._get_document_tables(document_id, tab_id)
if not fresh_tables:
return False, "Could not find table after creation", {}
# Step 3: Populate each cell with proper index refreshing
population_count = await self._populate_table_cells(
document_id, table_data, bold_headers, tab_id
)
metadata = {
"rows": rows,
"columns": cols,
"populated_cells": population_count,
"table_index": len(fresh_tables) - 1,
}
return (
True,
f"Successfully created {rows}x{cols} table and populated {population_count} cells",
metadata,
)
except Exception as e:
logger.error(f"Failed to create and populate table: {str(e)}")
return False, f"Table creation failed: {str(e)}", {}
async def _create_empty_table(
self, document_id: str, index: int, rows: int, cols: int, tab_id: str = None
) -> None:
"""Create an empty table at the specified index."""
logger.debug(f"Creating {rows}x{cols} table at index {index}")
await asyncio.to_thread(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={
"requests": [create_insert_table_request(index, rows, cols, tab_id)]
},
)
.execute
)
async def _get_document_tables(
self, document_id: str, tab_id: str = None
) -> List[Dict[str, Any]]:
"""Get fresh document structure and extract table information."""
doc = await asyncio.to_thread(
self.service.documents()
.get(documentId=document_id, includeTabsContent=True)
.execute
)
if tab_id:
tab = self._find_tab(doc.get("tabs", []), tab_id)
if tab and "documentTab" in tab:
doc = doc.copy()
doc["body"] = tab["documentTab"].get("body", {})
return find_tables(doc)
@staticmethod
def _find_tab(tabs: list, target_id: str):
"""Recursively find a tab by ID."""
for tab in tabs:
if tab.get("tabProperties", {}).get("tabId") == target_id:
return tab
if "childTabs" in tab:
found = TableOperationManager._find_tab(tab["childTabs"], target_id)
if found:
return found
return None
async def _populate_table_cells(
self,
document_id: str,
table_data: List[List[str]],
bold_headers: bool,
tab_id: str = None,
) -> int:
"""
Populate table cells with data, refreshing structure after each insertion.
This prevents index shifting issues by getting fresh cell positions
before each insertion.
"""
population_count = 0
for row_idx, row_data in enumerate(table_data):
logger.debug(f"Processing row {row_idx}: {len(row_data)} cells")
for col_idx, cell_text in enumerate(row_data):
if not cell_text: # Skip empty cells
continue
try:
# CRITICAL: Refresh document structure before each insertion
success = await self._populate_single_cell(
document_id,
row_idx,
col_idx,
cell_text,
bold_headers and row_idx == 0,
tab_id,
)
if success:
population_count += 1
logger.debug(f"Populated cell ({row_idx},{col_idx})")
else:
logger.warning(f"Failed to populate cell ({row_idx},{col_idx})")
except Exception as e:
logger.error(
f"Error populating cell ({row_idx},{col_idx}): {str(e)}"
)
return population_count
async def _populate_single_cell(
self,
document_id: str,
row_idx: int,
col_idx: int,
cell_text: str,
apply_bold: bool = False,
tab_id: str = None,
) -> bool:
"""
Populate a single cell with text, with optional bold formatting.
Returns True if successful, False otherwise.
"""
try:
# Get fresh table structure to avoid index shifting issues
tables = await self._get_document_tables(document_id, tab_id)
if not tables:
return False
table = tables[-1] # Use the last table (newly created one)
cells = table.get("cells", [])
# Bounds checking
if row_idx >= len(cells) or col_idx >= len(cells[row_idx]):
logger.error(f"Cell ({row_idx},{col_idx}) out of bounds")
return False
cell = cells[row_idx][col_idx]
insertion_index = cell.get("insertion_index")
if not insertion_index:
logger.warning(f"No insertion_index for cell ({row_idx},{col_idx})")
return False
# Insert text
await asyncio.to_thread(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={
"requests": [
{
"insertText": {
"location": {"index": insertion_index},
"text": cell_text,
}
}
]
},
)
.execute
)
# Apply bold formatting if requested
if apply_bold:
await self._apply_bold_formatting(
document_id, insertion_index, insertion_index + len(cell_text)
)
return True
except Exception as e:
logger.error(f"Failed to populate single cell: {str(e)}")
return False
async def _apply_bold_formatting(
self, document_id: str, start_index: int, end_index: int
) -> None:
"""Apply bold formatting to a text range."""
await asyncio.to_thread(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={
"requests": [
{
"updateTextStyle": {
"range": {
"startIndex": start_index,
"endIndex": end_index,
},
"textStyle": {"bold": True},
"fields": "bold",
}
}
]
},
)
.execute
)
async def populate_existing_table(
self,
document_id: str,
table_index: int,
table_data: List[List[str]],
clear_existing: bool = False,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Populate an existing table with data.
Args:
document_id: ID of the document
table_index: Index of the table to populate (0-based)
table_data: 2D list of data to insert
clear_existing: Whether to clear existing content first
Returns:
Tuple of (success, message, metadata)
"""
try:
tables = await self._get_document_tables(document_id)
if table_index >= len(tables):
return (
False,
f"Table index {table_index} not found. Document has {len(tables)} tables",
{},
)
table_info = tables[table_index]
# Validate dimensions
table_rows = table_info["rows"]
table_cols = table_info["columns"]
data_rows = len(table_data)
data_cols = len(table_data[0]) if table_data else 0
if data_rows > table_rows or data_cols > table_cols:
return (
False,
f"Data ({data_rows}x{data_cols}) exceeds table dimensions ({table_rows}x{table_cols})",
{},
)
# Populate cells
population_count = await self._populate_existing_table_cells(
document_id, table_index, table_data
)
metadata = {
"table_index": table_index,
"populated_cells": population_count,
"table_dimensions": f"{table_rows}x{table_cols}",
"data_dimensions": f"{data_rows}x{data_cols}",
}
return (
True,
f"Successfully populated {population_count} cells in existing table",
metadata,
)
except Exception as e:
return False, f"Failed to populate existing table: {str(e)}", {}
async def _populate_existing_table_cells(
self, document_id: str, table_index: int, table_data: List[List[str]]
) -> int:
"""Populate cells in an existing table."""
population_count = 0
for row_idx, row_data in enumerate(table_data):
for col_idx, cell_text in enumerate(row_data):
if not cell_text:
continue
# Get fresh table structure for each cell
tables = await self._get_document_tables(document_id)
if table_index >= len(tables):
break
table = tables[table_index]
cells = table.get("cells", [])
if row_idx >= len(cells) or col_idx >= len(cells[row_idx]):
continue
cell = cells[row_idx][col_idx]
# For existing tables, append to existing content
cell_end = cell["end_index"] - 1 # Don't include cell end marker
try:
await asyncio.to_thread(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={
"requests": [
{
"insertText": {
"location": {"index": cell_end},
"text": cell_text,
}
}
]
},
)
.execute
)
population_count += 1
except Exception as e:
logger.error(
f"Failed to populate existing cell ({row_idx},{col_idx}): {str(e)}"
)
return population_count

View File

@@ -0,0 +1,727 @@
"""
Validation Manager
This module provides centralized validation logic for Google Docs operations,
extracting validation patterns from individual tool functions.
"""
import logging
from typing import Dict, Any, List, Tuple, Optional
from urllib.parse import urlparse
from gdocs.docs_helpers import validate_operation
logger = logging.getLogger(__name__)
class ValidationManager:
"""
Centralized validation manager for Google Docs operations.
Provides consistent validation patterns and error messages across
all document operations, reducing code duplication and improving
error message quality.
"""
def __init__(self):
"""Initialize the validation manager."""
self.validation_rules = self._setup_validation_rules()
def _setup_validation_rules(self) -> Dict[str, Any]:
"""Setup validation rules and constraints."""
return {
"table_max_rows": 1000,
"table_max_columns": 20,
"document_id_pattern": r"^[a-zA-Z0-9-_]+$",
"max_text_length": 1000000, # 1MB text limit
"font_size_range": (1, 400), # Google Docs font size limits
"valid_header_footer_types": ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"],
"valid_section_types": ["header", "footer"],
"valid_list_types": ["UNORDERED", "ORDERED"],
"valid_element_types": ["table", "list", "page_break"],
"valid_alignments": ["START", "CENTER", "END", "JUSTIFIED"],
"heading_level_range": (0, 6),
}
def validate_document_id(self, document_id: str) -> Tuple[bool, str]:
"""
Validate Google Docs document ID format.
Args:
document_id: Document ID to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not document_id:
return False, "Document ID cannot be empty"
if not isinstance(document_id, str):
return (
False,
f"Document ID must be a string, got {type(document_id).__name__}",
)
# Basic length check (Google Docs IDs are typically 40+ characters)
if len(document_id) < 20:
return False, "Document ID appears too short to be valid"
return True, ""
def validate_table_data(self, table_data: List[List[str]]) -> Tuple[bool, str]:
"""
Comprehensive validation for table data format.
This extracts and centralizes table validation logic from multiple functions.
Args:
table_data: 2D array of data to validate
Returns:
Tuple of (is_valid, detailed_error_message)
"""
if not table_data:
return (
False,
"Table data cannot be empty. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
if not isinstance(table_data, list):
return (
False,
f"Table data must be a list, got {type(table_data).__name__}. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
# Check if it's a 2D list
if not all(isinstance(row, list) for row in table_data):
non_list_rows = [
i for i, row in enumerate(table_data) if not isinstance(row, list)
]
return (
False,
f"All rows must be lists. Rows {non_list_rows} are not lists. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
# Check for empty rows
if any(len(row) == 0 for row in table_data):
empty_rows = [i for i, row in enumerate(table_data) if len(row) == 0]
return (
False,
f"Rows cannot be empty. Empty rows found at indices: {empty_rows}",
)
# Check column consistency
col_counts = [len(row) for row in table_data]
if len(set(col_counts)) > 1:
return (
False,
f"All rows must have the same number of columns. Found column counts: {col_counts}. Fix your data structure.",
)
rows = len(table_data)
cols = col_counts[0]
# Check dimension limits
if rows > self.validation_rules["table_max_rows"]:
return (
False,
f"Too many rows ({rows}). Maximum allowed: {self.validation_rules['table_max_rows']}",
)
if cols > self.validation_rules["table_max_columns"]:
return (
False,
f"Too many columns ({cols}). Maximum allowed: {self.validation_rules['table_max_columns']}",
)
# Check cell content types
for row_idx, row in enumerate(table_data):
for col_idx, cell in enumerate(row):
if cell is None:
return (
False,
f"Cell ({row_idx},{col_idx}) is None. All cells must be strings, use empty string '' for empty cells.",
)
if not isinstance(cell, str):
return (
False,
f"Cell ({row_idx},{col_idx}) is {type(cell).__name__}, not string. All cells must be strings. Value: {repr(cell)}",
)
return True, f"Valid table data: {rows}×{cols} table format"
def validate_text_formatting_params(
self,
bold: Optional[bool] = None,
italic: Optional[bool] = None,
underline: Optional[bool] = None,
font_size: Optional[int] = None,
font_family: Optional[str] = None,
text_color: Optional[str] = None,
background_color: Optional[str] = None,
link_url: Optional[str] = None,
) -> Tuple[bool, str]:
"""
Validate text formatting parameters.
Args:
bold: Bold setting
italic: Italic setting
underline: Underline setting
font_size: Font size in points
font_family: Font family name
text_color: Text color in "#RRGGBB" format
background_color: Background color in "#RRGGBB" format
link_url: Hyperlink URL (http/https)
Returns:
Tuple of (is_valid, error_message)
"""
# Check if at least one formatting option is provided
formatting_params = [
bold,
italic,
underline,
font_size,
font_family,
text_color,
background_color,
link_url,
]
if all(param is None for param in formatting_params):
return (
False,
"At least one formatting parameter must be provided (bold, italic, underline, font_size, font_family, text_color, background_color, or link_url)",
)
# Validate boolean parameters
for param, name in [
(bold, "bold"),
(italic, "italic"),
(underline, "underline"),
]:
if param is not None and not isinstance(param, bool):
return (
False,
f"{name} parameter must be boolean (True/False), got {type(param).__name__}",
)
# Validate font size
if font_size is not None:
if not isinstance(font_size, int):
return (
False,
f"font_size must be an integer, got {type(font_size).__name__}",
)
min_size, max_size = self.validation_rules["font_size_range"]
if not (min_size <= font_size <= max_size):
return (
False,
f"font_size must be between {min_size} and {max_size} points, got {font_size}",
)
# Validate font family
if font_family is not None:
if not isinstance(font_family, str):
return (
False,
f"font_family must be a string, got {type(font_family).__name__}",
)
if not font_family.strip():
return False, "font_family cannot be empty"
# Validate colors
is_valid, error_msg = self.validate_color_param(text_color, "text_color")
if not is_valid:
return False, error_msg
is_valid, error_msg = self.validate_color_param(
background_color, "background_color"
)
if not is_valid:
return False, error_msg
is_valid, error_msg = self.validate_link_url(link_url)
if not is_valid:
return False, error_msg
return True, ""
def validate_link_url(self, link_url: Optional[str]) -> Tuple[bool, str]:
"""Validate hyperlink URL parameters."""
if link_url is None:
return True, ""
if not isinstance(link_url, str):
return False, f"link_url must be a string, got {type(link_url).__name__}"
if not link_url.strip():
return False, "link_url cannot be empty"
parsed = urlparse(link_url)
if parsed.scheme not in ("http", "https"):
return False, "link_url must start with http:// or https://"
if not parsed.netloc:
return False, "link_url must include a valid host"
return True, ""
def validate_paragraph_style_params(
self,
heading_level: Optional[int] = None,
alignment: Optional[str] = None,
line_spacing: Optional[float] = None,
indent_first_line: Optional[float] = None,
indent_start: Optional[float] = None,
indent_end: Optional[float] = None,
space_above: Optional[float] = None,
space_below: Optional[float] = None,
named_style_type: Optional[str] = None,
) -> Tuple[bool, str]:
"""
Validate paragraph style parameters.
Args:
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (must be positive)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
named_style_type: Direct named style (TITLE, SUBTITLE, HEADING_1..6, NORMAL_TEXT)
Returns:
Tuple of (is_valid, error_message)
"""
style_params = [
heading_level,
alignment,
line_spacing,
indent_first_line,
indent_start,
indent_end,
space_above,
space_below,
named_style_type,
]
if all(param is None for param in style_params):
return (
False,
"At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)",
)
if heading_level is not None and named_style_type is not None:
return (
False,
"heading_level and named_style_type are mutually exclusive; provide only one",
)
if named_style_type is not None:
valid_styles = [
"NORMAL_TEXT",
"TITLE",
"SUBTITLE",
"HEADING_1",
"HEADING_2",
"HEADING_3",
"HEADING_4",
"HEADING_5",
"HEADING_6",
]
if named_style_type not in valid_styles:
return (
False,
f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}",
)
if heading_level is not None:
if not isinstance(heading_level, int):
return (
False,
f"heading_level must be an integer, got {type(heading_level).__name__}",
)
min_level, max_level = self.validation_rules["heading_level_range"]
if not (min_level <= heading_level <= max_level):
return (
False,
f"heading_level must be between {min_level} and {max_level}, got {heading_level}",
)
if alignment is not None:
if not isinstance(alignment, str):
return (
False,
f"alignment must be a string, got {type(alignment).__name__}",
)
valid = self.validation_rules["valid_alignments"]
if alignment.upper() not in valid:
return (
False,
f"alignment must be one of: {', '.join(valid)}, got '{alignment}'",
)
if line_spacing is not None:
if not isinstance(line_spacing, (int, float)):
return (
False,
f"line_spacing must be a number, got {type(line_spacing).__name__}",
)
if line_spacing <= 0:
return False, "line_spacing must be positive"
for param, name in [
(indent_first_line, "indent_first_line"),
(indent_start, "indent_start"),
(indent_end, "indent_end"),
(space_above, "space_above"),
(space_below, "space_below"),
]:
if param is not None:
if not isinstance(param, (int, float)):
return (
False,
f"{name} must be a number, got {type(param).__name__}",
)
# indent_first_line may be negative (hanging indent)
if name != "indent_first_line" and param < 0:
return False, f"{name} must be non-negative, got {param}"
return True, ""
def validate_color_param(
self, color: Optional[str], param_name: str
) -> Tuple[bool, str]:
"""Validate color parameters (hex string "#RRGGBB")."""
if color is None:
return True, ""
if not isinstance(color, str):
return False, f"{param_name} must be a hex string like '#RRGGBB'"
if len(color) != 7 or not color.startswith("#"):
return False, f"{param_name} must be a hex string like '#RRGGBB'"
hex_color = color[1:]
if any(c not in "0123456789abcdefABCDEF" for c in hex_color):
return False, f"{param_name} must be a hex string like '#RRGGBB'"
return True, ""
def validate_index(self, index: int, context: str = "Index") -> Tuple[bool, str]:
"""
Validate a single document index.
Args:
index: Index to validate
context: Context description for error messages
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(index, int):
return False, f"{context} must be an integer, got {type(index).__name__}"
if index < 0:
return (
False,
f"{context} {index} is negative. You MUST call inspect_doc_structure first to get the proper insertion index.",
)
return True, ""
def validate_index_range(
self,
start_index: int,
end_index: Optional[int] = None,
document_length: Optional[int] = None,
) -> Tuple[bool, str]:
"""
Validate document index ranges.
Args:
start_index: Starting index
end_index: Ending index (optional)
document_length: Total document length for bounds checking
Returns:
Tuple of (is_valid, error_message)
"""
# Validate start_index
if not isinstance(start_index, int):
return (
False,
f"start_index must be an integer, got {type(start_index).__name__}",
)
if start_index < 0:
return False, f"start_index cannot be negative, got {start_index}"
# Validate end_index if provided
if end_index is not None:
if not isinstance(end_index, int):
return (
False,
f"end_index must be an integer, got {type(end_index).__name__}",
)
if end_index <= start_index:
return (
False,
f"end_index ({end_index}) must be greater than start_index ({start_index})",
)
# Validate against document length if provided
if document_length is not None:
if start_index >= document_length:
return (
False,
f"start_index ({start_index}) exceeds document length ({document_length})",
)
if end_index is not None and end_index > document_length:
return (
False,
f"end_index ({end_index}) exceeds document length ({document_length})",
)
return True, ""
def validate_element_insertion_params(
self, element_type: str, index: int, **kwargs
) -> Tuple[bool, str]:
"""
Validate parameters for element insertion.
Args:
element_type: Type of element to insert
index: Insertion index
**kwargs: Additional parameters specific to element type
Returns:
Tuple of (is_valid, error_message)
"""
# Validate element type
if element_type not in self.validation_rules["valid_element_types"]:
valid_types = ", ".join(self.validation_rules["valid_element_types"])
return (
False,
f"Invalid element_type '{element_type}'. Must be one of: {valid_types}",
)
# Validate index
if not isinstance(index, int) or index < 0:
return False, f"index must be a non-negative integer, got {index}"
# Validate element-specific parameters
if element_type == "table":
rows = kwargs.get("rows")
columns = kwargs.get("columns")
if not rows or not columns:
return False, "Table insertion requires 'rows' and 'columns' parameters"
if not isinstance(rows, int) or not isinstance(columns, int):
return False, "Table rows and columns must be integers"
if rows <= 0 or columns <= 0:
return False, "Table rows and columns must be positive integers"
if rows > self.validation_rules["table_max_rows"]:
return (
False,
f"Too many rows ({rows}). Maximum: {self.validation_rules['table_max_rows']}",
)
if columns > self.validation_rules["table_max_columns"]:
return (
False,
f"Too many columns ({columns}). Maximum: {self.validation_rules['table_max_columns']}",
)
elif element_type == "list":
list_type = kwargs.get("list_type")
if not list_type:
return False, "List insertion requires 'list_type' parameter"
if list_type not in self.validation_rules["valid_list_types"]:
valid_types = ", ".join(self.validation_rules["valid_list_types"])
return (
False,
f"Invalid list_type '{list_type}'. Must be one of: {valid_types}",
)
return True, ""
def validate_header_footer_params(
self, section_type: str, header_footer_type: str = "DEFAULT"
) -> Tuple[bool, str]:
"""
Validate header/footer operation parameters.
Args:
section_type: Type of section ("header" or "footer")
header_footer_type: Specific header/footer type
Returns:
Tuple of (is_valid, error_message)
"""
if section_type not in self.validation_rules["valid_section_types"]:
valid_types = ", ".join(self.validation_rules["valid_section_types"])
return (
False,
f"section_type must be one of: {valid_types}, got '{section_type}'",
)
if header_footer_type not in self.validation_rules["valid_header_footer_types"]:
valid_types = ", ".join(self.validation_rules["valid_header_footer_types"])
return (
False,
f"header_footer_type must be one of: {valid_types}, got '{header_footer_type}'",
)
return True, ""
def validate_batch_operations(
self, operations: List[Dict[str, Any]]
) -> Tuple[bool, str]:
"""
Validate a list of batch operations.
Args:
operations: List of operation dictionaries
Returns:
Tuple of (is_valid, error_message)
"""
if not operations:
return False, "Operations list cannot be empty"
if not isinstance(operations, list):
return False, f"Operations must be a list, got {type(operations).__name__}"
# Validate each operation
for i, op in enumerate(operations):
if not isinstance(op, dict):
return (
False,
f"Operation {i + 1} must be a dictionary, got {type(op).__name__}",
)
if "type" not in op:
return False, f"Operation {i + 1} missing required 'type' field"
# Validate required fields for the operation type
is_valid, error_msg = validate_operation(op)
if not is_valid:
return False, f"Operation {i + 1}: {error_msg}"
op_type = op["type"]
if op_type == "format_text":
is_valid, error_msg = self.validate_text_formatting_params(
op.get("bold"),
op.get("italic"),
op.get("underline"),
op.get("font_size"),
op.get("font_family"),
op.get("text_color"),
op.get("background_color"),
op.get("link_url"),
)
if not is_valid:
return False, f"Operation {i + 1} (format_text): {error_msg}"
is_valid, error_msg = self.validate_index_range(
op["start_index"], op["end_index"]
)
if not is_valid:
return False, f"Operation {i + 1} (format_text): {error_msg}"
elif op_type == "update_paragraph_style":
is_valid, error_msg = self.validate_paragraph_style_params(
op.get("heading_level"),
op.get("alignment"),
op.get("line_spacing"),
op.get("indent_first_line"),
op.get("indent_start"),
op.get("indent_end"),
op.get("space_above"),
op.get("space_below"),
op.get("named_style_type"),
)
if not is_valid:
return (
False,
f"Operation {i + 1} (update_paragraph_style): {error_msg}",
)
is_valid, error_msg = self.validate_index_range(
op["start_index"], op["end_index"]
)
if not is_valid:
return (
False,
f"Operation {i + 1} (update_paragraph_style): {error_msg}",
)
return True, ""
def validate_text_content(
self, text: str, max_length: Optional[int] = None
) -> Tuple[bool, str]:
"""
Validate text content for insertion.
Args:
text: Text to validate
max_length: Maximum allowed length
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(text, str):
return False, f"Text must be a string, got {type(text).__name__}"
max_len = max_length or self.validation_rules["max_text_length"]
if len(text) > max_len:
return False, f"Text too long ({len(text)} characters). Maximum: {max_len}"
return True, ""
def get_validation_summary(self) -> Dict[str, Any]:
"""
Get a summary of all validation rules and constraints.
Returns:
Dictionary containing validation rules
"""
return {
"constraints": self.validation_rules.copy(),
"supported_operations": {
"table_operations": ["create_table", "populate_table"],
"text_operations": [
"insert_text",
"format_text",
"find_replace",
"update_paragraph_style",
],
"element_operations": [
"insert_table",
"insert_list",
"insert_page_break",
],
"header_footer_operations": ["update_header", "update_footer"],
},
"data_formats": {
"table_data": "2D list of strings: [['col1', 'col2'], ['row1col1', 'row1col2']]",
"text_formatting": "Optional boolean/integer parameters for styling",
"document_indices": "Non-negative integers for position specification",
},
}