Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
bbaacd3
Add markdown-it-py and linkify-it-py dependencies for markdown writer
juliandickie Apr 24, 2026
3f01973
Scaffold docs_markdown_writer module with failing tests
juliandickie Apr 24, 2026
ff3ebd7
Implement paragraph emission in markdown writer
juliandickie Apr 24, 2026
8e87336
Implement heading emission with HEADING_N named styles
juliandickie Apr 24, 2026
beb350b
Implement inline bold, italic, code, and link styling
juliandickie Apr 24, 2026
69632ed
Implement unordered and ordered list emission
juliandickie Apr 24, 2026
4c3ac41
Implement fenced code block emission with monospace styling
juliandickie Apr 24, 2026
ac33475
Implement blockquote and horizontal rule emission
juliandickie Apr 24, 2026
a01af20
Verify tab_id threading across all request types
juliandickie Apr 24, 2026
bf37a92
Add real-world fixture test using HONOUR-HEALTH-01 blog article
juliandickie Apr 24, 2026
0103cfc
Add update_tab_from_markdown MCP tool wrapping the writer
juliandickie Apr 24, 2026
e8364b1
Document update_tab_from_markdown in skills reference and README
juliandickie Apr 24, 2026
b374139
Add spike test confirming end-to-end tab operations work
juliandickie Apr 24, 2026
5ae09fd
Fix insert_doc_tab tab_id extraction - response key is addDocumentTab
juliandickie Apr 24, 2026
7f6fe75
Fix update_tab_from_markdown deleteContentRange off-by-one
juliandickie Apr 24, 2026
026a4e0
Add integration test for update_tab_from_markdown against real Doc
juliandickie Apr 24, 2026
e25270c
Emit blank spacer paragraphs between top-level markdown blocks
juliandickie Apr 24, 2026
57cf334
Add acceptance script for HONOUR-HEALTH-01 A01 real content
juliandickie Apr 24, 2026
66db431
Address CodeRabbit PR review - docstring fixes, test hygiene, fence n…
juliandickie Apr 24, 2026
06109c4
Merge remote-tracking branch 'origin/main' into fork-extension
taylorwilsdon Apr 25, 2026
076d7ed
refac
taylorwilsdon Apr 25, 2026
57c16b5
refac
taylorwilsdon Apr 25, 2026
f2a6153
refac
taylorwilsdon Apr 26, 2026
c4dc3c1
feedback
taylorwilsdon Apr 26, 2026
7ab77e8
Merge branch 'main' of github.com:taylorwilsdon/google_workspace_mcp …
taylorwilsdon Apr 26, 2026
4f8796c
refac
taylorwilsdon Apr 26, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,7 @@ Saved files expire after 1 hour and are cleaned up automatically.
| <sub>`debug_table_structure`</sub> | <sub>Complete</sub> | <sub>Debug table issues</sub> |
| <sub>`list_document_comments`</sub> | <sub>Complete</sub> | <sub>List all document comments</sub> |
| <sub>`manage_document_comment`</sub> | <sub>Complete</sub> | <sub>Create, reply to, or resolve comments</sub> |
| <sub>`update_tab_from_markdown`</sub> | <sub>Complete</sub> | <sub>Populate a tab from markdown source</sub> |

#### 📊 Google Sheets <sub>[`sheets_tools.py`](gsheets/sheets_tools.py)</sub>

Expand Down
358 changes: 358 additions & 0 deletions gdocs/docs_markdown_writer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
"""Markdown to Google Docs API batchUpdate request converter.

Parses CommonMark+GFM markdown and emits a list of Docs API request dicts
that, when applied in order, render the markdown into a document or a
specific tab within a document.

Primary entry point - markdown_to_docs_requests(markdown_text, tab_id=None).
"""

from __future__ import annotations

from typing import Optional

from markdown_it import MarkdownIt


def markdown_to_docs_requests(
markdown_text: str,
tab_id: Optional[str] = None,
start_index: int = 1,
) -> list[dict]:
"""Convert markdown to a list of Docs API batchUpdate request dicts.

Args:
markdown_text - the markdown source
tab_id - optional tab ID; when provided, every range targets this tab
start_index - document index at which content insertion begins

Returns:
Ordered list of request dicts. Empty list for empty input.
"""
if not markdown_text.strip():
return []

md = MarkdownIt("commonmark")
tokens = md.parse(markdown_text)

requests: list[dict] = []
_emit_requests(tokens, requests, tab_id, start_index)
return requests
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _emit_requests(tokens, requests, tab_id, start_index):
"""Walk markdown-it tokens and append Docs API requests.

Maintains a running `cursor` that represents the current insertion point
in the document. Each insertText advances cursor by len(text).
"""
cursor = [start_index] # mutable via list so helpers can advance it

i = 0
while i < len(tokens):
tok = tokens[i]

