apply ruff formatting
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user