feat(notion-writer): add table blocks, inline rich-text parsing, and parser tests
Extend markdown_to_notion_blocks with GFM table support and rewrite parse_rich_text to emit Notion annotations for bold/italic/code/link/strike instead of dropping them as literal text. Move checkbox match ahead of the bullet match so "- [ ]" lines become to_do blocks, not bullets. Add test_parser.py with 13 regression tests covering rich-text spans, block types, and table cell formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
def parse_rich_text(text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse markdown inline formatting to Notion rich text."""
|
||||
rich_text = []
|
||||
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'(?<![a-zA-Z0-9_])_([^_\n]+)_(?![a-zA-Z0-9_])')),
|
||||
]
|
||||
|
||||
# Simple implementation - just plain text for now
|
||||
# TODO: Add support for bold, italic, code, links
|
||||
if text:
|
||||
rich_text.append({
|
||||
|
||||
def _rich_span(content: str, **annotations: Any) -> Dict[str, Any]:
|
||||
"""Build one rich-text span with optional annotations / link."""
|
||||
span: Dict[str, Any] = {
|
||||
"type": "text",
|
||||
"text": {"content": text[:2000]} # Notion limit
|
||||
})
|
||||
"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
|
||||
|
||||
return rich_text
|
||||
|
||||
def parse_rich_text(text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse markdown inline formatting into Notion rich-text spans.
|
||||
|
||||
Supports: **bold**, __bold__, *italic*, _italic_, `code`, ~~strike~~, [text](url).
|
||||
No nesting (Notion rich-text flattens annotations onto each span anyway).
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
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)
|
||||
|
||||
210
custom-skills/32-notion-writer/code/scripts/test_parser.py
Normal file
210
custom-skills/32-notion-writer/code/scripts/test_parser.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user