Skip to content

feat: render smart chips, tabs, and all paragraph elements in markdown - #649

Merged
taylorwilsdon merged 6 commits into
taylorwilsdon:mainfrom
georgebashi:feat/smart-chips-tabs-markdown
Apr 2, 2026
Merged

feat: render smart chips, tabs, and all paragraph elements in markdown#649
taylorwilsdon merged 6 commits into
taylorwilsdon:mainfrom
georgebashi:feat/smart-chips-tabs-markdown

Conversation

@georgebashi

@georgebashi georgebashi commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Description

The markdown converter was silently dropping smart chips (person mentions, dates, rich links, etc.), producing confusing output with missing content. This adds support for all 11 ParagraphElement types from the Google Docs API, document tab support, and timeout handling for large docs.

Smart chip rendering:

  • person → mailto link
  • richLink → markdown link with title
  • dateElement → display text or ISO-formatted timestamp
  • inlineObjectElement → image with alt text
  • footnoteReference → footnote marker with definitions appended
  • horizontalRule → ---
  • autoText → [Page #] / [Page Count] placeholders
  • equation → [Equation] placeholder
  • pageBreak / columnBreak → silently skipped
  • Unsupported building block chips (vote, stopwatch, timer, dropdown) → [Smart Chip] placeholder via PUA character replacement

Document tabs: Added includeTabsContent=True to the API call. Single-tab docs render without extra headings; multi-tab docs get # Tab Title separators. Recursive child tab support included.

Timeout handling: Wrapped the Docs API fetch in asyncio.wait_for with a 30s timeout, falling back to a non-tabs request for very large documents.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this change manually

42 tests total, including TestSmartChips (17 tests covering all chip types) and TestDocumentTabs (4 tests for single/multi/child/legacy tab rendering). Manually tested against a Google Doc containing all chip types.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have enabled "Allow edits from maintainers" for this pull request

Additional Notes

Also fixes a pre-existing bug: create_update_paragraph_style_request was called in docs_tools.py but never imported from docs_helpers.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-tab Google Docs now convert to Markdown with single-tab docs omitting headings and multi-tab docs producing section headers; child tabs are flattened.
    • Footnotes render as inline markers plus Markdown footnote definitions.
    • Improved inline element handling: person/rich-link/date chips, inline images, horizontal rules, page numbers, and equations.
    • Document fetch now times out after 30 seconds and returns a clear timeout message.
  • Tests

    • Extensive tests for tabs, smart chips, inline images, footnotes, and structural elements.

The markdown converter was silently dropping smart chips and only handling
textRun elements, producing confusing output with missing content. This adds
support for all 11 Google Docs ParagraphElement types:

- person → mailto link
- richLink → markdown link with title
- dateElement → display text or ISO-formatted timestamp
- inlineObjectElement → image with alt text
- footnoteReference → footnote marker with definitions appended
- horizontalRule → ---
- autoText → [Page #] / [Page Count] placeholders
- equation → [Equation] placeholder
- pageBreak / columnBreak → silently skipped
- Unsupported building block chips (vote, stopwatch, timer, dropdown, etc.)
  rendered as [Smart Chip] via PUA character replacement

Also adds document tab support (includeTabsContent=True) with timeout
fallback for large docs, and multi-tab rendering with heading separators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

convert_doc_to_markdown gained tab-aware rendering and refactored body conversion with shared footnote collection; paragraph conversion now supports smart chips, inline images, footnote references, horizontal rules, auto-text/equation placeholders, and page-break handling. get_doc_as_markdown adds a 30s fetch timeout and returns a timeout error string on timeout.

Changes

Cohort / File(s) Summary
Tab & Markdown Conversion
gdocs/docs_markdown.py
Add tab detection and routing to _convert_tabs_to_markdown/_collect_tabs or _convert_body_to_markdown; refactor paragraph/table conversion to thread shared footnote_defs/footnotes_meta/inline_objects; add handling for person/richLink/date smart chips, inline images (inlineObjectElement), footnoteReference markers (emit [^id] and collect definitions), horizontal rules, auto-text (page numbers), equations, and page-break behavior.
API Fetch Timeout
gdocs/docs_tools.py
Request includeTabsContent=True and wrap Docs API documents().get(...).execute() with asyncio.wait_for(..., 30); on timeout return explicit timeout error string instead of proceeding to conversion.
Tests: Chips, Footnotes & Tabs
tests/gdocs/test_docs_markdown.py
Add tests for person/richLink/dateElement chips, inline images via inlineObjectElement + inlineObjects, footnote references with appended definitions, horizontal rules, auto-text page numbers, equations, page-break handling, and tab scenarios (single vs multiple tabs and child-tab flattening).
Formatting-only / Minor
gcalendar/calendar_tools.py, tests/gcalendar/test_out_of_office.py
Non-functional reformatting of RFC3339 timestamp construction and minor call-site formatting changes; no behavioral changes.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant DocsTools as gdocs/docs_tools.py
    participant GoogleAPI as "Google Docs API"
    participant Converter as gdocs/docs_markdown.py

    Client->>DocsTools: get_doc_as_markdown(doc_id)
    DocsTools->>GoogleAPI: documents().get(includeTabsContent=True) (30s)
    alt fetch success
        GoogleAPI-->>DocsTools: document (may include tabs)
        DocsTools->>Converter: convert_doc_to_markdown(doc)
        Converter->>Converter: detect tabs -> _collect_tabs/_convert_tabs_to_markdown or _convert_body_to_markdown
        Converter-->>Client: markdown
    else timeout/error
        GoogleAPI-->>DocsTools: timeout/error
        DocsTools-->>Client: "Timeout — document too large or network issue"
    end
Loading
sequenceDiagram
    participant Parser
    participant ParagraphProc as _convert_paragraph_text
    participant ElementHandler
    participant FootnoteStore as "footnote_defs"
    participant Output

    Parser->>ParagraphProc: paragraph
    ParagraphProc->>ElementHandler: iterate inline elements
    alt smart chip
        ElementHandler-->>ParagraphProc: formatted text (name/link/date)
    else inline image
        ElementHandler-->>ParagraphProc: ![title](uri) or [Image: title]
    else footnoteReference
        ElementHandler-->>ParagraphProc: [^id]
        ElementHandler->>FootnoteStore: append definition
    else horizontalRule
        ElementHandler-->>ParagraphProc: ---
    else autoText/equation/pageBreak
        ElementHandler-->>ParagraphProc: [Page #] / [Equation] / skip
    end
    ParagraphProc->>Output: append paragraph
    ParagraphProc->>Output: append footnote_defs at end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibble tabs and chips with twitchy paws,

Footnotes stack like carrots, without a pause,
Images hop in, links take flight,
Timeouts wait thirty — then code turns light,
I bound away, markdown trails in my claws.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main features added: smart chips rendering, tab support, and paragraph element handling in markdown conversion.
Description check ✅ Passed The description is comprehensive, covering all required template sections with detailed explanations of changes, testing approach, and checklist completion.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@taylorwilsdon taylorwilsdon self-assigned this Apr 2, 2026
@taylorwilsdon
taylorwilsdon self-requested a review April 2, 2026 21:21
@taylorwilsdon taylorwilsdon added the enhancement New feature or request label Apr 2, 2026

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gdocs/docs_tools.py (1)

2073-2096: ⚠️ Potential issue | 🔴 Critical

Add create_update_paragraph_style_request to the imports from gdocs.docs_helpers.

Line 2073 calls create_update_paragraph_style_request(...), but it is not imported from gdocs.docs_helpers. Add it to the import block at line 24 to fix the undefined name error and the Ruff F821 failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` around lines 2073 - 2096, Import the missing function
create_update_paragraph_style_request from gdocs.docs_helpers into the module's
import block so the call in the paragraph style construction code resolves;
update the existing import statement that brings in other helpers (e.g., the
same import that currently imports functions from gdocs.docs_helpers) to include
create_update_paragraph_style_request so the name is defined when used in the
block that builds paragraph_style_request and appended to requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gdocs/docs_markdown.py`:
- Around line 92-113: _top-level metadata (footnotes_meta, inline_objects,
footnote_defs) are only passed to _convert_paragraph_text in
_convert_body_to_markdown, but _convert_table() → _extract_cell_text() still
calls _convert_paragraph_text without them, breaking
inlineObjectElement/footnoteReference in table cells; update _convert_table and
_extract_cell_text signatures to accept footnotes_meta, inline_objects, and
footnote_defs (preserve footnote_defs as the same mutable list) and thread those
arguments through when calling _convert_paragraph_text (and any other internal
paragraph conversion calls) so table cell conversion receives the same
document-scoped metadata as top-level paragraphs.
- Around line 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.

In `@gdocs/docs_tools.py`:
- Around line 2239-2274: The get_doc_as_markdown flow currently does two
sequential asyncio.wait_for(..., timeout=30) calls around
docs_service.documents().get(...).execute which can exceed the 30s request
budget and leaves the blocking thread running while silently dropping tabs
(convert_doc_to_markdown loses secondary tabs). Replace the retry-within-request
approach: perform a single await asyncio.wait_for(..., timeout=30) for the
full-request including includeTabsContent=True; if it times out, immediately
return a partial result (or a response that explicitly flags missing tabs) and
schedule a background fetch via
asyncio.create_task(asyncio.to_thread(...execute)) to retrieve tabs later (or
push to a background worker), then merge/update via convert_doc_to_markdown when
the background fetch completes. Ensure logs and the returned payload indicate
that tabs are pending so users aren’t silently missing data, and remove the
second blocking wait_for retry to avoid exceeding the 30s request context.

---

Outside diff comments:
In `@gdocs/docs_tools.py`:
- Around line 2073-2096: Import the missing function
create_update_paragraph_style_request from gdocs.docs_helpers into the module's
import block so the call in the paragraph style construction code resolves;
update the existing import statement that brings in other helpers (e.g., the
same import that currently imports functions from gdocs.docs_helpers) to include
create_update_paragraph_style_request so the name is defined when used in the
block that builds paragraph_style_request and appended to requests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 46a0651c-e0b5-4a13-9ad0-c6b778d85748

📥 Commits

Reviewing files that changed from the base of the PR and between 55fbb7d and d432c64.

📒 Files selected for processing (3)
  • gdocs/docs_markdown.py
  • gdocs/docs_tools.py
  • tests/gdocs/test_docs_markdown.py

Comment thread gdocs/docs_markdown.py
Comment thread gdocs/docs_markdown.py
Comment on lines +242 to +250
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 ""

@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.

Comment thread gdocs/docs_tools.py Outdated
georgebashi and others added 2 commits April 2, 2026 15:44
- Pass footnotes_meta, inline_objects, and footnote_defs through
  _convert_table → _extract_cell_text → _convert_paragraph_text so
  smart chips inside table cells render correctly.
- Add missing create_update_paragraph_style_request import in
  docs_tools.py (pre-existing bug).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A single timeout with a clear error is better than a silent fallback
that drops multi-tab content without telling the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@georgebashi

Copy link
Copy Markdown
Contributor Author

Addressed all the feedback, ready for re-review @taylorwilsdon

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

♻️ Duplicate comments (1)
gdocs/docs_tools.py (1)

2240-2275: ⚠️ Potential issue | 🟠 Major

Avoid the second in-request timeout fallback.

This path can now hold the tool open for ~60s, and wait_for(asyncio.to_thread(...)) will not stop the first .execute() once the worker thread has started. If the retry succeeds, convert_doc_to_markdown() also drops secondary tabs while comment fetching still runs against the full file, so the response can look complete while actually being partial. Fail fast after the first timeout, or return an explicit partial-result marker instead of issuing a second blocking fetch here.

In Python asyncio, does `asyncio.wait_for(asyncio.to_thread(blocking_call), timeout=30)` cancel the underlying worker thread after the timeout expires, or does the thread continue running in the background?

Based on learnings, "Avoid long-running operations (>30s) inside request context; instead stream partial results or schedule background tasks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gdocs/docs_tools.py` around lines 2240 - 2275, The retry block that does a
second
asyncio.wait_for(asyncio.to_thread(docs_service.documents().get(...).execute))
must be removed to avoid background threads continuing after timeout; in
get_doc_as_markdown (and where convert_doc_to_markdown is used), fail fast on
the first TimeoutError/asyncio.TimeoutError by returning an explicit
partial-result marker or scheduling a background task, rather than issuing the
second blocking fetch, and ensure logs mention document_id and that the response
is partial; target the docs_service.documents().get(...).execute call and the
except handler to implement this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gdocs/docs_markdown.py`:
- Around line 213-217: The current footnote handling uses
_extract_footnote_text() which only concatenates textRuns and loses rich
content; update the footnote materialization (where "footnoteReference" is
handled) to pass document-scoped metadata (footnotes_meta, footnote_defs, and
any other context used by _convert_paragraph_text) into a new conversion path
and build each footnote body via _convert_paragraph_text() instead of
hand-stitching runs; specifically replace calls to _extract_footnote_text() with
a flow that creates a paragraph-like element for the footnote and invokes
_convert_paragraph_text(paragraph_elem, footnotes_meta, footnote_defs, ...) so
links, smart chips, inline objects, and style splits are preserved (also apply
the same change to the other occurrences around lines 314-346).
- Around line 68-74: The current loop over all_tab_docs uses tab_md =
_convert_body_to_markdown(tab_doc) and skips appending a section when
tab_md.strip() is empty, which drops empty tabs; change the logic in the loop
that builds sections (the sections list, the for title, tab_doc in all_tab_docs
block) to always append the tab heading f"# {title}\n\n{tab_md}" even if tab_md
is blank so every tab is emitted; preserve the final return behavior
("\n".join(sections).rstrip("\n") + "\n") so output trailing newline handling
remains unchanged.

---

Duplicate comments:
In `@gdocs/docs_tools.py`:
- Around line 2240-2275: The retry block that does a second
asyncio.wait_for(asyncio.to_thread(docs_service.documents().get(...).execute))
must be removed to avoid background threads continuing after timeout; in
get_doc_as_markdown (and where convert_doc_to_markdown is used), fail fast on
the first TimeoutError/asyncio.TimeoutError by returning an explicit
partial-result marker or scheduling a background task, rather than issuing the
second blocking fetch, and ensure logs mention document_id and that the response
is partial; target the docs_service.documents().get(...).execute call and the
except handler to implement this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eed64500-3da8-422a-96b8-61b5a05b2337

📥 Commits

Reviewing files that changed from the base of the PR and between d432c64 and 6e59220.

📒 Files selected for processing (2)
  • gdocs/docs_markdown.py
  • gdocs/docs_tools.py

Comment thread gdocs/docs_markdown.py
Comment thread gdocs/docs_markdown.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants