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
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:
18
gdocs/managers/__init__.py
Normal file
18
gdocs/managers/__init__.py
Normal 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",
|
||||
]
|
||||
534
gdocs/managers/batch_operation_manager.py
Normal file
534
gdocs/managers/batch_operation_manager.py
Normal 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",
|
||||
},
|
||||
],
|
||||
}
|
||||
339
gdocs/managers/header_footer_manager.py
Normal file
339
gdocs/managers/header_footer_manager.py
Normal 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}"
|
||||
405
gdocs/managers/table_operation_manager.py
Normal file
405
gdocs/managers/table_operation_manager.py
Normal 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
|
||||
727
gdocs/managers/validation_manager.py
Normal file
727
gdocs/managers/validation_manager.py
Normal 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",
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user