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:
2026-04-23 13:00:12 +09:00
parent c9772db119
commit 665fe22201
2 changed files with 343 additions and 18 deletions

View File

@@ -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'(?<![a-zA-Z0-9_])_([^_\n]+)_(?![a-zA-Z0-9_])')),
]
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": 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)