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 3622341..d87c4d6 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -34,9 +34,9 @@ def extract_notion_id(url_or_id: str) -> Optional[str]: # Notion URL patterns patterns = [ r'notion\.so/(?:[^/]+/)?([a-f0-9]{32})', # notion.so/workspace/page-id - r'notion\.so/(?:[^/]+/)?[^-]+-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id + r'notion\.so/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id r'notion\.site/(?:[^/]+/)?([a-f0-9]{32})', # public notion.site - r'notion\.site/(?:[^/]+/)?[^-]+-([a-f0-9]{32})', + r'notion\.site/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})', r'([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})', # UUID format ] @@ -277,6 +277,7 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: INLINE_PATTERNS = [ ('code', re.compile(r'`([^`\n]+)`')), + ('mention', re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')), ('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')), ('bold_star', re.compile(r'\*\*([^*\n]+)\*\*')), ('bold_under', re.compile(r'__([^_\n]+)__')), @@ -347,6 +348,21 @@ def parse_rich_text(text: str) -> List[Dict[str, Any]]: if kind == 'code': spans.append(_rich_span(m.group(1), code=True)) + elif kind == 'mention': + mention_title, mention_target = m.group(1), m.group(2) + page_id = extract_notion_id(mention_target) + if page_id: + spans.append({ + "type": "mention", + "mention": { + "type": "page", + "page": {"id": format_id_with_dashes(page_id)}, + }, + "plain_text": mention_title, + }) + else: + # Invalid ID — degrade to plain text "@Title" + spans.append(_rich_span(f"@{mention_title}")) elif kind == 'link': link_text, link_url = m.group(1), m.group(2) if _is_absolute_url(link_url): diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py index 863a938..19fc9d4 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_parser.py +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -343,6 +343,41 @@ only one column here _assert("only one column here" in joined, "content preserved as paragraph") +def test_mention_id(): + """@[Title](32-hex-id) produces a mention rich-text span with page reference.""" + raw_id = "abcdef0123456789abcdef0123456789" + spans = parse_rich_text(f"see @[Roadmap]({raw_id}) for plans") + mention_spans = [s for s in spans if s.get("type") == "mention"] + _assert(len(mention_spans) == 1, "one mention span emitted") + m = mention_spans[0] + _assert(m["mention"]["type"] == "page", "mention type is page") + _assert(m["mention"]["page"]["id"].replace("-", "") == raw_id, "page id resolved") + _assert(m.get("plain_text") == "Roadmap", "plain_text is the title") + + +def test_mention_url(): + """@[Title](https://notion.so/Page-Title-id) extracts ID from URL.""" + raw_id = "abcdef0123456789abcdef0123456789" + url = f"https://notion.so/My-Page-{raw_id}" + spans = parse_rich_text(f"check @[My Page]({url})") + mention_spans = [s for s in spans if s.get("type") == "mention"] + _assert(len(mention_spans) == 1, "one mention span emitted from URL form") + _assert(mention_spans[0]["mention"]["page"]["id"].replace("-", "") == raw_id, + "ID extracted from Notion URL") + + +def test_mention_invalid_falls_back(): + """A non-resolvable target degrades to plain text '@Title' with no link.""" + spans = parse_rich_text("ping @[Bob](not-a-real-id) please") + mention_spans = [s for s in spans if s.get("type") == "mention"] + _assert(len(mention_spans) == 0, "no mention span for invalid id") + plain_text_joined = "".join( + s["text"]["content"] for s in spans if s.get("type") == "text" + ) + _assert("@Bob" in plain_text_joined, "title rendered as plain '@Bob'") + _assert("not-a-real-id" not in plain_text_joined, "invalid id stripped from output") + + def test_rich_text_anchor_link_becomes_bold(): """TOC-style fragment links must not be sent as Notion link annotations.""" spans = parse_rich_text("see [Section A](#section-a) below") @@ -405,6 +440,9 @@ def run_all(): test_columns_two, test_columns_with_blocks, test_columns_single_degrades, + test_mention_id, + test_mention_url, + test_mention_invalid_falls_back, test_rich_text_anchor_link_becomes_bold, test_rich_text_relative_link_becomes_plain, test_rich_text_absolute_link_preserved,