diff --git a/gcalendar/calendar_tools.py b/gcalendar/calendar_tools.py index 5a6c32ead..da87bf175 100644 --- a/gcalendar/calendar_tools.py +++ b/gcalendar/calendar_tools.py @@ -318,7 +318,11 @@ def _correct_time_format_for_api( date_obj = datetime.datetime.strptime(time_str, "%Y-%m-%d") dt = tz.localize(date_obj) # Convert to UTC and format as RFC3339 - formatted = dt.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + formatted = ( + dt.astimezone(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) except pytz.exceptions.UnknownTimeZoneError: logger.warning( f"Could not apply timezone '{timezone}', falling back to UTC for {param_name}" @@ -1418,7 +1422,11 @@ async def _list_ooo_events_impl( try: tz = pytz.timezone(timezone) now = datetime.datetime.now(tz) - effective_time_min = now.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + effective_time_min = ( + now.astimezone(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) except pytz.exceptions.UnknownTimeZoneError: logger.warning( f"Could not apply timezone '{timezone}', falling back to UTC" @@ -1723,7 +1731,9 @@ def _focus_time_time_entry( """ if "T" not in time_str: time_str = f"{time_str}T00:00:00" - logger.info(f"[focus_time_time_entry] Converted date-only to dateTime: {time_str}") + logger.info( + f"[focus_time_time_entry] Converted date-only to dateTime: {time_str}" + ) has_explicit_offset = time_str.endswith("Z") or bool( re.search(r"[+-]\d{2}:\d{2}$", time_str) @@ -1740,7 +1750,9 @@ def _focus_time_time_entry( return entry -def _validate_chat_status(chat_status: Optional[str], function_name: str) -> Optional[str]: +def _validate_chat_status( + chat_status: Optional[str], function_name: str +) -> Optional[str]: """Validate chat status for Focus Time events.""" if chat_status is None: return None @@ -1852,7 +1864,11 @@ async def _list_focus_time_events_impl( try: tz = pytz.timezone(timezone) now = datetime.datetime.now(tz) - effective_time_min = now.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + effective_time_min = ( + now.astimezone(datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) except pytz.exceptions.UnknownTimeZoneError: logger.warning( f"Could not apply timezone '{timezone}', falling back to UTC" @@ -1949,11 +1965,17 @@ async def _update_focus_time_event_impl( start_time, is_end=False, timezone=timezone ) if end_time is not None: - patch_body["end"] = _focus_time_time_entry(end_time, is_end=True, timezone=timezone) + patch_body["end"] = _focus_time_time_entry( + end_time, is_end=True, timezone=timezone + ) if recurrence is not None: patch_body["recurrence"] = recurrence - if auto_decline_mode is not None or decline_message is not None or chat_status is not None: + if ( + auto_decline_mode is not None + or decline_message is not None + or chat_status is not None + ): existing_ft_props = existing_event.get("focusTimeProperties", {}) updated_ft_props: Dict[str, str] = { "autoDeclineMode": _validate_auto_decline_mode( diff --git a/gdocs/docs_markdown.py b/gdocs/docs_markdown.py index d9c183dd9..d25662e44 100644 --- a/gdocs/docs_markdown.py +++ b/gdocs/docs_markdown.py @@ -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__) @@ -33,24 +37,79 @@ 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) + 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: @@ -66,10 +125,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 ) @@ -106,22 +169,65 @@ 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, + 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() @@ -133,12 +239,163 @@ 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 "" + + +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, + 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: @@ -212,7 +469,14 @@ 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, + active_footnotes: set[str] | None = None, +) -> str: """Convert a table element to markdown.""" rows = table.get("tableRows", []) if not rows: @@ -222,7 +486,13 @@ 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, + active_footnotes=active_footnotes, + ) cells.append(cell_text) md_rows.append("| " + " | ".join(cells) + " |") @@ -233,12 +503,25 @@ 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, + 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"]) + 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) diff --git a/gdocs/docs_tools.py b/gdocs/docs_tools.py index e27286e19..2a2c1dd60 100644 --- a/gdocs/docs_tools.py +++ b/gdocs/docs_tools.py @@ -33,6 +33,7 @@ create_insert_doc_tab_request, create_update_doc_tab_request, create_delete_doc_tab_request, + create_update_paragraph_style_request, validate_suggestions_view_mode, create_update_paragraph_style_request, ) @@ -2240,15 +2241,25 @@ async def get_doc_as_markdown( f"[get_doc_as_markdown] Doc={document_id}, comments={include_comments}, mode={comment_mode}" ) - # Fetch document content via Docs API - doc = await asyncio.to_thread( - docs_service.documents() - .get( - documentId=document_id, - suggestionsViewMode=suggestions_view_mode, + # Fetch document content via Docs API (includeTabsContent for multi-tab docs) + try: + doc = await asyncio.wait_for( + asyncio.to_thread( + docs_service.documents() + .get( + documentId=document_id, + includeTabsContent=True, + suggestionsViewMode=suggestions_view_mode, + ) + .execute + ), + timeout=30, + ) + except (TimeoutError, asyncio.TimeoutError): + return ( + f"Error: Timed out fetching document {document_id} from Google Docs API. " + "The document may be too large or there may be a network issue. Please try again." ) - .execute - ) markdown = convert_doc_to_markdown(doc) diff --git a/tests/gcalendar/test_out_of_office.py b/tests/gcalendar/test_out_of_office.py index d325f5aeb..cb93d7f00 100644 --- a/tests/gcalendar/test_out_of_office.py +++ b/tests/gcalendar/test_out_of_office.py @@ -72,9 +72,7 @@ def test_date_only_start_converts_to_midnight_when_timezone_provided(self): } def test_date_only_end_converts_to_midnight_when_timezone_provided(self): - result = _ooo_time_entry( - "2026-04-06", is_end=True, timezone="America/New_York" - ) + result = _ooo_time_entry("2026-04-06", is_end=True, timezone="America/New_York") assert result == { "dateTime": "2026-04-06T00:00:00", "timeZone": "America/New_York", diff --git a/tests/gdocs/test_docs_markdown.py b/tests/gdocs/test_docs_markdown.py index 804c39000..c2b5fb6a2 100644 --- a/tests/gdocs/test_docs_markdown.py +++ b/tests/gdocs/test_docs_markdown.py @@ -333,12 +333,660 @@ def test_regular_bullet_not_checklist(self): assert "[x]" not in md +PERSON_CHIP_DOC = { + "title": "Person Chip Test", + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Assigned to ", "textStyle": {}}}, + { + "person": { + "personProperties": { + "name": "Alice Smith", + "email": "alice@example.com", + } + } + }, + {"textRun": {"content": " for review.\n", "textStyle": {}}}, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + ] + }, +} + +RICH_LINK_CHIP_DOC = { + "title": "Rich Link Chip Test", + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "See ", "textStyle": {}}}, + { + "richLink": { + "richLinkProperties": { + "title": "Project Plan", + "uri": "https://docs.google.com/document/d/abc123", + } + } + }, + {"textRun": {"content": " for details.\n", "textStyle": {}}}, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + ] + }, +} + + +class TestSmartChips: + def test_person_chip(self): + md = convert_doc_to_markdown(PERSON_CHIP_DOC) + assert "[Alice Smith](mailto:alice@example.com)" in md + + def test_person_chip_in_context(self): + md = convert_doc_to_markdown(PERSON_CHIP_DOC) + assert "Assigned to [Alice Smith](mailto:alice@example.com) for review." in md + + def test_person_chip_name_only(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + {"person": {"personProperties": {"name": "Bob Jones"}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "Bob Jones" in md + + def test_person_chip_email_only(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "person": { + "personProperties": {"email": "bob@example.com"} + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "[bob@example.com](mailto:bob@example.com)" in md + + def test_rich_link_chip(self): + md = convert_doc_to_markdown(RICH_LINK_CHIP_DOC) + assert "[Project Plan](https://docs.google.com/document/d/abc123)" in md + + def test_rich_link_chip_in_context(self): + md = convert_doc_to_markdown(RICH_LINK_CHIP_DOC) + assert ( + "See [Project Plan](https://docs.google.com/document/d/abc123) for details." + in md + ) + + def test_rich_link_uri_only(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "richLink": { + "richLinkProperties": { + "uri": "https://example.com" + } + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "https://example.com" in md + + def test_date_chip_display_text(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Due by ", + "textStyle": {}, + } + }, + { + "dateElement": { + "dateElementProperties": { + "displayText": "Mar 31, 2026", + "timestamp": "2026-03-31T00:00:00Z", + } + } + }, + { + "textRun": { + "content": " please.\n", + "textStyle": {}, + } + }, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "Due by Mar 31, 2026 please." in md + + def test_date_chip_timestamp_fallback(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "dateElement": { + "dateElementProperties": { + "timestamp": "2026-01-15T00:00:00Z", + } + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "2026-01-15" in md + + def test_inline_image(self): + doc = { + "title": "Test", + "inlineObjects": { + "kix.obj1": { + "inlineObjectProperties": { + "embeddedObject": { + "title": "Logo", + "imageProperties": { + "contentUri": "https://example.com/logo.png" + }, + } + } + } + }, + "body": { + "content": [ + { + "paragraph": { + "elements": [ + {"inlineObjectElement": {"inlineObjectId": "kix.obj1"}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "![Logo](https://example.com/logo.png)" in md + + def test_inline_image_no_uri(self): + doc = { + "title": "Test", + "inlineObjects": { + "kix.obj1": { + "inlineObjectProperties": {"embeddedObject": {"title": "Chart"}} + } + }, + "body": { + "content": [ + { + "paragraph": { + "elements": [ + {"inlineObjectElement": {"inlineObjectId": "kix.obj1"}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "[Image: Chart]" in md + + def test_footnote_reference(self): + doc = { + "title": "Test", + "footnotes": { + "kix.fn1": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "See the appendix.\n", + "textStyle": {}, + } + } + ] + } + } + ] + } + }, + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Important claim", + "textStyle": {}, + } + }, + {"footnoteReference": {"footnoteId": "kix.fn1"}}, + { + "textRun": { + "content": " and more text.\n", + "textStyle": {}, + } + }, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "Important claim[^kix.fn1] and more text." in md + assert "[^kix.fn1]: See the appendix." in md + + def test_footnote_reference_preserves_rich_inline_content(self): + doc = { + "title": "Test", + "inlineObjects": { + "kix.inline1": { + "inlineObjectProperties": { + "embeddedObject": { + "title": "Chart", + "imageProperties": { + "contentUri": "https://cdn.example.com/chart.png" + }, + } + } + } + }, + "footnotes": { + "kix.fn1": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "See ", + "textStyle": {}, + } + }, + { + "textRun": { + "content": "styled", + "textStyle": {"bold": True}, + } + }, + { + "textRun": { + "content": " ", + "textStyle": {}, + } + }, + { + "textRun": { + "content": "link", + "textStyle": { + "link": { + "url": "https://example.com/link" + } + }, + } + }, + { + "textRun": { + "content": " ", + "textStyle": {}, + } + }, + { + "person": { + "personProperties": { + "name": "Ada Lovelace", + "email": "ada@example.com", + } + } + }, + { + "textRun": { + "content": " ", + "textStyle": {}, + } + }, + { + "richLink": { + "richLinkProperties": { + "title": "Project Plan", + "uri": "https://example.com/plan", + } + } + }, + { + "textRun": { + "content": " ", + "textStyle": {}, + } + }, + { + "inlineObjectElement": { + "inlineObjectId": "kix.inline1" + } + }, + { + "textRun": { + "content": "\n", + "textStyle": {}, + } + }, + ] + } + } + ] + } + }, + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Important claim", + "textStyle": {}, + } + }, + {"footnoteReference": {"footnoteId": "kix.fn1"}}, + { + "textRun": { + "content": ".\n", + "textStyle": {}, + } + }, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + + md = convert_doc_to_markdown(doc) + + assert "Important claim[^kix.fn1]." in md + assert ( + "[^kix.fn1]: See **styled** [link](https://example.com/link) " + "[Ada Lovelace](mailto:ada@example.com) " + "[Project Plan](https://example.com/plan) " + "![Chart](https://cdn.example.com/chart.png)" + ) in md + + def test_horizontal_rule(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Above\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + { + "paragraph": { + "elements": [{"horizontalRule": {}}], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Below\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "---" in md + + def test_auto_text_page_number(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Page ", + "textStyle": {}, + } + }, + {"autoText": {"type": "PAGE_NUMBER"}}, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "Page [Page #]" in md + + def test_equation_placeholder(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "The formula is ", + "textStyle": {}, + } + }, + {"equation": {}}, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "The formula is [Equation]" in md + + def test_page_break_skipped(self): + doc = { + "title": "Test", + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Before", + "textStyle": {}, + } + }, + {"pageBreak": {}}, + { + "textRun": { + "content": "After\n", + "textStyle": {}, + } + }, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + }, + } + md = convert_doc_to_markdown(doc) + assert "BeforeAfter" in md + + class TestEmptyDoc: def test_empty(self): md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}}) assert md.strip() == "" +def _make_tab(title, tab_id, text): + """Helper to build a tab structure with a single paragraph.""" + return { + "tabProperties": {"title": title, "tabId": tab_id}, + "documentTab": { + "body": { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": f"{text}\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + } + ] + } + }, + } + + +class TestDocumentTabs: + def test_single_tab_no_heading(self): + """A single-tab doc should render without a tab heading.""" + doc = {"tabs": [_make_tab("Main", "t1", "Hello world")]} + md = convert_doc_to_markdown(doc) + assert "Hello world" in md + assert "# Main" not in md + + def test_multi_tab_headings(self): + """Multi-tab docs should get a heading per tab.""" + doc = { + "tabs": [ + _make_tab("Overview", "t1", "First tab content"), + _make_tab("Details", "t2", "Second tab content"), + ] + } + md = convert_doc_to_markdown(doc) + assert "# Overview" in md + assert "First tab content" in md + assert "# Details" in md + assert "Second tab content" in md + + def test_multi_tab_keeps_empty_tabs(self): + """Empty tabs should still render a heading in multi-tab docs.""" + doc = { + "tabs": [ + _make_tab("Overview", "t1", "First tab content"), + _make_tab("Empty", "t2", ""), + ] + } + md = convert_doc_to_markdown(doc) + assert "# Overview" in md + assert "First tab content" in md + assert "# Empty" in md + + def test_child_tabs(self): + """Child tabs should be flattened and rendered.""" + parent = _make_tab("Parent", "t1", "Parent content") + child = _make_tab("Child", "t2", "Child content") + parent["childTabs"] = [child] + doc = {"tabs": [parent]} + md = convert_doc_to_markdown(doc) + assert "# Parent" in md + assert "Parent content" in md + assert "# Child" in md + assert "Child content" in md + + def test_legacy_body_fallback(self): + """Docs without tabs should still work via legacy body field.""" + md = convert_doc_to_markdown(SIMPLE_DOC) + assert "Hello world" in md + + # --- Comment parsing tests ---