feat: enhance Google Docs and Drive functionality

- Add insert_doc_image_from_drive() with permission checking
- Add insert_doc_image_url() for URL-based image insertion
- Add gdrive/drive_file_permissions.py with permission utilities
- Add gdrive/drive_helpers.py with Drive utility functions
- Add create_insert_text_segment_request() helper for headers/footers
- Improve error handling to distinguish auth vs validation errors
- Maintain backward compatibility with existing functionality
- Include comprehensive error messages and user guidance

Tested successfully with real Google Workspace integration.
This commit is contained in:
Rob Sherman
2025-08-14 18:38:37 -07:00
parent 99018d32d4
commit 203005b76d
5 changed files with 449 additions and 1 deletions

View File

@@ -72,6 +72,28 @@ def create_insert_text_request(index: int, text: str) -> Dict[str, Any]:
}
}
def create_insert_text_segment_request(index: int, text: str, segment_id: str) -> Dict[str, Any]:
"""
Create an insertText request for Google Docs API with segmentId (for headers/footers).
Args:
index: Position to insert text
text: Text to insert
segment_id: Segment ID (for targeting headers/footers)
Returns:
Dictionary representing the insertText request with segmentId
"""
return {
'insertText': {
'location': {
'segmentId': segment_id,
'index': index
},
'text': text
}
}
def create_delete_range_request(start_index: int, end_index: int) -> Dict[str, Any]:
"""
Create a deleteContentRange request for Google Docs API.

View File

@@ -644,6 +644,166 @@ 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("insert_doc_image_from_drive", service_type="docs")
@require_multiple_services([
{"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"},
{"service_type": "docs", "scopes": "docs_write", "param_name": "docs_service"}
])
async def insert_doc_image_from_drive(
drive_service,
docs_service,
user_google_email: str,
document_id: str,
drive_file_name: str,
index: int,
width: int = None,
height: int = None,
) -> str:
"""
Searches for an image in Google Drive by name and inserts it into a Google Doc.
Checks permissions first and provides helpful error messages if the image isn't publicly shared.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
drive_file_name: Name of the image file in Google Drive (e.g., "product_roadmap_2025.png")
index: Position to insert image (0-based)
width: Image width in points (optional)
height: Image height in points (optional)
Returns:
str: Confirmation message with insertion details or error with instructions
"""
logger.info(f"[insert_doc_image_from_drive] Doc={document_id}, file={drive_file_name}, index={index}")
# Build search query for the specific file name
escaped_name = drive_file_name.replace("'", "\\'")
search_query = f"name = '{escaped_name}'"
# Search for the file in Drive with permission information
list_params = {
"q": search_query,
"pageSize": 5,
"fields": "files(id, name, mimeType, webViewLink, permissions, shared)",
"supportsAllDrives": True,
"includeItemsFromAllDrives": True,
}
search_results = await asyncio.to_thread(
drive_service.files().list(**list_params).execute
)
files = search_results.get('files', [])
if not files:
return f"❌ Error: File '{drive_file_name}' not found in Google Drive"
# Use the first matching file
file_info = files[0]
file_id = file_info.get('id')
file_name = file_info.get('name')
mime_type = file_info.get('mimeType', '')
# Check if it's an image file
if not mime_type.startswith('image/'):
logger.warning(f"File '{drive_file_name}' has MIME type '{mime_type}' which may not be an image")
# Check permissions to see if file has "anyone with link" permission
from gdrive.drive_helpers import check_public_link_permission
permissions = file_info.get('permissions', [])
has_public_link = check_public_link_permission(permissions)
if not has_public_link:
from gdrive.drive_helpers import format_public_sharing_error
return format_public_sharing_error(file_name, file_id)
# File has public access - proceed with insertion
from gdrive.drive_helpers import get_drive_image_url
image_uri = get_drive_image_url(file_id)
# Use helper function to create request
request = create_insert_image_request(index, image_uri, width, height)
requests = [request]
try:
await asyncio.to_thread(
docs_service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
)
size_info = ""
if width or height:
size_info = f" (size: {width or 'auto'}x{height or 'auto'} points)"
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"✅ Successfully inserted Drive image '{file_name}' (ID: {file_id}){size_info} at index {index} in document {document_id}. Link: {link}"
except Exception as e:
error_str = str(e)
if "publicly accessible" in error_str or "forbidden" in error_str.lower():
return f"❌ API Error: Drive image '{file_name}' access denied despite public sharing. May need propagation time or use insert_doc_image_url with: {get_drive_image_url(file_id)}"
else:
return f"❌ Error inserting image '{file_name}': {e}"
@server.tool()
@handle_http_errors("insert_doc_image_url", service_type="docs")
@require_google_service("docs", "docs_write")
async def insert_doc_image_url(
service,
user_google_email: str,
document_id: str,
image_url: str,
index: int,
width: int = None,
height: int = None,
) -> str:
"""
Inserts an image from a URL into a Google Doc.
Simplified version that only works with URLs, not Drive files.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
image_url: Public image URL (must start with http:// or https://)
index: Position to insert image (0-based)
width: Image width in points (optional)
height: Image height in points (optional)
Returns:
str: Confirmation message with insertion details
"""
logger.info(f"[insert_doc_image_url] Doc={document_id}, url={image_url}, index={index}")
# Validate URL format
if not (image_url.startswith('http://') or image_url.startswith('https://')):
return f"❌ Error: image_url must be a valid HTTP/HTTPS URL. Got: {image_url}"
# 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
if index == 0:
logger.debug("Adjusting index from 0 to 1 to avoid first section break")
index = 1
# Use helper function to create request
request = create_insert_image_request(index, image_url, width, height)
requests = [request]
await asyncio.to_thread(
service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
)
size_info = ""
if width or height:
size_info = f" (size: {width or 'auto'}x{height or 'auto'} points)"
link = f"https://docs.google.com/document/d/{document_id}/edit"
return f"✅ Successfully inserted URL image{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")