Files
our-claude-skills/custom-skills/32-notion-writer/code/scripts/md_translate.py
Andrew Yim 759bb20911 fix(notion-writer): fence-aware image/translate, NtnUploadError boundary, dead-code removal
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>
2026-06-27 10:33:09 +09:00

136 lines
4.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)