FIX 1: _content_has_local_images and extract_local_images now skip lines inside ``` fences so a local-image ref inside a code block is not treated as real. FIX 2: md_translate.translate() tracks fence state; callout/columns/mention transforms are suppressed for lines inside ``` ... ``` blocks (pass verbatim). FIX 3: main() catches NtnUploadError and prints a clean error + e.stderr before sys.exit(1); body moved to _main_body(parser, args). FIX 4: ntn_files.upload() raises NtnUploadError on empty stdout instead of IndexError, giving a clean message combined with FIX 3. FIX 5: Removed unused write_to_page() (both page paths are inlined). FIX 6: create_page_markdown() omits the 'markdown' key when value is falsy, avoiding empty-markdown sends on DB rows created without --file/--stdin. FIX 7: CLAUDE.md documents --allow-deleting-content scope (markdown engine only). TDD: Added test_fence_passthrough_no_transform (md_translate, suite now 8) and test_local_image_inside_fence_ignored (engine_routing, suite now 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.8 KiB
Python
136 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
||
"""Translate the notion-writer markdown dialect into Notion enhanced
|
||
markdown for the markdown write engine. Pure functions, no I/O."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import List
|
||
|
||
# NOTE: `notion_writer` is imported lazily inside _translate_mentions to avoid
|
||
# a circular import (notion_writer imports this module at its top level).
|
||
|
||
# Skill alert type -> (emoji, Notion enhanced-markdown background color)
|
||
CALLOUT_MAP = {
|
||
"NOTE": ("ℹ️", "blue_bg"),
|
||
"TIP": ("💡", "green_bg"),
|
||
"IMPORTANT": ("☝️", "purple_bg"),
|
||
"WARNING": ("⚠️", "yellow_bg"),
|
||
"CAUTION": ("🚨", "red_bg"),
|
||
}
|
||
_ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
|
||
_MENTION_RE = re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')
|
||
|
||
|
||
def _translate_mentions(text: str) -> str:
|
||
from notion_writer import extract_notion_id, format_id_with_dashes # lazy: breaks import cycle
|
||
|
||
def repl(m: "re.Match") -> str:
|
||
title, target = m.group(1), m.group(2)
|
||
page_id = extract_notion_id(target)
|
||
if page_id:
|
||
return (f'<mention-page url="{format_id_with_dashes(page_id)}">'
|
||
f'{title}</mention-page>')
|
||
return f"@{title}"
|
||
return _MENTION_RE.sub(repl, text)
|
||
|
||
|
||
def _indent(lines: List[str]) -> List[str]:
|
||
return ["\t" + ln if ln.strip() else ln for ln in lines]
|
||
|
||
|
||
def translate(content: str) -> str:
|
||
lines = content.split("\n")
|
||
out: List[str] = []
|
||
i = 0
|
||
in_fence = False
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
|
||
# Fence tracking: pass through unchanged, toggle state
|
||
if line.strip().startswith("```"):
|
||
in_fence = not in_fence
|
||
out.append(line)
|
||
i += 1
|
||
continue
|
||
|
||
# Inside fence: pass through verbatim, no transformation
|
||
if in_fence:
|
||
out.append(line)
|
||
i += 1
|
||
continue
|
||
|
||
# Callout: > [!TYPE] then contiguous > body lines
|
||
if line.lstrip().startswith(">"):
|
||
body_first = line.lstrip()[1:].strip()
|
||
alert = _ALERT_RE.match(body_first)
|
||
if alert:
|
||
emoji, color = CALLOUT_MAP[alert.group(1)]
|
||
i += 1
|
||
body: List[str] = []
|
||
while i < len(lines) and lines[i].lstrip().startswith(">"):
|
||
body.append(lines[i].lstrip()[1:].lstrip())
|
||
i += 1
|
||
out.append(f'<callout icon="{emoji}" color="{color}">')
|
||
out.extend(_indent([_translate_mentions(b) for b in body]))
|
||
out.append("</callout>")
|
||
continue
|
||
|
||
# Columns: ::: columns / ::: column / :::
|
||
# State machine: "::: column" opens a column; ":::" closes a column
|
||
# when one is open, otherwise closes the wrapper. The Pandoc layout
|
||
# emits N "::: column" opens, N ":::" column-closes, then one final
|
||
# ":::" wrapper-close.
|
||
if line.strip() == "::: columns":
|
||
i += 1
|
||
cols: List[List[str]] = []
|
||
cur: List[str] = None
|
||
while i < len(lines):
|
||
s = lines[i].strip()
|
||
if s == "::: column":
|
||
if cur is not None:
|
||
cols.append(cur)
|
||
cur = []
|
||
i += 1
|
||
elif s == ":::":
|
||
if cur is not None: # close the open column
|
||
cols.append(cur)
|
||
cur = None
|
||
i += 1
|
||
else: # close the wrapper
|
||
i += 1
|
||
break
|
||
else:
|
||
if cur is not None:
|
||
cur.append(lines[i])
|
||
i += 1
|
||
out.append("<columns>")
|
||
for col in cols:
|
||
out.append("\t<column>")
|
||
out.extend(_indent(_indent(
|
||
[_translate_mentions(c) for c in col])))
|
||
out.append("\t</column>")
|
||
out.append("</columns>")
|
||
continue
|
||
|
||
# Toggle: <details><summary>..</summary> body </details>
|
||
if line.strip() == "<details>":
|
||
out.append("<details>")
|
||
i += 1
|
||
if i < len(lines) and lines[i].lstrip().startswith("<summary>"):
|
||
out.append(lines[i].strip())
|
||
i += 1
|
||
body = []
|
||
while i < len(lines) and lines[i].strip() != "</details>":
|
||
body.append(_translate_mentions(lines[i]))
|
||
i += 1
|
||
out.extend(_indent(body))
|
||
out.append("</details>")
|
||
if i < len(lines): # skip closing </details>
|
||
i += 1
|
||
continue
|
||
|
||
out.append(_translate_mentions(line))
|
||
i += 1
|
||
return "\n".join(out)
|