apply ruff formatting

This commit is contained in:
Taylor Wilsdon
2025-12-13 13:49:28 -08:00
parent 1d80a24ca4
commit 6b8352a354
50 changed files with 4010 additions and 2842 deletions

View File

@@ -3,6 +3,7 @@ Google Docs MCP Tools
This module provides MCP tools for interacting with Google Docs API and managing Google Docs via Drive.
"""
import logging
import asyncio
import io
@@ -25,30 +26,29 @@ from gdocs.docs_helpers import (
create_insert_table_request,
create_insert_page_break_request,
create_insert_image_request,
create_bullet_list_request
create_bullet_list_request,
)
# Import document structure and table utilities
from gdocs.docs_structure import (
parse_document_structure,
find_tables,
analyze_document_complexity
)
from gdocs.docs_tables import (
extract_table_as_data
analyze_document_complexity,
)
from gdocs.docs_tables import extract_table_as_data
# Import operation managers for complex business logic
from gdocs.managers import (
TableOperationManager,
HeaderFooterManager,
ValidationManager,
BatchOperationManager
BatchOperationManager,
)
import json
logger = logging.getLogger(__name__)
@server.tool()
@handle_http_errors("search_docs", is_read_only=True, service_type="docs")
@require_google_service("drive", "drive_read")
@@ -69,15 +69,17 @@ async def search_docs(
escaped_query = query.replace("'", "\\'")
response = await asyncio.to_thread(
service.files().list(
service.files()
.list(
q=f"name contains '{escaped_query}' and mimeType='application/vnd.google-apps.document' and trashed=false",
pageSize=page_size,
fields="files(id, name, createdTime, modifiedTime, webViewLink)",
supportsAllDrives=True,
includeItemsFromAllDrives=True
).execute
includeItemsFromAllDrives=True,
)
.execute
)
files = response.get('files', [])
files = response.get("files", [])
if not files:
return f"No Google Docs found matching '{query}'."
@@ -88,12 +90,19 @@ async def search_docs(
)
return "\n".join(output)
@server.tool()
@handle_http_errors("get_doc_content", is_read_only=True, service_type="docs")
@require_multiple_services([
{"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
{"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}
])
@require_multiple_services(
[
{
"service_type": "drive",
"scopes": "drive_read",
"param_name": "drive_service",
},
{"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"},
]
)
async def get_doc_content(
drive_service: Any,
docs_service: Any,
@@ -108,31 +117,37 @@ async def get_doc_content(
Returns:
str: The document content with metadata header.
"""
logger.info(f"[get_doc_content] Invoked. Document/File ID: '{document_id}' for user '{user_google_email}'")
logger.info(
f"[get_doc_content] Invoked. Document/File ID: '{document_id}' for user '{user_google_email}'"
)
# Step 2: Get file metadata from Drive
file_metadata = await asyncio.to_thread(
drive_service.files().get(
fileId=document_id, fields="id, name, mimeType, webViewLink",
supportsAllDrives=True
).execute
drive_service.files()
.get(
fileId=document_id,
fields="id, name, mimeType, webViewLink",
supportsAllDrives=True,
)
.execute
)
mime_type = file_metadata.get("mimeType", "")
file_name = file_metadata.get("name", "Unknown File")
web_view_link = file_metadata.get("webViewLink", "#")
logger.info(f"[get_doc_content] File '{file_name}' (ID: {document_id}) has mimeType: '{mime_type}'")
logger.info(
f"[get_doc_content] File '{file_name}' (ID: {document_id}) has mimeType: '{mime_type}'"
)
body_text = "" # Initialize body_text
body_text = "" # Initialize body_text
# Step 3: Process based on mimeType
if mime_type == "application/vnd.google-apps.document":
logger.info("[get_doc_content] Processing as native Google Doc.")
doc_data = await asyncio.to_thread(
docs_service.documents().get(
documentId=document_id,
includeTabsContent=True
).execute
docs_service.documents()
.get(documentId=document_id, includeTabsContent=True)
.execute
)
# Tab header format constant
TAB_HEADER_FORMAT = "\n--- TAB: {tab_name} ---\n"
@@ -147,25 +162,27 @@ async def get_doc_content(
text_lines.append(TAB_HEADER_FORMAT.format(tab_name=tab_name))
for element in elements:
if 'paragraph' in element:
paragraph = element.get('paragraph', {})
para_elements = paragraph.get('elements', [])
if "paragraph" in element:
paragraph = element.get("paragraph", {})
para_elements = paragraph.get("elements", [])
current_line_text = ""
for pe in para_elements:
text_run = pe.get('textRun', {})
if text_run and 'content' in text_run:
current_line_text += text_run['content']
text_run = pe.get("textRun", {})
if text_run and "content" in text_run:
current_line_text += text_run["content"]
if current_line_text.strip():
text_lines.append(current_line_text)
elif 'table' in element:
elif "table" in element:
# Handle table content
table = element.get('table', {})
table_rows = table.get('tableRows', [])
table = element.get("table", {})
table_rows = table.get("tableRows", [])
for row in table_rows:
row_cells = row.get('tableCells', [])
row_cells = row.get("tableCells", [])
for cell in row_cells:
cell_content = cell.get('content', [])
cell_text = extract_text_from_elements(cell_content, depth=depth + 1)
cell_content = cell.get("content", [])
cell_text = extract_text_from_elements(
cell_content, depth=depth + 1
)
if cell_text.strip():
text_lines.append(cell_text)
return "".join(text_lines)
@@ -174,18 +191,18 @@ async def get_doc_content(
"""Process a tab and its nested child tabs recursively"""
tab_text = ""
if 'documentTab' in tab:
props = tab.get('tabProperties', {})
tab_title = props.get('title', 'Untitled Tab')
tab_id = props.get('tabId', 'Unknown ID')
if "documentTab" in tab:
props = tab.get("tabProperties", {})
tab_title = props.get("title", "Untitled Tab")
tab_id = props.get("tabId", "Unknown ID")
# Add indentation for nested tabs to show hierarchy
if level > 0:
tab_title = " " * level + f"{tab_title} ( ID: {tab_id})"
tab_body = tab.get('documentTab', {}).get('body', {}).get('content', [])
tab_body = tab.get("documentTab", {}).get("body", {}).get("content", [])
tab_text += extract_text_from_elements(tab_body, tab_title)
# Process child tabs (nested tabs)
child_tabs = tab.get('childTabs', [])
child_tabs = tab.get("childTabs", [])
for child_tab in child_tabs:
tab_text += process_tab_hierarchy(child_tab, level + 1)
@@ -194,13 +211,13 @@ async def get_doc_content(
processed_text_lines = []
# Process main document body
body_elements = doc_data.get('body', {}).get('content', [])
body_elements = doc_data.get("body", {}).get("content", [])
main_content = extract_text_from_elements(body_elements)
if main_content.strip():
processed_text_lines.append(main_content)
# Process all tabs
tabs = doc_data.get('tabs', [])
tabs = doc_data.get("tabs", [])
for tab in tabs:
tab_content = process_tab_hierarchy(tab)
if tab_content.strip():
@@ -208,19 +225,27 @@ async def get_doc_content(
body_text = "".join(processed_text_lines)
else:
logger.info(f"[get_doc_content] Processing as Drive file (e.g., .docx, other). MimeType: {mime_type}")
logger.info(
f"[get_doc_content] Processing as Drive file (e.g., .docx, other). MimeType: {mime_type}"
)
export_mime_type_map = {
# Example: "application/vnd.google-apps.spreadsheet"z: "text/csv",
# Native GSuite types that are not Docs would go here if this function
# was intended to export them. For .docx, direct download is used.
# Example: "application/vnd.google-apps.spreadsheet"z: "text/csv",
# Native GSuite types that are not Docs would go here if this function
# was intended to export them. For .docx, direct download is used.
}
effective_export_mime = export_mime_type_map.get(mime_type)
request_obj = (
drive_service.files().export_media(fileId=document_id, mimeType=effective_export_mime, supportsAllDrives=True)
drive_service.files().export_media(
fileId=document_id,
mimeType=effective_export_mime,
supportsAllDrives=True,
)
if effective_export_mime
else drive_service.files().get_media(fileId=document_id, supportsAllDrives=True)
else drive_service.files().get_media(
fileId=document_id, supportsAllDrives=True
)
)
fh = io.BytesIO()
@@ -246,18 +271,16 @@ async def get_doc_content(
header = (
f'File: "{file_name}" (ID: {document_id}, Type: {mime_type})\n'
f'Link: {web_view_link}\n\n--- CONTENT ---\n'
f"Link: {web_view_link}\n\n--- CONTENT ---\n"
)
return header + body_text
@server.tool()
@handle_http_errors("list_docs_in_folder", is_read_only=True, service_type="docs")
@require_google_service("drive", "drive_read")
async def list_docs_in_folder(
service: Any,
user_google_email: str,
folder_id: str = 'root',
page_size: int = 100
service: Any, user_google_email: str, folder_id: str = "root", page_size: int = 100
) -> str:
"""
Lists Google Docs within a specific Drive folder.
@@ -265,25 +288,32 @@ async def list_docs_in_folder(
Returns:
str: A formatted list of Google Docs in the specified folder.
"""
logger.info(f"[list_docs_in_folder] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'")
logger.info(
f"[list_docs_in_folder] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'"
)
rsp = await asyncio.to_thread(
service.files().list(
service.files()
.list(
q=f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.document' and trashed=false",
pageSize=page_size,
fields="files(id, name, modifiedTime, webViewLink)",
supportsAllDrives=True,
includeItemsFromAllDrives=True
).execute
includeItemsFromAllDrives=True,
)
.execute
)
items = rsp.get('files', [])
items = rsp.get("files", [])
if not items:
return f"No Google Docs found in folder '{folder_id}'."
out = [f"Found {len(items)} Docs in folder '{folder_id}':"]
for f in items:
out.append(f"- {f['name']} (ID: {f['id']}) Modified: {f.get('modifiedTime')} Link: {f.get('webViewLink')}")
out.append(
f"- {f['name']} (ID: {f['id']}) Modified: {f.get('modifiedTime')} Link: {f.get('webViewLink')}"
)
return "\n".join(out)
@server.tool()
@handle_http_errors("create_doc", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -291,7 +321,7 @@ async def create_doc(
service: Any,
user_google_email: str,
title: str,
content: str = '',
content: str = "",
) -> str:
"""
Creates a new Google Doc and optionally inserts initial content.
@@ -301,14 +331,22 @@ async def create_doc(
"""
logger.info(f"[create_doc] Invoked. Email: '{user_google_email}', Title='{title}'")
doc = await asyncio.to_thread(service.documents().create(body={'title': title}).execute)
doc_id = doc.get('documentId')
doc = await asyncio.to_thread(
service.documents().create(body={"title": title}).execute
)
doc_id = doc.get("documentId")
if content:
requests = [{'insertText': {'location': {'index': 1}, 'text': content}}]
await asyncio.to_thread(service.documents().batchUpdate(documentId=doc_id, body={'requests': requests}).execute)
requests = [{"insertText": {"location": {"index": 1}, "text": content}}]
await asyncio.to_thread(
service.documents()
.batchUpdate(documentId=doc_id, body={"requests": requests})
.execute
)
link = f"https://docs.google.com/document/d/{doc_id}/edit"
msg = f"Created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}"
logger.info(f"Successfully created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}")
logger.info(
f"Successfully created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}"
)
return msg
@@ -363,16 +401,39 @@ async def modify_doc_text(
return f"Error: {error_msg}"
# Validate that we have something to do
if text is None and not any([
bold is not None, italic is not None, underline is not None,
font_size, font_family, text_color, background_color
]):
if text is None and not any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
]
):
return "Error: Must provide either 'text' to insert/replace, or formatting parameters (bold, italic, underline, font_size, font_family, text_color, background_color)."
# Validate text formatting params if provided
if any([bold is not None, italic is not None, underline is not None, font_size, font_family, text_color, background_color]):
if any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
]
):
is_valid, error_msg = validator.validate_text_formatting_params(
bold, italic, underline, font_size, font_family, text_color, background_color
bold,
italic,
underline,
font_size,
font_family,
text_color,
background_color,
)
if not is_valid:
return f"Error: {error_msg}"
@@ -397,15 +458,23 @@ async def modify_doc_text(
# Instead, we insert new text at index 1 and then delete the old text
requests.append(create_insert_text_request(1, text))
adjusted_end = end_index + len(text)
requests.append(create_delete_range_request(1 + len(text), adjusted_end))
operations.append(f"Replaced text from index {start_index} to {end_index}")
requests.append(
create_delete_range_request(1 + len(text), adjusted_end)
)
operations.append(
f"Replaced text from index {start_index} to {end_index}"
)
else:
# Normal replacement: delete old text, then insert new text
requests.extend([
create_delete_range_request(start_index, end_index),
create_insert_text_request(start_index, text)
])
operations.append(f"Replaced text from index {start_index} to {end_index}")
requests.extend(
[
create_delete_range_request(start_index, end_index),
create_insert_text_request(start_index, text),
]
)
operations.append(
f"Replaced text from index {start_index} to {end_index}"
)
else:
# Text insertion
actual_index = 1 if start_index == 0 else start_index
@@ -413,7 +482,17 @@ async def modify_doc_text(
operations.append(f"Inserted text at index {start_index}")
# Handle formatting
if any([bold is not None, italic is not None, underline is not None, font_size, font_family, text_color, background_color]):
if any(
[
bold is not None,
italic is not None,
underline is not None,
font_size,
font_family,
text_color,
background_color,
]
):
# Adjust range for formatting based on text operations
format_start = start_index
format_end = end_index
@@ -444,7 +523,7 @@ async def modify_doc_text(
font_size,
font_family,
text_color,
background_color
background_color,
)
)
@@ -464,13 +543,14 @@ async def modify_doc_text(
if background_color:
format_details.append(f"background_color={background_color}")
operations.append(f"Applied formatting ({', '.join(format_details)}) to range {format_start}-{format_end}")
operations.append(
f"Applied formatting ({', '.join(format_details)}) to range {format_start}-{format_end}"
)
await asyncio.to_thread(
service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
@@ -478,6 +558,7 @@ async def modify_doc_text(
text_info = f" Text length: {len(text)} characters." if text else ""
return f"{operation_summary} in document {document_id}.{text_info} Link: {link}"
@server.tool()
@handle_http_errors("find_and_replace_doc", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -502,23 +583,24 @@ async def find_and_replace_doc(
Returns:
str: Confirmation message with replacement count
"""
logger.info(f"[find_and_replace_doc] Doc={document_id}, find='{find_text}', replace='{replace_text}'")
logger.info(
f"[find_and_replace_doc] Doc={document_id}, find='{find_text}', replace='{replace_text}'"
)
requests = [create_find_replace_request(find_text, replace_text, match_case)]
result = await asyncio.to_thread(
service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
# Extract number of replacements from response
replacements = 0
if 'replies' in result and result['replies']:
reply = result['replies'][0]
if 'replaceAllText' in reply:
replacements = reply['replaceAllText'].get('occurrencesChanged', 0)
if "replies" in result and result["replies"]:
reply = result["replies"][0]
if "replaceAllText" in reply:
replacements = reply["replaceAllText"].get("occurrencesChanged", 0)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Replaced {replacements} occurrence(s) of '{find_text}' with '{replace_text}' in document {document_id}. Link: {link}"
@@ -554,7 +636,9 @@ async def insert_doc_elements(
Returns:
str: Confirmation message with insertion details
"""
logger.info(f"[insert_doc_elements] Doc={document_id}, type={element_type}, index={index}")
logger.info(
f"[insert_doc_elements] Doc={document_id}, type={element_type}, index={index}"
)
# Handle the special case where we can't insert at the first section break
# If index is 0, bump it to 1 to avoid the section break
@@ -579,10 +663,12 @@ async def insert_doc_elements(
text = "List item"
# Insert text first, then create list
requests.extend([
create_insert_text_request(index, text + '\n'),
create_bullet_list_request(index, index + len(text), list_type)
])
requests.extend(
[
create_insert_text_request(index, text + "\n"),
create_bullet_list_request(index, index + len(text), list_type),
]
)
description = f"{list_type.lower()} list"
elif element_type == "page_break":
@@ -593,21 +679,27 @@ async def insert_doc_elements(
return f"Error: Unsupported element type '{element_type}'. Supported types: 'table', 'list', 'page_break'."
await asyncio.to_thread(
service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Inserted {description} at index {index} in document {document_id}. Link: {link}"
@server.tool()
@handle_http_errors("insert_doc_image", service_type="docs")
@require_multiple_services([
{"service_type": "docs", "scopes": "docs_write", "param_name": "docs_service"},
{"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"}
])
@require_multiple_services(
[
{"service_type": "docs", "scopes": "docs_write", "param_name": "docs_service"},
{
"service_type": "drive",
"scopes": "drive_read",
"param_name": "drive_service",
},
]
)
async def insert_doc_image(
docs_service: Any,
drive_service: Any,
@@ -632,7 +724,9 @@ async def insert_doc_image(
Returns:
str: Confirmation message with insertion details
"""
logger.info(f"[insert_doc_image] Doc={document_id}, source={image_source}, index={index}")
logger.info(
f"[insert_doc_image] Doc={document_id}, source={image_source}, index={index}"
)
# Handle the special case where we can't insert at the first section break
# If index is 0, bump it to 1 to avoid the section break
@@ -641,20 +735,24 @@ async def insert_doc_image(
index = 1
# Determine if source is a Drive file ID or URL
is_drive_file = not (image_source.startswith('http://') or image_source.startswith('https://'))
is_drive_file = not (
image_source.startswith("http://") or image_source.startswith("https://")
)
if is_drive_file:
# Verify Drive file exists and get metadata
try:
file_metadata = await asyncio.to_thread(
drive_service.files().get(
drive_service.files()
.get(
fileId=image_source,
fields="id, name, mimeType",
supportsAllDrives=True
).execute
supportsAllDrives=True,
)
.execute
)
mime_type = file_metadata.get('mimeType', '')
if not mime_type.startswith('image/'):
mime_type = file_metadata.get("mimeType", "")
if not mime_type.startswith("image/"):
return f"Error: File {image_source} is not an image (MIME type: {mime_type})."
image_uri = f"https://drive.google.com/uc?id={image_source}"
@@ -669,10 +767,9 @@ async def insert_doc_image(
requests = [create_insert_image_request(index, image_uri, width, height)]
await asyncio.to_thread(
docs_service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
docs_service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
size_info = ""
@@ -682,6 +779,7 @@ async def insert_doc_image(
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Inserted {source_description}{size_info} at index {index} in document {document_id}. Link: {link}"
@server.tool()
@handle_http_errors("update_doc_headers_footers", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -715,7 +813,9 @@ async def update_doc_headers_footers(
if not is_valid:
return f"Error: {error_msg}"
is_valid, error_msg = validator.validate_header_footer_params(section_type, header_footer_type)
is_valid, error_msg = validator.validate_header_footer_params(
section_type, header_footer_type
)
if not is_valid:
return f"Error: {error_msg}"
@@ -736,6 +836,7 @@ async def update_doc_headers_footers(
else:
return f"Error: {message}"
@server.tool()
@handle_http_errors("batch_update_doc", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -787,11 +888,12 @@ async def batch_update_doc(
if success:
link = f"https://docs.google.com/document/d/{document_id}/edit"
replies_count = metadata.get('replies_count', 0)
replies_count = metadata.get("replies_count", 0)
return f"{message} on document {document_id}. API replies: {replies_count}. Link: {link}"
else:
return f"Error: {message}"
@server.tool()
@handle_http_errors("inspect_doc_structure", is_read_only=True, service_type="docs")
@require_google_service("docs", "docs_read")
@@ -846,46 +948,56 @@ async def inspect_doc_structure(
# Simplify for JSON serialization
result = {
'title': structure['title'],
'total_length': structure['total_length'],
'statistics': {
'elements': len(structure['body']),
'tables': len(structure['tables']),
'paragraphs': sum(1 for e in structure['body'] if e.get('type') == 'paragraph'),
'has_headers': bool(structure['headers']),
'has_footers': bool(structure['footers'])
"title": structure["title"],
"total_length": structure["total_length"],
"statistics": {
"elements": len(structure["body"]),
"tables": len(structure["tables"]),
"paragraphs": sum(
1 for e in structure["body"] if e.get("type") == "paragraph"
),
"has_headers": bool(structure["headers"]),
"has_footers": bool(structure["footers"]),
},
'elements': []
"elements": [],
}
# Add element summaries
for element in structure['body']:
for element in structure["body"]:
elem_summary = {
'type': element['type'],
'start_index': element['start_index'],
'end_index': element['end_index']
"type": element["type"],
"start_index": element["start_index"],
"end_index": element["end_index"],
}
if element['type'] == 'table':
elem_summary['rows'] = element['rows']
elem_summary['columns'] = element['columns']
elem_summary['cell_count'] = len(element.get('cells', []))
elif element['type'] == 'paragraph':
elem_summary['text_preview'] = element.get('text', '')[:100]
if element["type"] == "table":
elem_summary["rows"] = element["rows"]
elem_summary["columns"] = element["columns"]
elem_summary["cell_count"] = len(element.get("cells", []))
elif element["type"] == "paragraph":
elem_summary["text_preview"] = element.get("text", "")[:100]
result['elements'].append(elem_summary)
result["elements"].append(elem_summary)
# Add table details
if structure['tables']:
result['tables'] = []
for i, table in enumerate(structure['tables']):
if structure["tables"]:
result["tables"] = []
for i, table in enumerate(structure["tables"]):
table_data = extract_table_as_data(table)
result['tables'].append({
'index': i,
'position': {'start': table['start_index'], 'end': table['end_index']},
'dimensions': {'rows': table['rows'], 'columns': table['columns']},
'preview': table_data[:3] if table_data else [] # First 3 rows
})
result["tables"].append(
{
"index": i,
"position": {
"start": table["start_index"],
"end": table["end_index"],
},
"dimensions": {
"rows": table["rows"],
"columns": table["columns"],
},
"preview": table_data[:3] if table_data else [], # First 3 rows
}
)
else:
# Return basic analysis
@@ -894,19 +1006,22 @@ async def inspect_doc_structure(
# Add table information
tables = find_tables(doc)
if tables:
result['table_details'] = []
result["table_details"] = []
for i, table in enumerate(tables):
result['table_details'].append({
'index': i,
'rows': table['rows'],
'columns': table['columns'],
'start_index': table['start_index'],
'end_index': table['end_index']
})
result["table_details"].append(
{
"index": i,
"rows": table["rows"],
"columns": table["columns"],
"start_index": table["start_index"],
"end_index": table["end_index"],
}
)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Document structure analysis for {document_id}:\n\n{json.dumps(result, indent=2)}\n\nLink: {link}"
@server.tool()
@handle_http_errors("create_table_with_data", service_type="docs")
@require_google_service("docs", "docs_write")
@@ -986,17 +1101,21 @@ async def create_table_with_data(
# If it failed due to index being at or beyond document end, retry with adjusted index
if not success and "must be less than the end index" in message:
logger.debug(f"Index {index} is at document boundary, retrying with index {index - 1}")
logger.debug(
f"Index {index} is at document boundary, retrying with index {index - 1}"
)
success, message, metadata = await table_manager.create_and_populate_table(
document_id, table_data, index - 1, bold_headers
)
if success:
link = f"https://docs.google.com/document/d/{document_id}/edit"
rows = metadata.get('rows', 0)
columns = metadata.get('columns', 0)
rows = metadata.get("rows", 0)
columns = metadata.get("columns", 0)
return f"SUCCESS: {message}. Table: {rows}x{columns}, Index: {index}. Link: {link}"
return (
f"SUCCESS: {message}. Table: {rows}x{columns}, Index: {index}. Link: {link}"
)
else:
return f"ERROR: {message}"
@@ -1047,7 +1166,9 @@ async def debug_table_structure(
Returns:
str: Detailed JSON structure showing table layout, cell positions, and current content
"""
logger.debug(f"[debug_table_structure] Doc={document_id}, table_index={table_index}")
logger.debug(
f"[debug_table_structure] Doc={document_id}, table_index={table_index}"
)
# Get the document
doc = await asyncio.to_thread(
@@ -1063,28 +1184,29 @@ async def debug_table_structure(
# Extract detailed cell information
debug_info = {
'table_index': table_index,
'dimensions': f"{table_info['rows']}x{table_info['columns']}",
'table_range': f"[{table_info['start_index']}-{table_info['end_index']}]",
'cells': []
"table_index": table_index,
"dimensions": f"{table_info['rows']}x{table_info['columns']}",
"table_range": f"[{table_info['start_index']}-{table_info['end_index']}]",
"cells": [],
}
for row_idx, row in enumerate(table_info['cells']):
for row_idx, row in enumerate(table_info["cells"]):
row_info = []
for col_idx, cell in enumerate(row):
cell_debug = {
'position': f"({row_idx},{col_idx})",
'range': f"[{cell['start_index']}-{cell['end_index']}]",
'insertion_index': cell.get('insertion_index', 'N/A'),
'current_content': repr(cell.get('content', '')),
'content_elements_count': len(cell.get('content_elements', []))
"position": f"({row_idx},{col_idx})",
"range": f"[{cell['start_index']}-{cell['end_index']}]",
"insertion_index": cell.get("insertion_index", "N/A"),
"current_content": repr(cell.get("content", "")),
"content_elements_count": len(cell.get("content_elements", [])),
}
row_info.append(cell_debug)
debug_info['cells'].append(row_info)
debug_info["cells"].append(row_info)
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"Table structure debug for table {table_index}:\n\n{json.dumps(debug_info, indent=2)}\n\nLink: {link}"
@server.tool()
@handle_http_errors("export_doc_to_pdf", service_type="drive")
@require_google_service("drive", "drive_file")
@@ -1107,16 +1229,20 @@ async def export_doc_to_pdf(
Returns:
str: Confirmation message with PDF file details and links
"""
logger.info(f"[export_doc_to_pdf] Email={user_google_email}, Doc={document_id}, pdf_filename={pdf_filename}, folder_id={folder_id}")
logger.info(
f"[export_doc_to_pdf] Email={user_google_email}, Doc={document_id}, pdf_filename={pdf_filename}, folder_id={folder_id}"
)
# Get file metadata first to validate it's a Google Doc
try:
file_metadata = await asyncio.to_thread(
service.files().get(
fileId=document_id,
service.files()
.get(
fileId=document_id,
fields="id, name, mimeType, webViewLink",
supportsAllDrives=True
).execute
supportsAllDrives=True,
)
.execute
)
except Exception as e:
return f"Error: Could not access document {document_id}: {str(e)}"
@@ -1134,75 +1260,70 @@ async def export_doc_to_pdf(
# Export the document as PDF
try:
request_obj = service.files().export_media(
fileId=document_id,
mimeType='application/pdf',
supportsAllDrives=True
fileId=document_id, mimeType="application/pdf", supportsAllDrives=True
)
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request_obj)
done = False
while not done:
_, done = await asyncio.to_thread(downloader.next_chunk)
pdf_content = fh.getvalue()
pdf_size = len(pdf_content)
except Exception as e:
return f"Error: Failed to export document to PDF: {str(e)}"
# Determine PDF filename
if not pdf_filename:
pdf_filename = f"{original_name}_PDF.pdf"
elif not pdf_filename.endswith('.pdf'):
pdf_filename += '.pdf'
elif not pdf_filename.endswith(".pdf"):
pdf_filename += ".pdf"
# Upload PDF to Drive
try:
# Reuse the existing BytesIO object by resetting to the beginning
fh.seek(0)
# Create media upload object
media = MediaIoBaseUpload(
fh,
mimetype='application/pdf',
resumable=True
)
media = MediaIoBaseUpload(fh, mimetype="application/pdf", resumable=True)
# Prepare file metadata for upload
file_metadata = {
'name': pdf_filename,
'mimeType': 'application/pdf'
}
file_metadata = {"name": pdf_filename, "mimeType": "application/pdf"}
# Add parent folder if specified
if folder_id:
file_metadata['parents'] = [folder_id]
file_metadata["parents"] = [folder_id]
# Upload the file
uploaded_file = await asyncio.to_thread(
service.files().create(
service.files()
.create(
body=file_metadata,
media_body=media,
fields='id, name, webViewLink, parents',
supportsAllDrives=True
).execute
fields="id, name, webViewLink, parents",
supportsAllDrives=True,
)
.execute
)
pdf_file_id = uploaded_file.get('id')
pdf_web_link = uploaded_file.get('webViewLink', '#')
pdf_parents = uploaded_file.get('parents', [])
logger.info(f"[export_doc_to_pdf] Successfully uploaded PDF to Drive: {pdf_file_id}")
pdf_file_id = uploaded_file.get("id")
pdf_web_link = uploaded_file.get("webViewLink", "#")
pdf_parents = uploaded_file.get("parents", [])
logger.info(
f"[export_doc_to_pdf] Successfully uploaded PDF to Drive: {pdf_file_id}"
)
folder_info = ""
if folder_id:
folder_info = f" in folder {folder_id}"
elif pdf_parents:
folder_info = f" in folder {pdf_parents[0]}"
return f"Successfully exported '{original_name}' to PDF and saved to Drive as '{pdf_filename}' (ID: {pdf_file_id}, {pdf_size:,} bytes){folder_info}. PDF: {pdf_web_link} | Original: {web_view_link}"
except Exception as e:
return f"Error: Failed to upload PDF to Drive: {str(e)}. PDF was generated successfully ({pdf_size:,} bytes) but could not be saved to Drive."
@@ -1211,7 +1332,7 @@ async def export_doc_to_pdf(
_comment_tools = create_comment_tools("document", "document_id")
# Extract and register the functions
read_doc_comments = _comment_tools['read_comments']
create_doc_comment = _comment_tools['create_comment']
reply_to_comment = _comment_tools['reply_to_comment']
resolve_comment = _comment_tools['resolve_comment']
read_doc_comments = _comment_tools["read_comments"]
create_doc_comment = _comment_tools["create_comment"]
reply_to_comment = _comment_tools["reply_to_comment"]
resolve_comment = _comment_tools["resolve_comment"]