Skip to content

Commit 8b8d74c

Browse files
Merge pull request #490 from MaxGhenis/feat/doc-markdown-export
feat(docs): add get_doc_as_markdown tool with comment context
2 parents 860bc4c + 7b7fcfd commit 8b8d74c

4 files changed

Lines changed: 678 additions & 0 deletions

File tree

core/tool_tiers.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ docs:
6666
- list_docs_in_folder
6767
- insert_doc_elements
6868
- update_paragraph_style
69+
- get_doc_as_markdown
6970
complete:
7071
- insert_doc_image
7172
- update_doc_headers_footers

gdocs/docs_markdown.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"""
2+
Google Docs to Markdown Converter
3+
4+
Converts Google Docs API JSON responses to clean Markdown, preserving:
5+
- Headings (H1-H6, Title, Subtitle)
6+
- Bold, italic, strikethrough, code, links
7+
- Ordered and unordered lists with nesting
8+
- Tables with header row separators
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
from typing import Any
15+
16+
logger = logging.getLogger(__name__)
17+
18+
MONO_FONTS = {"Courier New", "Consolas", "Roboto Mono", "Source Code Pro"}
19+
20+
HEADING_MAP = {
21+
"TITLE": "#",
22+
"SUBTITLE": "##",
23+
"HEADING_1": "#",
24+
"HEADING_2": "##",
25+
"HEADING_3": "###",
26+
"HEADING_4": "####",
27+
"HEADING_5": "#####",
28+
"HEADING_6": "######",
29+
}
30+
31+
32+
def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
33+
"""Convert a Google Docs API document response to markdown.
34+
35+
Args:
36+
doc: The document JSON from docs.documents.get()
37+
38+
Returns:
39+
Markdown string
40+
"""
41+
body = doc.get("body", {})
42+
content = body.get("content", [])
43+
lists_meta = doc.get("lists", {})
44+
45+
lines: list[str] = []
46+
ordered_counters: dict[tuple[str, int], int] = {}
47+
prev_was_list = False
48+
49+
for element in content:
50+
if "paragraph" in element:
51+
para = element["paragraph"]
52+
text = _convert_paragraph_text(para)
53+
54+
if not text.strip():
55+
if prev_was_list:
56+
prev_was_list = False
57+
continue
58+
59+
bullet = para.get("bullet")
60+
if bullet:
61+
list_id = bullet["listId"]
62+
nesting = bullet.get("nestingLevel", 0)
63+
is_ordered = _is_ordered_list(lists_meta, list_id, nesting)
64+
65+
if is_ordered:
66+
key = (list_id, nesting)
67+
ordered_counters[key] = ordered_counters.get(key, 0) + 1
68+
counter = ordered_counters[key]
69+
indent = " " * nesting
70+
lines.append(f"{indent}{counter}. {text}")
71+
else:
72+
indent = " " * nesting
73+
lines.append(f"{indent}- {text}")
74+
prev_was_list = True
75+
else:
76+
if prev_was_list:
77+
ordered_counters.clear()
78+
lines.append("")
79+
prev_was_list = False
80+
81+
style = para.get("paragraphStyle", {})
82+
named_style = style.get("namedStyleType", "NORMAL_TEXT")
83+
prefix = HEADING_MAP.get(named_style, "")
84+
85+
if prefix:
86+
lines.append(f"{prefix} {text}")
87+
lines.append("")
88+
else:
89+
lines.append(text)
90+
lines.append("")
91+
92+
elif "table" in element:
93+
if prev_was_list:
94+
ordered_counters.clear()
95+
lines.append("")
96+
prev_was_list = False
97+
table_md = _convert_table(element["table"])
98+
lines.append(table_md)
99+
lines.append("")
100+
101+
result = "\n".join(lines).rstrip("\n") + "\n"
102+
return result
103+
104+
105+
def _convert_paragraph_text(para: dict[str, Any]) -> str:
106+
"""Convert paragraph elements to inline markdown text."""
107+
parts: list[str] = []
108+
for elem in para.get("elements", []):
109+
if "textRun" in elem:
110+
parts.append(_convert_text_run(elem["textRun"]))
111+
return "".join(parts).strip()
112+
113+
114+
def _convert_text_run(text_run: dict[str, Any]) -> str:
115+
"""Convert a single text run to markdown."""
116+
content = text_run.get("content", "")
117+
style = text_run.get("textStyle", {})
118+
119+
text = content.rstrip("\n")
120+
if not text:
121+
return ""
122+
123+
return _apply_text_style(text, style)
124+
125+
126+
def _apply_text_style(text: str, style: dict[str, Any]) -> str:
127+
"""Apply markdown formatting based on text style."""
128+
link = style.get("link", {})
129+
url = link.get("url")
130+
131+
font_family = style.get("weightedFontFamily", {}).get("fontFamily", "")
132+
if font_family in MONO_FONTS:
133+
return f"`{text}`"
134+
135+
bold = style.get("bold", False)
136+
italic = style.get("italic", False)
137+
strikethrough = style.get("strikethrough", False)
138+
139+
if bold and italic:
140+
text = f"***{text}***"
141+
elif bold:
142+
text = f"**{text}**"
143+
elif italic:
144+
text = f"*{text}*"
145+
146+
if strikethrough:
147+
text = f"~~{text}~~"
148+
149+
if url:
150+
text = f"[{text}]({url})"
151+
152+
return text
153+
154+
155+
def _is_ordered_list(
156+
lists_meta: dict[str, Any], list_id: str, nesting: int
157+
) -> bool:
158+
"""Check if a list at a given nesting level is ordered."""
159+
list_info = lists_meta.get(list_id, {})
160+
nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", [])
161+
if nesting < len(nesting_levels):
162+
level = nesting_levels[nesting]
163+
glyph = level.get("glyphType", "")
164+
return glyph not in ("", "GLYPH_TYPE_UNSPECIFIED")
165+
return False
166+
167+
168+
def _convert_table(table: dict[str, Any]) -> str:
169+
"""Convert a table element to markdown."""
170+
rows = table.get("tableRows", [])
171+
if not rows:
172+
return ""
173+
174+
md_rows: list[str] = []
175+
for i, row in enumerate(rows):
176+
cells: list[str] = []
177+
for cell in row.get("tableCells", []):
178+
cell_text = _extract_cell_text(cell)
179+
cells.append(cell_text)
180+
md_rows.append("| " + " | ".join(cells) + " |")
181+
182+
if i == 0:
183+
sep = "| " + " | ".join("---" for _ in cells) + " |"
184+
md_rows.append(sep)
185+
186+
return "\n".join(md_rows)
187+
188+
189+
def _extract_cell_text(cell: dict[str, Any]) -> str:
190+
"""Extract text from a table cell."""
191+
parts: list[str] = []
192+
for content_elem in cell.get("content", []):
193+
if "paragraph" in content_elem:
194+
text = _convert_paragraph_text(content_elem["paragraph"])
195+
if text.strip():
196+
parts.append(text.strip())
197+
cell_text = " ".join(parts)
198+
return cell_text.replace("|", "\\|")
199+
200+
201+
def format_comments_inline(markdown: str, comments: list[dict[str, Any]]) -> str:
202+
"""Insert footnote-style comment annotations inline in markdown.
203+
204+
For each comment, finds the anchor text in the markdown and inserts
205+
a footnote reference. Unmatched comments go to an appendix at the bottom.
206+
"""
207+
if not comments:
208+
return markdown
209+
210+
footnotes: list[str] = []
211+
unmatched: list[dict[str, Any]] = []
212+
213+
for i, comment in enumerate(comments, 1):
214+
ref = f"[^c{i}]"
215+
anchor = comment.get("anchor_text", "")
216+
217+
if anchor and anchor in markdown:
218+
markdown = markdown.replace(anchor, anchor + ref, 1)
219+
footnotes.append(_format_footnote(i, comment))
220+
else:
221+
unmatched.append(comment)
222+
223+
if footnotes:
224+
markdown = markdown.rstrip("\n") + "\n\n" + "\n".join(footnotes) + "\n"
225+
226+
if unmatched:
227+
appendix = format_comments_appendix(unmatched)
228+
if appendix.strip():
229+
markdown = markdown.rstrip("\n") + "\n\n" + appendix
230+
231+
return markdown
232+
233+
234+
def _format_footnote(num: int, comment: dict[str, Any]) -> str:
235+
"""Format a single footnote."""
236+
lines = [f"[^c{num}]: **{comment['author']}**: {comment['content']}"]
237+
for reply in comment.get("replies", []):
238+
lines.append(f" - **{reply['author']}**: {reply['content']}")
239+
return "\n".join(lines)
240+
241+
242+
def format_comments_appendix(comments: list[dict[str, Any]]) -> str:
243+
"""Format comments as an appendix section with blockquoted anchors."""
244+
if not comments:
245+
return ""
246+
247+
lines = ["## Comments", ""]
248+
for comment in comments:
249+
resolved_tag = " *(Resolved)*" if comment.get("resolved") else ""
250+
anchor = comment.get("anchor_text", "")
251+
if anchor:
252+
lines.append(f"> {anchor}")
253+
lines.append("")
254+
lines.append(
255+
f"- **{comment['author']}**: {comment['content']}{resolved_tag}"
256+
)
257+
for reply in comment.get("replies", []):
258+
lines.append(f" - **{reply['author']}**: {reply['content']}")
259+
lines.append("")
260+
261+
return "\n".join(lines)
262+
263+
264+
def parse_drive_comments(
265+
response: dict[str, Any], include_resolved: bool = False
266+
) -> list[dict[str, Any]]:
267+
"""Parse Drive API comments response into structured dicts.
268+
269+
Args:
270+
response: Raw JSON from drive.comments.list()
271+
include_resolved: Whether to include resolved comments
272+
273+
Returns:
274+
List of comment dicts with keys: author, content, anchor_text,
275+
replies, resolved
276+
"""
277+
results = []
278+
for comment in response.get("comments", []):
279+
if not include_resolved and comment.get("resolved", False):
280+
continue
281+
282+
anchor_text = comment.get("quotedFileContent", {}).get("value", "")
283+
replies = [
284+
{
285+
"author": r.get("author", {}).get("displayName", "Unknown"),
286+
"content": r.get("content", ""),
287+
}
288+
for r in comment.get("replies", [])
289+
]
290+
results.append(
291+
{
292+
"author": comment.get("author", {}).get("displayName", "Unknown"),
293+
"content": comment.get("content", ""),
294+
"anchor_text": anchor_text,
295+
"replies": replies,
296+
"resolved": comment.get("resolved", False),
297+
}
298+
)
299+
return results

0 commit comments

Comments
 (0)