diff --git a/custom-skills/32-notion-writer/code/scripts/notion_writer.py b/custom-skills/32-notion-writer/code/scripts/notion_writer.py index 0658f1d..dbbac0b 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -51,6 +51,23 @@ def format_id_with_dashes(raw_id: str) -> str: return raw_id +TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$') + + +def _is_table_row(line: str) -> bool: + stripped = line.strip() + return stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2 + + +def _split_table_row(line: str) -> List[str]: + stripped = line.strip() + if stripped.startswith('|'): + stripped = stripped[1:] + if stripped.endswith('|'): + stripped = stripped[:-1] + return [cell.strip() for cell in stripped.split('|')] + + def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]: """Convert markdown text to Notion block objects.""" blocks = [] @@ -65,6 +82,17 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]: i += 1 continue + # Table (header row + separator + body rows) + if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]): + header_cells = _split_table_row(line) + i += 2 # skip separator + body_rows: List[List[str]] = [] + while i < len(lines) and _is_table_row(lines[i]): + body_rows.append(_split_table_row(lines[i])) + i += 1 + blocks.append(create_table_block(header_cells, body_rows)) + continue + # Headers if line.startswith('######'): blocks.append(create_heading_block(line[6:].strip(), 3)) @@ -89,6 +117,14 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]: i += 1 blocks.append(create_code_block('\n'.join(code_lines), language)) + # Checkbox / Todo (must come before generic bullet match) + elif line.strip().startswith('- [ ]'): + text = line.strip()[5:].strip() + blocks.append(create_todo_block(text, False)) + elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'): + text = line.strip()[5:].strip() + blocks.append(create_todo_block(text, True)) + # Bullet list elif line.strip().startswith('- ') or line.strip().startswith('* '): text = line.strip()[2:] @@ -99,14 +135,6 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]: text = re.sub(r'^\d+\.\s', '', line.strip()) blocks.append(create_numbered_list_block(text)) - # Checkbox / Todo - elif line.strip().startswith('- [ ]'): - text = line.strip()[5:].strip() - blocks.append(create_todo_block(text, False)) - elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'): - text = line.strip()[5:].strip() - blocks.append(create_todo_block(text, True)) - # Blockquote elif line.startswith('>'): text = line[1:].strip() @@ -125,19 +153,73 @@ def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]: return blocks +INLINE_PATTERNS = [ + ('code', re.compile(r'`([^`\n]+)`')), + ('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')), + ('bold_star', re.compile(r'\*\*([^*\n]+)\*\*')), + ('bold_under', re.compile(r'__([^_\n]+)__')), + ('strike', re.compile(r'~~([^~\n]+)~~')), + ('italic_star', re.compile(r'\*([^*\n]+)\*')), + ('italic_under', re.compile(r'(? Dict[str, Any]: + """Build one rich-text span with optional annotations / link.""" + span: Dict[str, Any] = { + "type": "text", + "text": {"content": content[:2000]}, + } + link = annotations.pop('link', None) + if link: + span["text"]["link"] = {"url": link} + if annotations: + span["annotations"] = {k: v for k, v in annotations.items() if v} + return span + + def parse_rich_text(text: str) -> List[Dict[str, Any]]: - """Parse markdown inline formatting to Notion rich text.""" - rich_text = [] + """Parse markdown inline formatting into Notion rich-text spans. - # Simple implementation - just plain text for now - # TODO: Add support for bold, italic, code, links - if text: - rich_text.append({ - "type": "text", - "text": {"content": text[:2000]} # Notion limit - }) + Supports: **bold**, __bold__, *italic*, _italic_, `code`, ~~strike~~, [text](url). + No nesting (Notion rich-text flattens annotations onto each span anyway). + """ + if not text: + return [] - return rich_text + spans: List[Dict[str, Any]] = [] + remaining = text + + while remaining: + # Find the earliest inline token in what's left. + best: Optional[tuple] = None # (kind, match_obj) + for kind, pattern in INLINE_PATTERNS: + m = pattern.search(remaining) + if m and (best is None or m.start() < best[1].start()): + best = (kind, m) + + if not best: + spans.append(_rich_span(remaining)) + break + + kind, m = best + if m.start() > 0: + spans.append(_rich_span(remaining[:m.start()])) + + if kind == 'code': + spans.append(_rich_span(m.group(1), code=True)) + elif kind == 'link': + spans.append(_rich_span(m.group(1), link=m.group(2))) + elif kind in ('bold_star', 'bold_under'): + spans.append(_rich_span(m.group(1), bold=True)) + elif kind == 'strike': + spans.append(_rich_span(m.group(1), strikethrough=True)) + elif kind in ('italic_star', 'italic_under'): + spans.append(_rich_span(m.group(1), italic=True)) + + remaining = remaining[m.end():] + + return spans def create_paragraph_block(text: str) -> Dict[str, Any]: @@ -221,6 +303,39 @@ def create_divider_block() -> Dict[str, Any]: } +def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]: + """Build a Notion `table` block with header + body rows. + + All rows are padded to the header width so Notion accepts the payload. + Each cell renders through `parse_rich_text` so inline formatting works + inside tables too. + """ + width = max(len(header_cells), max((len(r) for r in body_rows), default=0)) + + def row_block(cells: List[str]) -> Dict[str, Any]: + padded = cells + [''] * (width - len(cells)) + return { + "object": "block", + "type": "table_row", + "table_row": { + "cells": [parse_rich_text(c) for c in padded] + }, + } + + children = [row_block(header_cells)] + [row_block(r) for r in body_rows] + + return { + "object": "block", + "type": "table", + "table": { + "table_width": width, + "has_column_header": True, + "has_row_header": False, + "children": children, + }, + } + + def list_accessible_content(notion: Client, filter_type: str = 'all') -> None: """List all accessible pages and databases.""" print("=" * 70) diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py new file mode 100644 index 0000000..d1a3c19 --- /dev/null +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Parser tests for notion_writer.py — run with `python test_parser.py`.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from notion_writer import markdown_to_notion_blocks, parse_rich_text + + +FIXTURE = """# Top Heading + +## Section With **Bold** And `code` + +Plain paragraph with *italic*, **bold**, `inline`, a [link](https://example.com), and ~~strike~~. + +- First bullet +- Second bullet with **bold** +* Alt bullet marker + +1. First numbered +2. Second numbered + +- [ ] Unchecked task +- [x] Checked task + +> A quote with *emphasis* + +```python +print("hello") +``` + +--- + +| Task | Priority | Status | +|------|----------|--------| +| Fix HTML reporter | P0 | Pending | +| Korean encoding | **P1** | In Progress | +| Type hints | P2 | [link](https://example.com/types) | +""" + + +def _assert(cond, msg): + if not cond: + print(f" ✗ FAIL: {msg}") + raise SystemExit(1) + print(f" ✓ {msg}") + + +def test_rich_text_plain(): + spans = parse_rich_text("hello world") + _assert(len(spans) == 1, "plain text emits one span") + _assert(spans[0]["text"]["content"] == "hello world", "plain content preserved") + _assert("annotations" not in spans[0], "plain text has no annotations") + + +def test_rich_text_bold(): + spans = parse_rich_text("a **bold** b") + _assert(len(spans) == 3, "bold splits into 3 spans (pre / bold / post)") + _assert(spans[0]["text"]["content"] == "a ", "pre-text preserved") + _assert(spans[1]["text"]["content"] == "bold", "bold content stripped of markers") + _assert(spans[1].get("annotations", {}).get("bold") is True, "bold annotation set") + _assert(spans[2]["text"]["content"] == " b", "post-text preserved") + + +def test_rich_text_italic(): + spans = parse_rich_text("x *em* y") + _assert(any(s.get("annotations", {}).get("italic") for s in spans), "italic annotation set") + + +def test_rich_text_code(): + spans = parse_rich_text("run `npm test` now") + code_spans = [s for s in spans if s.get("annotations", {}).get("code")] + _assert(len(code_spans) == 1, "code span emitted") + _assert(code_spans[0]["text"]["content"] == "npm test", "code content stripped of backticks") + + +def test_rich_text_link(): + spans = parse_rich_text("see [the docs](https://example.com/docs) please") + link_spans = [s for s in spans if s["text"].get("link")] + _assert(len(link_spans) == 1, "link span emitted") + _assert(link_spans[0]["text"]["link"]["url"] == "https://example.com/docs", "link URL preserved") + _assert(link_spans[0]["text"]["content"] == "the docs", "link text preserved") + + +def test_rich_text_strike(): + spans = parse_rich_text("~~gone~~ now") + strike = [s for s in spans if s.get("annotations", {}).get("strikethrough")] + _assert(len(strike) == 1, "strikethrough annotation set") + + +def test_rich_text_mixed(): + spans = parse_rich_text("**bold** and *italic* and `code` and [link](https://x.com)") + kinds = set() + for s in spans: + ann = s.get("annotations") or {} + if ann.get("bold"): kinds.add("bold") + if ann.get("italic"): kinds.add("italic") + if ann.get("code"): kinds.add("code") + if s["text"].get("link"): kinds.add("link") + _assert(kinds == {"bold", "italic", "code", "link"}, "all four inline types present") + + +def test_blocks_headings(): + blocks = markdown_to_notion_blocks(FIXTURE) + types = [b["type"] for b in blocks] + _assert("heading_1" in types, "H1 emitted") + _assert("heading_2" in types, "H2 emitted") + + +def test_blocks_bullets_and_numbers(): + blocks = markdown_to_notion_blocks(FIXTURE) + types = [b["type"] for b in blocks] + _assert(types.count("bulleted_list_item") == 3, "3 bulleted items (incl. * marker)") + _assert(types.count("numbered_list_item") == 2, "2 numbered items") + + +def test_blocks_todo(): + blocks = markdown_to_notion_blocks(FIXTURE) + todos = [b for b in blocks if b["type"] == "to_do"] + _assert(len(todos) == 2, "2 to_do blocks") + _assert(todos[0]["to_do"]["checked"] is False, "first todo unchecked") + _assert(todos[1]["to_do"]["checked"] is True, "second todo checked") + _assert(todos[0]["to_do"]["rich_text"][0]["text"]["content"] == "Unchecked task", + "todo text stripped of '- [ ]' marker") + + +def test_blocks_quote_code_divider(): + blocks = markdown_to_notion_blocks(FIXTURE) + types = [b["type"] for b in blocks] + _assert("quote" in types, "quote block emitted") + _assert("code" in types, "code block emitted") + _assert("divider" in types, "divider block emitted") + + code_block = next(b for b in blocks if b["type"] == "code") + _assert(code_block["code"]["language"] == "python", "code language preserved") + + +def test_blocks_table(): + blocks = markdown_to_notion_blocks(FIXTURE) + tables = [b for b in blocks if b["type"] == "table"] + _assert(len(tables) == 1, "exactly one table block emitted") + + t = tables[0]["table"] + _assert(t["table_width"] == 3, "table width = 3 columns") + _assert(t["has_column_header"] is True, "column header flag set") + _assert(len(t["children"]) == 4, "4 rows (1 header + 3 body)") + + # Header cell content + header_row = t["children"][0]["table_row"]["cells"] + _assert(header_row[0][0]["text"]["content"] == "Task", "header cell 0 = 'Task'") + _assert(header_row[1][0]["text"]["content"] == "Priority", "header cell 1 = 'Priority'") + + # Body row inline formatting + row_bold = t["children"][2]["table_row"]["cells"] # "| Korean encoding | **P1** | In Progress |" + bold_cell = row_bold[1] + _assert(bold_cell[0].get("annotations", {}).get("bold") is True, + "bold inline formatting preserved inside table cell") + + row_link = t["children"][3]["table_row"]["cells"] # "| Type hints | P2 | [link](...) |" + link_cell = row_link[2] + _assert(link_cell[0]["text"].get("link", {}).get("url") == "https://example.com/types", + "link inline formatting preserved inside table cell") + + +def test_no_literal_markers_leak(): + """Regression: before the rich-text rewrite, ** and [ ] leaked as literal text.""" + blocks = markdown_to_notion_blocks("A **bold** word and a [link](https://x.com).") + para = blocks[0]["paragraph"]["rich_text"] + joined = "".join(s["text"]["content"] for s in para) + _assert("**" not in joined, "no literal ** in paragraph") + _assert("[link]" not in joined, "no literal [link] in paragraph") + _assert("bold" in joined and "link" in joined, "visible words preserved") + + +def run_all(): + tests = [ + test_rich_text_plain, + test_rich_text_bold, + test_rich_text_italic, + test_rich_text_code, + test_rich_text_link, + test_rich_text_strike, + test_rich_text_mixed, + test_blocks_headings, + test_blocks_bullets_and_numbers, + test_blocks_todo, + test_blocks_quote_code_divider, + test_blocks_table, + test_no_literal_markers_leak, + ] + for t in tests: + print(f"\n{t.__name__}") + t() + + print("\n" + "=" * 50) + print(f"✅ All {len(tests)} tests passed") + print("=" * 50) + + # Dump the fixture's block types for visual sanity check + blocks = markdown_to_notion_blocks(FIXTURE) + print("\nFixture produces block types:") + for b in blocks: + print(f" - {b['type']}") + + +if __name__ == "__main__": + run_all()