-
Notifications
You must be signed in to change notification settings - Fork 264
Add markdown export tool for google docs #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
diunko
wants to merge
2
commits into
taylorwilsdon:main
Choose a base branch
from
diunko:add-markdown
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||
| import io | ||||||||||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| from googleapiclient.http import MediaIoBaseDownload | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -47,6 +48,71 @@ | |||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def _replace_base64_images_with_placeholders(markdown_content: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||
| Replace base64 encoded images with readable placeholders. | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Converts: | ||||||||||||||||||||||||||||||||||||||||||||||||
| [image1]: <data:image/png;base64,iVBORw0KGgoAAAANS...> | ||||||||||||||||||||||||||||||||||||||||||||||||
| To: | ||||||||||||||||||||||||||||||||||||||||||||||||
| [image1]: [PNG image, ~2.5MB] | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Also handles inline images: | ||||||||||||||||||||||||||||||||||||||||||||||||
|  | ||||||||||||||||||||||||||||||||||||||||||||||||
| To: | ||||||||||||||||||||||||||||||||||||||||||||||||
|  | ||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def calculate_size(base64_str: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||
| """Calculate approximate size of base64 encoded data.""" | ||||||||||||||||||||||||||||||||||||||||||||||||
| # Base64 encoding increases size by ~33%, so reverse that | ||||||||||||||||||||||||||||||||||||||||||||||||
| # Each base64 char represents 6 bits, so 4 chars = 3 bytes | ||||||||||||||||||||||||||||||||||||||||||||||||
| num_chars = len(base64_str) | ||||||||||||||||||||||||||||||||||||||||||||||||
| size_bytes = (num_chars * 3) // 4 | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if size_bytes < 1024: | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"{size_bytes}B" | ||||||||||||||||||||||||||||||||||||||||||||||||
| elif size_bytes < 1024 * 1024: | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"{size_bytes / 1024:.1f}KB" | ||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"{size_bytes / (1024 * 1024):.1f}MB" | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def replace_reference_image(match): | ||||||||||||||||||||||||||||||||||||||||||||||||
| """Replace reference-style image with placeholder.""" | ||||||||||||||||||||||||||||||||||||||||||||||||
| image_ref = match.group(1) | ||||||||||||||||||||||||||||||||||||||||||||||||
| image_type = match.group(2) | ||||||||||||||||||||||||||||||||||||||||||||||||
| base64_data = match.group(3) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| size = calculate_size(base64_data) | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"[{image_ref}]: [{image_type.upper()} image, ~{size}]" | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| def replace_inline_image(match): | ||||||||||||||||||||||||||||||||||||||||||||||||
| """Replace inline image with placeholder.""" | ||||||||||||||||||||||||||||||||||||||||||||||||
| alt_text = match.group(1) | ||||||||||||||||||||||||||||||||||||||||||||||||
| image_type = match.group(2) | ||||||||||||||||||||||||||||||||||||||||||||||||
| base64_data = match.group(3) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| size = calculate_size(base64_data) | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"} image, ~{size}])" | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Pattern for reference-style images: [image1]: <data:image/png;base64,...> | ||||||||||||||||||||||||||||||||||||||||||||||||
| reference_pattern = r'\[([^\]]+)\]:\s*<data:image/([^;]+);base64,([^>]+)>' | ||||||||||||||||||||||||||||||||||||||||||||||||
| markdown_content = re.sub(reference_pattern, replace_reference_image, markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Pattern for inline images:  | ||||||||||||||||||||||||||||||||||||||||||||||||
| inline_pattern = r'!\[([^\]]*)\]\(data:image/([^;]+);base64,([^)]+)\)' | ||||||||||||||||||||||||||||||||||||||||||||||||
| markdown_content = re.sub(inline_pattern, replace_inline_image, markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Also handle images without angle brackets (some variations) | ||||||||||||||||||||||||||||||||||||||||||||||||
| reference_pattern_no_brackets = r'\[([^\]]+)\]:\s*data:image/([^;]+);base64,(\S+)' | ||||||||||||||||||||||||||||||||||||||||||||||||
| markdown_content = re.sub(reference_pattern_no_brackets, replace_reference_image, markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Log if we found and replaced images | ||||||||||||||||||||||||||||||||||||||||||||||||
| if 'data:image' in markdown_content: | ||||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning("Found remaining base64 images that couldn't be processed") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| return markdown_content | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @server.tool() | ||||||||||||||||||||||||||||||||||||||||||||||||
| @handle_http_errors("search_docs", is_read_only=True, service_type="docs") | ||||||||||||||||||||||||||||||||||||||||||||||||
| @require_google_service("drive", "drive_read") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -243,6 +309,110 @@ def process_tab_hierarchy(tab, level=0): | |||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
| return header + body_text | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @server.tool() | ||||||||||||||||||||||||||||||||||||||||||||||||
| @handle_http_errors("get_doc_markdown", is_read_only=True, service_type="docs") | ||||||||||||||||||||||||||||||||||||||||||||||||
| @require_google_service("drive", "drive_read") | ||||||||||||||||||||||||||||||||||||||||||||||||
| async def get_doc_markdown( | ||||||||||||||||||||||||||||||||||||||||||||||||
| service, | ||||||||||||||||||||||||||||||||||||||||||||||||
| user_google_email: str, | ||||||||||||||||||||||||||||||||||||||||||||||||
| document_id: str, | ||||||||||||||||||||||||||||||||||||||||||||||||
| include_metadata: bool = True, | ||||||||||||||||||||||||||||||||||||||||||||||||
| process_images: bool = True, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||
| Retrieves a Google Doc in Markdown format, preserving formatting like headers, | ||||||||||||||||||||||||||||||||||||||||||||||||
| bold, italic, links, lists, and tables. | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||
| user_google_email (str): The user's Google email address. Required. | ||||||||||||||||||||||||||||||||||||||||||||||||
| document_id (str): The ID of the Google Doc to export. Required. | ||||||||||||||||||||||||||||||||||||||||||||||||
| include_metadata (bool): Whether to include document metadata header. Defaults to True. | ||||||||||||||||||||||||||||||||||||||||||||||||
| process_images (bool): Whether to replace base64 images with placeholders. Defaults to True. | ||||||||||||||||||||||||||||||||||||||||||||||||
| If True, replaces large base64 encoded images with readable placeholders like [PNG image, ~2.5MB]. | ||||||||||||||||||||||||||||||||||||||||||||||||
| If False, keeps original base64 encoded images (warning: may consume significant context). | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||
| str: The document content in Markdown format. | ||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info(f"[get_doc_markdown] Exporting document '{document_id}' as Markdown for user '{user_google_email}'") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||
| # Get file metadata first | ||||||||||||||||||||||||||||||||||||||||||||||||
| file_metadata = await asyncio.to_thread( | ||||||||||||||||||||||||||||||||||||||||||||||||
| service.files().get( | ||||||||||||||||||||||||||||||||||||||||||||||||
| fileId=document_id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| fields="id, name, mimeType, webViewLink, modifiedTime" | ||||||||||||||||||||||||||||||||||||||||||||||||
| ).execute | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| mime_type = file_metadata.get("mimeType", "") | ||||||||||||||||||||||||||||||||||||||||||||||||
| file_name = file_metadata.get("name", "Unknown Document") | ||||||||||||||||||||||||||||||||||||||||||||||||
| web_view_link = file_metadata.get("webViewLink", "#") | ||||||||||||||||||||||||||||||||||||||||||||||||
| modified_time = file_metadata.get("modifiedTime", "Unknown") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Check if it's a Google Doc | ||||||||||||||||||||||||||||||||||||||||||||||||
| if mime_type != "application/vnd.google-apps.document": | ||||||||||||||||||||||||||||||||||||||||||||||||
| return f"Error: File '{file_name}' is not a Google Doc (MIME type: {mime_type}). This tool only works with native Google Docs." | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Export as Markdown using Drive API | ||||||||||||||||||||||||||||||||||||||||||||||||
| request = service.files().export_media( | ||||||||||||||||||||||||||||||||||||||||||||||||
| fileId=document_id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| mimeType='text/markdown' # Native Markdown export | ||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Download the exported content | ||||||||||||||||||||||||||||||||||||||||||||||||
| fh = io.BytesIO() | ||||||||||||||||||||||||||||||||||||||||||||||||
| downloader = MediaIoBaseDownload(fh, request) | ||||||||||||||||||||||||||||||||||||||||||||||||
| loop = asyncio.get_event_loop() | ||||||||||||||||||||||||||||||||||||||||||||||||
| done = False | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| while not done: | ||||||||||||||||||||||||||||||||||||||||||||||||
| status, done = await loop.run_in_executor(None, downloader.next_chunk) | ||||||||||||||||||||||||||||||||||||||||||||||||
| if status: | ||||||||||||||||||||||||||||||||||||||||||||||||
| logger.debug(f"Download progress: {int(status.progress() * 100)}%") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Get the Markdown content | ||||||||||||||||||||||||||||||||||||||||||||||||
| markdown_content = fh.getvalue().decode('utf-8') | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Process base64 images and replace with placeholders (if requested) | ||||||||||||||||||||||||||||||||||||||||||||||||
| if process_images: | ||||||||||||||||||||||||||||||||||||||||||||||||
| original_size = len(markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
| markdown_content = _replace_base64_images_with_placeholders(markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
| new_size = len(markdown_content) | ||||||||||||||||||||||||||||||||||||||||||||||||
| if new_size < original_size: | ||||||||||||||||||||||||||||||||||||||||||||||||
| size_reduction_mb = (original_size - new_size) / (1024 * 1024) | ||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info(f"[get_doc_markdown] Replaced base64 images with placeholders, reduced size by ~{size_reduction_mb:.1f}MB") | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| # Prepare output | ||||||||||||||||||||||||||||||||||||||||||||||||
| if include_metadata: | ||||||||||||||||||||||||||||||||||||||||||||||||
| header = f"""--- | ||||||||||||||||||||||||||||||||||||||||||||||||
| title: {file_name} | ||||||||||||||||||||||||||||||||||||||||||||||||
| document_id: {document_id} | ||||||||||||||||||||||||||||||||||||||||||||||||
| modified: {modified_time} | ||||||||||||||||||||||||||||||||||||||||||||||||
| link: {web_view_link} | ||||||||||||||||||||||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||
| return header + markdown_content | ||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||
| return markdown_content | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||
| # If Markdown export fails, provide helpful error message | ||||||||||||||||||||||||||||||||||||||||||||||||
| if "Invalid mime type" in str(e) or "Export only supports" in str(e): | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+400
to
+402
|
||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | |
| # If Markdown export fails, provide helpful error message | |
| if "Invalid mime type" in str(e) or "Export only supports" in str(e): | |
| except HttpError as e: | |
| # If Markdown export fails, provide helpful error message | |
| error_content = None | |
| try: | |
| import json | |
| error_content = json.loads(e.content.decode("utf-8")) | |
| except Exception: | |
| error_content = {} | |
| error_reason = "" | |
| if error_content and "error" in error_content: | |
| error_message = error_content["error"].get("message", "") | |
| error_reason = error_content["error"].get("status", "") | |
| else: | |
| error_message = str(e) | |
| # Check for invalid mime type or unsupported export | |
| if ( | |
| ("Invalid mime type" in error_message) | |
| or ("Export only supports" in error_message) | |
| or (error_reason in ["FAILED_PRECONDITION", "INVALID_ARGUMENT"]) | |
| ): |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function performs three separate regex substitutions on the same content. For large documents, this could be inefficient. Consider combining the patterns into a single regex with alternation groups or using re.compile() to pre-compile the patterns if this function will be called frequently.