Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 256 additions & 10 deletions gdocs/docs_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
- 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__)
Expand All @@ -33,24 +37,80 @@
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)
if tab_md.strip():
sections.append(f"# {title}\n\n{tab_md}")

return "\n".join(sections).rstrip("\n") + "\n"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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)
text = _convert_paragraph_text(
para,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if not text.strip():
if prev_was_list:
Expand All @@ -66,10 +126,14 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
checked = _is_checked(para)
checkbox = "[x]" if checked else "[ ]"
indent = " " * nesting
# Re-render text without strikethrough for checked items
# to avoid redundant ~~text~~ alongside [x]
cb_text = (
_convert_paragraph_text(para, skip_strikethrough=True)
_convert_paragraph_text(
para,
skip_strikethrough=True,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
if checked
else text
)
Expand Down Expand Up @@ -106,22 +170,60 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
ordered_counters.clear()
lines.append("")
prev_was_list = False
table_md = _convert_table(element["table"])
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
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,
) -> 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, footnote_defs
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
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()


Expand All @@ -133,12 +235,134 @@ def _convert_text_run(
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 ""
Comment on lines +251 to +259

@coderabbitai coderabbitai Bot Apr 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not expand person chips into raw email addresses by default.

This now embeds the underlying email in every person chip via mailto:, even when the document only rendered the display name. That leaks extra PII into the Markdown response and the LLM context.

🛡️ Proposed safe default
 def _convert_person_chip(person: dict[str, Any]) -> str:
-    """Convert a person smart chip to a mailto link."""
+    """Convert a person smart chip to display text."""
     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 ""
+    return name or email or ""

As per coding guidelines, "Never log or leak secrets, refresh tokens, or PII in exceptions or event streams" and "Tool response payloads should be concise; redact verbose HTML/email bodies by default and offer links or IDs to fetch full content."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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_person_chip(person: dict[str, Any]) -> str:
"""Convert a person smart chip to display text."""
props = person.get("personProperties", {})
name = props.get("name", "")
email = props.get("email", "")
return name or email or ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_markdown.py` around lines 242 - 250, The function
_convert_person_chip currently expands person smart chips into mailto: links,
leaking email PII; change it to only return the display name (props["name"] or a
redacted placeholder) by default and avoid embedding the email or mailto: URI;
if there is a legitimate need to expose the email make it opt-in via an explicit
parameter (e.g., include_email: bool) to _convert_person_chip or a separate
helper, and update call sites to pass that flag where safe. Ensure the function
uses props.get("name", "") as the primary output and never uses
props.get("email") in the returned string unless the explicit opt-in flag is
true.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad feedback, it should read the contents as they are.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@taylorwilsdon, understood — rendering the full contents of person chips (including the email as a mailto: link) faithfully reflects what the document contains. I'll defer to your judgment here.

(´・ω・`)


🧠 Learnings used
Learnt from: hGriff0n
Repo: taylorwilsdon/google_workspace_mcp PR: 499
File: gkeep/keep_tools.py:417-425
Timestamp: 2026-02-21T18:00:37.834Z
Learning: When using asyncio.to_thread(callable, *args, **kwargs), pass a callable, not the result of a call. For methods on objects, pass the method reference (e.g., await asyncio.to_thread(service.some_method(...).execute)) rather than invoking it upfront (service.some_method(...).execute()). This ensures a callable is provided to to_thread, which will run it in a separate thread. This guideline applies broadly to all Python files in the Google Workspace MCP codebase (and similar Python projects); enforce by scanning for instances where a method is called immediately inside to_thread instead of passing the bound method/reference.



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"![{title}]({uri})"
return f"[Image: {title}]" if title else "[Image]"


def _convert_footnote_reference(
ref: dict[str, Any],
footnotes_meta: dict[str, Any] | None,
footnote_defs: list[tuple[str, str]] | 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:
fn_content = footnotes_meta[fn_id].get("content", [])
fn_text = _extract_footnote_text(fn_content)
# 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:
footnote_defs.append((fn_id, fn_text))
return f"[^{fn_id}]"


def _extract_footnote_text(content: list[dict[str, Any]]) -> str:
"""Extract plain text from footnote content elements."""
parts: list[str] = []
for element in content:
if "paragraph" in element:
for elem in element["paragraph"].get("elements", []):
if "textRun" in elem:
text = elem["textRun"].get("content", "").strip()
if text:
parts.append(text)
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:
Expand Down Expand Up @@ -212,7 +436,13 @@ def _is_checked(para: dict[str, Any]) -> bool:
return False


def _convert_table(table: dict[str, Any]) -> str:
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,
) -> str:
"""Convert a table element to markdown."""
rows = table.get("tableRows", [])
if not rows:
Expand All @@ -222,7 +452,12 @@ def _convert_table(table: dict[str, Any]) -> str:
for i, row in enumerate(rows):
cells: list[str] = []
for cell in row.get("tableCells", []):
cell_text = _extract_cell_text(cell)
cell_text = _extract_cell_text(
cell,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
cells.append(cell_text)
md_rows.append("| " + " | ".join(cells) + " |")

Expand All @@ -233,12 +468,23 @@ def _convert_table(table: dict[str, Any]) -> str:
return "\n".join(md_rows)


def _extract_cell_text(cell: dict[str, Any]) -> str:
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,
) -> 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"])
text = _convert_paragraph_text(
content_elem["paragraph"],
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
if text.strip():
parts.append(text.strip())
cell_text = " ".join(parts)
Expand Down
Loading
Loading