if tok.type == "heading_open":
level = int(tok.tag[1]) # 'h1' -> 1
inline_tok = tokens[i + 1]
text, inline_styles = _render_inline_with_styles(
inline_tok.children or [], cursor[0], tab_id
)
text += "\n"
range_start = cursor[0]
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
requests.append(_build_heading_style(range_start, cursor[0], level, tab_id))
requests.extend(inline_styles)
# Blank spacer paragraph between top-level blocks for visual spacing
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i += 3
continue

if tok.type in ("bullet_list_open", "ordered_list_open"):
preset = (
"BULLET_DISC_CIRCLE_SQUARE"
if tok.type == "bullet_list_open"
else "NUMBERED_DECIMAL_ALPHA_ROMAN"
)
list_start = cursor[0]
# Find the matching closing token
close_type = tok.type.replace("_open", "_close")
depth = 1
j = i + 1
while j < len(tokens) and depth > 0:
if tokens[j].type == tok.type:
depth += 1
elif tokens[j].type == close_type:
depth -= 1
if depth == 0:
break
j += 1
# Iterate items between i and j
k = i + 1
while k < j:
item = tokens[k]
if item.type == "list_item_open":
# Inner structure typically - list_item_open, paragraph_open, inline, paragraph_close, list_item_close
# Find the inline token within this list_item
if k + 2 < j and tokens[k + 2].type == "inline":
inline_tok = tokens[k + 2]
text, inline_styles = _render_inline_with_styles(
inline_tok.children or [], cursor[0], tab_id
)
text += "\n"
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
requests.extend(inline_styles)
k += 1
list_end = cursor[0]
# One createParagraphBullets covering the full list range
rng = {"startIndex": list_start, "endIndex": list_end}
if tab_id:
rng["tabId"] = tab_id
requests.append({
"createParagraphBullets": {
"range": rng,
"bulletPreset": preset,
}
})
# Blank spacer paragraph between top-level blocks for visual spacing
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i = j + 1
continue
Comment on lines +79 to +132

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 | 🟡 Minor

Nested lists of different types are silently dropped.

The matching-close scan at lines 90–97 only tracks tokens whose type equals tok.type (e.g., bullet_list_open); a nested ordered_list inside a bullet list increments nothing but still consumes tokens that the inner k loop (lines 99–114) skips because it only processes list_item_open at the current nesting level. As a result, nested lists of a different kind are not rendered into the output at all.

This is consistent with the "unsupported features" note at the top of the module, but it's a silent data loss rather than a syntax error for callers. Consider either (a) documenting this more prominently (e.g., in the tool docstring for update_tab_from_markdown) or (b) falling back to flattening nested-list items into the outer list at a single bullet level. Not a blocker.

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

In `@gdocs/docs_markdown_writer.py` around lines 79 - 130, The code in the
list-handling block (using tok, tokens, the matching-close scan and the inner k
loop in gdocs/docs_markdown_writer.py) fails to account for nested lists of a
different type and thus silently drops them; update the matching-close scan and
the item iteration so any nested list_open/close of either kind is tracked and
nested list items are flattened/processed: in the depth scan, treat
tokens[j].type in ("bullet_list_open","ordered_list_open") as an increment and
their corresponding "_close" types as decrements (rather than only comparing to
tok.type), and in the inner loop that iterates items between i and j (the code
that finds "list_item_open" and locates its inline child before calling
_render_inline_with_styles and appending createParagraphBullets), search for
list_item_open tokens at any nesting depth (or search forward to the next
"inline" token inside a list_item_open) so nested ordered/bullet lists are
either flattened into the outer list or correctly captured for bullets created
by createParagraphBullets.


if tok.type == "fence":
content = tok.content
start_idx = cursor[0]
text = content if content.endswith("\n") else content + "\n"
text += "\n" # trailing blank line to separate from next block
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
code_end = cursor[0] - 1 # exclude the trailing blank newline
requests.append(
_build_text_style(
start_idx,
code_end,
{"weightedFontFamily": {"fontFamily": "Courier New", "weight": 400}},
"weightedFontFamily",
tab_id,
)
)
# Blank spacer paragraph between top-level blocks for visual spacing
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i += 1
continue

if tok.type == "blockquote_open":
close_type = "blockquote_close"
depth = 1
j = i + 1
while j < len(tokens) and depth > 0:
if tokens[j].type == tok.type:
depth += 1
elif tokens[j].type == close_type:
depth -= 1
if depth == 0:
break
j += 1
quote_start = cursor[0]
# Process paragraphs inside the blockquote
k = i + 1
while k < j:
if tokens[k].type == "paragraph_open" and k + 1 < j and tokens[k + 1].type == "inline":
inline_tok = tokens[k + 1]
text, inline_styles = _render_inline_with_styles(
inline_tok.children or [], cursor[0], tab_id
)
text += "\n"
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
requests.extend(inline_styles)
k += 3
continue
k += 1
quote_end = cursor[0]
# Apply indent across the whole blockquote range
rng = {"startIndex": quote_start, "endIndex": quote_end}
if tab_id:
rng["tabId"] = tab_id
requests.append({
"updateParagraphStyle": {
"range": rng,
"paragraphStyle": {
"indentStart": {"magnitude": 36, "unit": "PT"},
},
"fields": "indentStart",
}
})
# Blank spacer paragraph between top-level blocks for visual spacing
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i = j + 1
continue

