Download google-workspace/upstream/source/gdocs/docs_markdown.py from smlflg/Connector: direct link, hf CLI and curl.
- Browser
- Download file 21.6 kB
-
https://huggingface.co/smlflg/Connector/resolve/main/google-workspace/upstream/source/gdocs/docs_markdown.py
- Command line
-
hf download hf://smlflg/Connector/google-workspace/upstream/source/gdocs/docs_markdown.py
-
curl -L -o docs_markdown.py https://huggingface.co/smlflg/Connector/resolve/main/google-workspace/upstream/source/gdocs/docs_markdown.py
21.6 kB
| """ | |
| Google Docs to Markdown Converter | |
| Converts Google Docs API JSON responses to clean Markdown, preserving: | |
| - Headings (H1-H6, Title, Subtitle) | |
| - Bold, italic, strikethrough, code, links | |
| - Ordered and unordered lists with nesting | |
| - Checklists with checked/unchecked state | |
| - Tables with header row separators | |
| - Smart chips: person (@mentions), rich links, dates, inline images, | |
| footnotes, horizontal rules, auto-text (page numbers), equations | |
| - Document tabs (multi-tab and nested child tabs) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from datetime import datetime | |
| from typing import Any | |
| logger = logging.getLogger(__name__) | |
| MONO_FONTS = {"Courier New", "Consolas", "Roboto Mono", "Source Code Pro"} | |
| HEADING_MAP = { | |
| "TITLE": "#", | |
| "SUBTITLE": "##", | |
| "HEADING_1": "#", | |
| "HEADING_2": "##", | |
| "HEADING_3": "###", | |
| "HEADING_4": "####", | |
| "HEADING_5": "#####", | |
| "HEADING_6": "######", | |
| } | |
| def convert_doc_to_markdown(doc: dict[str, Any]) -> str: | |
| """Convert a Google Docs API document response to markdown. | |
| Supports both legacy (top-level body) and tab-aware responses | |
| (includeTabsContent=True). For multi-tab docs, each tab gets a | |
| heading separator. Single-tab docs render without a tab heading. | |
| Args: | |
| doc: The document JSON from docs.documents.get() | |
| Returns: | |
| Markdown string | |
| """ | |
| tabs = doc.get("tabs", []) | |
| if tabs: | |
| return _convert_tabs_to_markdown(tabs) | |
| # Legacy: no tabs, use top-level body/lists/footnotes/inlineObjects | |
| return _convert_body_to_markdown(doc) | |
| def _convert_tabs_to_markdown(tabs: list[dict[str, Any]]) -> str: | |
| """Convert a list of document tabs to markdown, recursing into child tabs.""" | |
| all_tab_docs: list[tuple[str, dict[str, Any]]] = [] | |
| _collect_tabs(tabs, all_tab_docs) | |
| if len(all_tab_docs) == 1: | |
| _, tab_doc = all_tab_docs[0] | |
| return _convert_body_to_markdown(tab_doc) | |
| sections: list[str] = [] | |
| for title, tab_doc in all_tab_docs: | |
| tab_md = _convert_body_to_markdown(tab_doc) | |
| sections.append(f"# {title}\n\n{tab_md}") | |
| return "\n".join(sections).rstrip("\n") + "\n" | |
| def _collect_tabs( | |
| tabs: list[dict[str, Any]], | |
| result: list[tuple[str, dict[str, Any]]], | |
| ) -> None: | |
| """Flatten tab hierarchy into (title, documentTab) pairs.""" | |
| for tab in tabs: | |
| props = tab.get("tabProperties", {}) | |
| title = props.get("title", "Untitled Tab") | |
| doc_tab = tab.get("documentTab", {}) | |
| if doc_tab: | |
| result.append((title, doc_tab)) | |
| for child in tab.get("childTabs", []): | |
| _collect_tabs([child], result) | |
| def _convert_body_to_markdown(doc: dict[str, Any]) -> str: | |
| """Convert a single document body (or documentTab) to markdown.""" | |
| body = doc.get("body", {}) | |
| content = body.get("content", []) | |
| lists_meta = doc.get("lists", {}) | |
| footnotes_meta = doc.get("footnotes", {}) | |
| inline_objects = doc.get("inlineObjects", {}) | |
| lines: list[str] = [] | |
| ordered_counters: dict[tuple[str, int], int] = {} | |
| prev_was_list = False | |
| footnote_defs: list[tuple[str, str]] = [] | |
| for element in content: | |
| if "paragraph" in element: | |
| para = element["paragraph"] | |
| text = _convert_paragraph_text( | |
| para, | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| ) | |
| if not text.strip(): | |
| if prev_was_list: | |
| prev_was_list = False | |
| continue | |
| bullet = para.get("bullet") | |
| if bullet: | |
| list_id = bullet["listId"] | |
| nesting = bullet.get("nestingLevel", 0) | |
| if _is_checklist(lists_meta, list_id, nesting): | |
| checked = _is_checked(para) | |
| checkbox = "[x]" if checked else "[ ]" | |
| indent = " " * nesting | |
| cb_text = ( | |
| _convert_paragraph_text( | |
| para, | |
| skip_strikethrough=True, | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| ) | |
| if checked | |
| else text | |
| ) | |
| lines.append(f"{indent}- {checkbox} {cb_text}") | |
| elif _is_ordered_list(lists_meta, list_id, nesting): | |
| key = (list_id, nesting) | |
| ordered_counters[key] = ordered_counters.get(key, 0) + 1 | |
| counter = ordered_counters[key] | |
| indent = " " * nesting | |
| lines.append(f"{indent}{counter}. {text}") | |
| else: | |
| indent = " " * nesting | |
| lines.append(f"{indent}- {text}") | |
| prev_was_list = True | |
| else: | |
| if prev_was_list: | |
| ordered_counters.clear() | |
| lines.append("") | |
| prev_was_list = False | |
| style = para.get("paragraphStyle", {}) | |
| named_style = style.get("namedStyleType", "NORMAL_TEXT") | |
| prefix = HEADING_MAP.get(named_style, "") | |
| if prefix: | |
| lines.append(f"{prefix} {text}") | |
| lines.append("") | |
| else: | |
| lines.append(text) | |
| lines.append("") | |
| elif "table" in element: | |
| if prev_was_list: | |
| ordered_counters.clear() | |
| lines.append("") | |
| prev_was_list = False | |
| table_md = _convert_table( | |
| element["table"], | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| ) | |
| lines.append(table_md) | |
| lines.append("") | |
| if footnote_defs: | |
| lines.append("") | |
| for fn_id, fn_text in footnote_defs: | |
| lines.append(f"[^{fn_id}]: {fn_text}") | |
| result = "\n".join(lines).rstrip("\n") + "\n" | |
| return result | |
| def _convert_paragraph_text( | |
| para: dict[str, Any], | |
| skip_strikethrough: bool = False, | |
| footnotes_meta: dict[str, Any] | None = None, | |
| inline_objects: dict[str, Any] | None = None, | |
| footnote_defs: list[tuple[str, str]] | None = None, | |
| active_footnotes: set[str] | None = None, | |
| ) -> str: | |
| """Convert paragraph elements to inline markdown text.""" | |
| parts: list[str] = [] | |
| for elem in para.get("elements", []): | |
| if "textRun" in elem: | |
| parts.append(_convert_text_run(elem["textRun"], skip_strikethrough)) | |
| elif "person" in elem: | |
| parts.append(_convert_person_chip(elem["person"])) | |
| elif "richLink" in elem: | |
| parts.append(_convert_rich_link_chip(elem["richLink"])) | |
| elif "dateElement" in elem: | |
| parts.append(_convert_date_chip(elem["dateElement"])) | |
| elif "inlineObjectElement" in elem: | |
| parts.append( | |
| _convert_inline_object(elem["inlineObjectElement"], inline_objects) | |
| ) | |
| elif "footnoteReference" in elem: | |
| parts.append( | |
| _convert_footnote_reference( | |
| elem["footnoteReference"], | |
| footnotes_meta, | |
| inline_objects, | |
| footnote_defs, | |
| active_footnotes, | |
| ) | |
| ) | |
| elif "horizontalRule" in elem: | |
| parts.append("\n---\n") | |
| elif "autoText" in elem: | |
| parts.append(_convert_auto_text(elem["autoText"])) | |
| elif "pageBreak" in elem or "columnBreak" in elem: | |
| pass # No meaningful markdown representation | |
| elif "equation" in elem: | |
| parts.append(_convert_equation(elem["equation"])) | |
| return "".join(parts).strip() | |
| def _convert_text_run( | |
| text_run: dict[str, Any], skip_strikethrough: bool = False | |
| ) -> str: | |
| """Convert a single text run to markdown.""" | |
| content = text_run.get("content", "") | |
| style = text_run.get("textStyle", {}) | |
| text = content.rstrip("\n") | |
| # Replace Google Docs Private Use Area chip placeholders (e.g. \ue907) | |
| # that appear for unsupported chip types like vote, stopwatch, timer | |
| text = text.replace("\ue907", "[Smart Chip]") | |
| if not text: | |
| return "" | |
| return _apply_text_style(text, style, skip_strikethrough) | |
| def _convert_person_chip(person: dict[str, Any]) -> str: | |
| """Convert a person smart chip to a mailto link.""" | |
| props = person.get("personProperties", {}) | |
| name = props.get("name", "") | |
| email = props.get("email", "") | |
| if email: | |
| label = name or email | |
| return f"[{label}](mailto:{email})" | |
| return name or "" | |
| def _convert_rich_link_chip(rich_link: dict[str, Any]) -> str: | |
| """Convert a rich link smart chip to markdown. | |
| Rich link chips contain a richLinkProperties dict with title and uri. | |
| """ | |
| props = rich_link.get("richLinkProperties", {}) | |
| title = props.get("title", "") | |
| uri = props.get("uri", "") | |
| if title and uri: | |
| return f"[{title}]({uri})" | |
| if uri: | |
| return uri | |
| return title or "" | |
| def _convert_date_chip(date_elem: dict[str, Any]) -> str: | |
| """Convert a date smart chip (dateElement) to markdown. | |
| Date elements contain dateElementProperties with displayText (the | |
| locale-formatted string) and a timestamp. | |
| """ | |
| props = date_elem.get("dateElementProperties", {}) | |
| display_text = props.get("displayText", "") | |
| if display_text: | |
| return display_text | |
| # Fallback: parse the timestamp into a date string | |
| timestamp = props.get("timestamp", "") | |
| if timestamp: | |
| try: | |
| dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) | |
| return dt.strftime("%Y-%m-%d") | |
| except (ValueError, TypeError): | |
| return timestamp | |
| return "" | |
| def _convert_inline_object( | |
| element: dict[str, Any], | |
| inline_objects: dict[str, Any] | None, | |
| ) -> str: | |
| """Convert an inline object (image/drawing) to markdown.""" | |
| obj_id = element.get("inlineObjectId", "") | |
| if not inline_objects or obj_id not in inline_objects: | |
| return "" | |
| obj = inline_objects[obj_id] | |
| props = obj.get("inlineObjectProperties", {}).get("embeddedObject", {}) | |
| title = props.get("title", "") or props.get("description", "") | |
| uri = props.get("imageProperties", {}).get("contentUri", "") | |
| if uri: | |
| return f"" | |
| return f"[Image: {title}]" if title else "[Image]" | |
| def _convert_footnote_reference( | |
| ref: dict[str, Any], | |
| footnotes_meta: dict[str, Any] | None, | |
| inline_objects: dict[str, Any] | None, | |
| footnote_defs: list[tuple[str, str]] | None, | |
| active_footnotes: set[str] | None = None, | |
| ) -> str: | |
| """Convert a footnote reference to a markdown footnote marker. | |
| Also collects the footnote definition text for appending at the end. | |
| """ | |
| fn_id = ref.get("footnoteId", "") | |
| if not fn_id: | |
| return "" | |
| if footnotes_meta and footnote_defs is not None and fn_id in footnotes_meta: | |
| # Avoid duplicates if the same footnote is referenced multiple times. | |
| existing_ids = {fid for fid, _ in footnote_defs} | |
| if fn_id not in existing_ids: | |
| next_active = set(active_footnotes or ()) | |
| if fn_id not in next_active: | |
| next_active.add(fn_id) | |
| fn_content = footnotes_meta[fn_id].get("content", []) | |
| fn_text = _convert_footnote_content( | |
| fn_content, | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| active_footnotes=next_active, | |
| ) | |
| footnote_defs.append((fn_id, fn_text)) | |
| return f"[^{fn_id}]" | |
| def _convert_footnote_content( | |
| content: list[dict[str, Any]], | |
| *, | |
| footnotes_meta: dict[str, Any] | None, | |
| inline_objects: dict[str, Any] | None, | |
| footnote_defs: list[tuple[str, str]] | None, | |
| active_footnotes: set[str] | None = None, | |
| ) -> str: | |
| """Convert footnote content with the same inline handling as body paragraphs.""" | |
| parts: list[str] = [] | |
| for element in content: | |
| if "paragraph" in element: | |
| text = _convert_paragraph_text( | |
| element["paragraph"], | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| active_footnotes=active_footnotes, | |
| ) | |
| if text.strip(): | |
| parts.append(text.strip()) | |
| elif "table" in element: | |
| table_text = _convert_table( | |
| element["table"], | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| active_footnotes=active_footnotes, | |
| ) | |
| if table_text.strip(): | |
| parts.append(table_text.replace("\n", " ")) | |
| return " ".join(parts) | |
| def _convert_auto_text(auto_text: dict[str, Any]) -> str: | |
| """Convert an autoText element (e.g. page number) to a placeholder.""" | |
| text_type = auto_text.get("type", "") | |
| if text_type == "PAGE_NUMBER": | |
| return "[Page #]" | |
| if text_type == "PAGE_COUNT": | |
| return "[Page Count]" | |
| return "" | |
| def _convert_equation(equation: dict[str, Any]) -> str: | |
| """Convert an equation element to markdown.""" | |
| # The Docs API does not expose equation content as text — only the | |
| # suggestedInsertionIds / suggestedDeletionIds are available. | |
| return "[Equation]" | |
| def _apply_text_style( | |
| text: str, style: dict[str, Any], skip_strikethrough: bool = False | |
| ) -> str: | |
| """Apply markdown formatting based on text style.""" | |
| link = style.get("link", {}) | |
| url = link.get("url") | |
| font_family = style.get("weightedFontFamily", {}).get("fontFamily", "") | |
| if font_family in MONO_FONTS: | |
| return f"`{text}`" | |
| bold = style.get("bold", False) | |
| italic = style.get("italic", False) | |
| strikethrough = style.get("strikethrough", False) | |
| if bold and italic: | |
| text = f"***{text}***" | |
| elif bold: | |
| text = f"**{text}**" | |
| elif italic: | |
| text = f"*{text}*" | |
| if strikethrough and not skip_strikethrough: | |
| text = f"~~{text}~~" | |
| if url: | |
| text = f"[{text}]({url})" | |
| return text | |
| def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool: | |
| """Check if a list at a given nesting level is ordered.""" | |
| list_info = lists_meta.get(list_id, {}) | |
| nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", []) | |
| if nesting < len(nesting_levels): | |
| level = nesting_levels[nesting] | |
| glyph = level.get("glyphType", "") | |
| return glyph not in ("", "GLYPH_TYPE_UNSPECIFIED") | |
| return False | |
| def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool: | |
| """Check if a list at a given nesting level is a checklist. | |
| Google Docs checklists are distinguished from regular bullet lists by having | |
| GLYPH_TYPE_UNSPECIFIED with no glyphSymbol — the Docs UI renders interactive | |
| checkboxes rather than a static glyph character. | |
| """ | |
| list_info = lists_meta.get(list_id, {}) | |
| nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", []) | |
| if nesting < len(nesting_levels): | |
| level = nesting_levels[nesting] | |
| glyph_type = level.get("glyphType", "") | |
| has_glyph_symbol = "glyphSymbol" in level | |
| return glyph_type in ("", "GLYPH_TYPE_UNSPECIFIED") and not has_glyph_symbol | |
| return False | |
| def _is_checked(para: dict[str, Any]) -> bool: | |
| """Check if a checklist item is checked. | |
| Google Docs marks checked checklist items by applying strikethrough | |
| formatting to the paragraph text. | |
| """ | |
| for elem in para.get("elements", []): | |
| if "textRun" in elem: | |
| content = elem["textRun"].get("content", "").strip() | |
| if content: | |
| return elem["textRun"].get("textStyle", {}).get("strikethrough", False) | |
| return False | |
| def _convert_table( | |
| table: dict[str, Any], | |
| *, | |
| footnotes_meta: dict[str, Any] | None = None, | |
| inline_objects: dict[str, Any] | None = None, | |
| footnote_defs: list[tuple[str, str]] | None = None, | |
| active_footnotes: set[str] | None = None, | |
| ) -> str: | |
| """Convert a table element to markdown.""" | |
| rows = table.get("tableRows", []) | |
| if not rows: | |
| return "" | |
| md_rows: list[str] = [] | |
| for i, row in enumerate(rows): | |
| cells: list[str] = [] | |
| for cell in row.get("tableCells", []): | |
| cell_text = _extract_cell_text( | |
| cell, | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| active_footnotes=active_footnotes, | |
| ) | |
| cells.append(cell_text) | |
| md_rows.append("| " + " | ".join(cells) + " |") | |
| if i == 0: | |
| sep = "| " + " | ".join("---" for _ in cells) + " |" | |
| md_rows.append(sep) | |
| return "\n".join(md_rows) | |
| def _extract_cell_text( | |
| cell: dict[str, Any], | |
| *, | |
| footnotes_meta: dict[str, Any] | None = None, | |
| inline_objects: dict[str, Any] | None = None, | |
| footnote_defs: list[tuple[str, str]] | None = None, | |
| active_footnotes: set[str] | None = None, | |
| ) -> str: | |
| """Extract text from a table cell.""" | |
| parts: list[str] = [] | |
| for content_elem in cell.get("content", []): | |
| if "paragraph" in content_elem: | |
| text = _convert_paragraph_text( | |
| content_elem["paragraph"], | |
| footnotes_meta=footnotes_meta, | |
| inline_objects=inline_objects, | |
| footnote_defs=footnote_defs, | |
| active_footnotes=active_footnotes, | |
| ) | |
| if text.strip(): | |
| parts.append(text.strip()) | |
| cell_text = " ".join(parts) | |
| return cell_text.replace("|", "\\|") | |
| def format_comments_inline(markdown: str, comments: list[dict[str, Any]]) -> str: | |
| """Insert footnote-style comment annotations inline in markdown. | |
| For each comment, finds the anchor text in the markdown and inserts | |
| a footnote reference. Unmatched comments go to an appendix at the bottom. | |
| """ | |
| if not comments: | |
| return markdown | |
| footnotes: list[str] = [] | |
| unmatched: list[dict[str, Any]] = [] | |
| for i, comment in enumerate(comments, 1): | |
| ref = f"[^c{i}]" | |
| anchor = comment.get("anchor_text", "") | |
| if anchor and anchor in markdown: | |
| markdown = markdown.replace(anchor, anchor + ref, 1) | |
| footnotes.append(_format_footnote(i, comment)) | |
| else: | |
| unmatched.append(comment) | |
| if footnotes: | |
| markdown = markdown.rstrip("\n") + "\n\n" + "\n".join(footnotes) + "\n" | |
| if unmatched: | |
| appendix = format_comments_appendix(unmatched) | |
| if appendix.strip(): | |
| markdown = markdown.rstrip("\n") + "\n\n" + appendix | |
| return markdown | |
| def _format_footnote(num: int, comment: dict[str, Any]) -> str: | |
| """Format a single footnote.""" | |
| lines = [f"[^c{num}]: **{comment['author']}**: {comment['content']}"] | |
| for reply in comment.get("replies", []): | |
| lines.append(f" - **{reply['author']}**: {reply['content']}") | |
| return "\n".join(lines) | |
| def format_comments_appendix(comments: list[dict[str, Any]]) -> str: | |
| """Format comments as an appendix section with blockquoted anchors.""" | |
| if not comments: | |
| return "" | |
| lines = ["## Comments", ""] | |
| for comment in comments: | |
| resolved_tag = " *(Resolved)*" if comment.get("resolved") else "" | |
| anchor = comment.get("anchor_text", "") | |
| if anchor: | |
| lines.append(f"> {anchor}") | |
| lines.append("") | |
| lines.append(f"- **{comment['author']}**: {comment['content']}{resolved_tag}") | |
| for reply in comment.get("replies", []): | |
| lines.append(f" - **{reply['author']}**: {reply['content']}") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def parse_drive_comments( | |
| response: dict[str, Any], include_resolved: bool = False | |
| ) -> list[dict[str, Any]]: | |
| """Parse Drive API comments response into structured dicts. | |
| Args: | |
| response: Raw JSON from drive.comments.list() | |
| include_resolved: Whether to include resolved comments | |
| Returns: | |
| List of comment dicts with keys: author, content, anchor_text, | |
| replies, resolved | |
| """ | |
| results = [] | |
| for comment in response.get("comments", []): | |
| if not include_resolved and comment.get("resolved", False): | |
| continue | |
| anchor_text = comment.get("quotedFileContent", {}).get("value", "") | |
| replies = [ | |
| { | |
| "author": r.get("author", {}).get("displayName", "Unknown"), | |
| "content": r.get("content", ""), | |
| } | |
| for r in comment.get("replies", []) | |
| ] | |
| results.append( | |
| { | |
| "author": comment.get("author", {}).get("displayName", "Unknown"), | |
| "content": comment.get("content", ""), | |
| "anchor_text": anchor_text, | |
| "replies": replies, | |
| "resolved": comment.get("resolved", False), | |
| } | |
| ) | |
| return results | |