if tok.type == "hr":
# Emit a blank paragraph as a visual separator
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i += 1
continue

if tok.type == "paragraph_open":
# paragraph_open is followed by inline (children), then paragraph_close
inline_tok = tokens[i + 1]
text, inline_styles = _render_inline_with_styles(
inline_tok.children or [], cursor[0], tab_id
)
text += "\n"
requests.append(_build_insert_text(cursor[0], text, tab_id))
cursor[0] += len(text)
requests.extend(inline_styles)
# Blank spacer paragraph between top-level blocks for visual spacing.
# Only top-level paragraphs receive spacers - list-item paragraphs
# and blockquote paragraphs dispatch inside their own branches.
requests.append(_build_insert_text(cursor[0], "\n", tab_id))
cursor[0] += 1
i += 3 # skip paragraph_open, inline, paragraph_close
continue

i += 1


def _render_inline_with_styles(
children,
base_index: int,
tab_id: Optional[str],
) -> tuple[str, list[dict]]:
"""Walk inline tokens, returning plain text and style requests.

Args:
children - inline tokens from markdown-it
base_index - the document index where this inline block starts
tab_id - optional tab ID for ranges

Returns:
(plain_text, style_requests). The caller emits insertText with
plain_text starting at base_index, then appends the style_requests.
"""
text_parts: list[str] = []
style_requests: list[dict] = []
local_pos = 0 # position within this inline block (0-based)
# Stack entries are tuples. For strong/em: (style_name, start_local_pos).
# For link: (style_name, start_local_pos, href).
stack: list[tuple] = []

for tok in children:
if tok.type == "text":
text_parts.append(tok.content)
local_pos += len(tok.content)
elif tok.type == "softbreak":
text_parts.append(" ")
local_pos += 1
elif tok.type == "hardbreak":
text_parts.append("\n")
local_pos += 1
elif tok.type == "code_inline":
# self-contained - emit style immediately
start_local = local_pos
text_parts.append(tok.content)
local_pos += len(tok.content)
style_requests.append(
_build_text_style(
base_index + start_local,
base_index + local_pos,
{"weightedFontFamily": {"fontFamily": "Courier New", "weight": 400}},
"weightedFontFamily",
tab_id,
)
)
elif tok.type in ("strong_open", "em_open"):
stack.append((tok.type, local_pos))
elif tok.type in ("strong_close", "em_close"):
opener_type = tok.type.replace("_close", "_open")
for idx in range(len(stack) - 1, -1, -1):
if stack[idx][0] == opener_type:
_, start_local = stack.pop(idx)
style_key = "bold" if opener_type == "strong_open" else "italic"
style_requests.append(
_build_text_style(
base_index + start_local,
base_index + local_pos,
{style_key: True},
style_key,
tab_id,
)
)
break
elif tok.type == "link_open":
# tok.attrs may be a dict (newer markdown-it-py) or list of [key, val]
# pairs (older). Support both.
attrs = tok.attrs
if isinstance(attrs, dict):
href = attrs.get("href")
else:
href = next((a[1] for a in attrs if a[0] == "href"), None)
stack.append(("link_open", local_pos, href))
elif tok.type == "link_close":
for idx in range(len(stack) - 1, -1, -1):
if stack[idx][0] == "link_open":
_, start_local, href = stack.pop(idx)
style_requests.append(
_build_text_style(
base_index + start_local,
base_index + local_pos,
{"link": {"url": href}},
"link",
tab_id,
)
)
break
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return "".join(text_parts), style_requests


def _build_text_style(
start: int,
end: int,
style: dict,
fields: str,
tab_id: Optional[str],
) -> dict:
"""Build an updateTextStyle request."""
rng = {"startIndex": start, "endIndex": end}
if tab_id:
rng["tabId"] = tab_id
return {
"updateTextStyle": {
"range": rng,
"textStyle": style,
"fields": fields,
}
}


def _build_insert_text(index: int, text: str, tab_id: Optional[str]) -> dict:
"""Build an insertText request dict, threading tab_id if provided."""
location = {"index": index}
if tab_id:
location["tabId"] = tab_id
return {"insertText": {"location": location, "text": text}}


def _build_heading_style(
start: int, end: int, level: int, tab_id: Optional[str]
) -> dict:
"""Build updateParagraphStyle request setting HEADING_N named style."""
rng = {"startIndex": start, "endIndex": end}
if tab_id:
rng["tabId"] = tab_id
return {
"updateParagraphStyle": {
"range": rng,
"paragraphStyle": {"namedStyleType": f"HEADING_{level}"},
"fields": "namedStyleType",
}
}
Loading