Compare commits
10 Commits
0a44526761
...
50c6741c76
| Author | SHA1 | Date | |
|---|---|---|---|
| 50c6741c76 | |||
| 759bb20911 | |||
| cf7e649284 | |||
| 5e898f81e7 | |||
| b14701d92c | |||
| aeba10cea4 | |||
| c5a6c3cda0 | |||
| 4bb2b2d918 | |||
| 2ee5809f57 | |||
| 80c9efa282 |
@@ -139,6 +139,19 @@ python notion_writer.py -d DATABASE_URL -t "Entry Title" -f content.md
|
|||||||
| `---` | Divider |
|
| `---` | Divider |
|
||||||
| Paragraphs | Paragraph |
|
| Paragraphs | Paragraph |
|
||||||
|
|
||||||
|
### Engines and image uploads
|
||||||
|
|
||||||
|
Two write engines via `--engine {blocks,markdown}` (default: `blocks`).
|
||||||
|
|
||||||
|
The **blocks engine** (default) converts markdown locally to Notion block objects. Local images (``) are auto-uploaded via the `ntn` CLI and embedded at their original position in the page. Requires `ntn` installed and `ntn login`.
|
||||||
|
|
||||||
|
The **markdown engine** (`--engine markdown`) posts the document through Notion's native enhanced-markdown API (`Notion-Version: 2026-03-11`, set automatically; override with `--notion-version`). The skill's authoring dialect — GitHub alerts (`[!NOTE]`), Pandoc columns (`::: columns`), `<details>` toggles, and `@[mention]` — is auto-translated before posting. Note: local images are appended at the end of the page rather than inline with this engine; use `--engine blocks` when image position matters. Pass `--allow-deleting-content` when `--replace` needs to remove child pages or databases.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Markdown engine — create a DB row from a doc with callouts or columns
|
||||||
|
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
||||||
|
```
|
||||||
|
|
||||||
## Workflow Example
|
## Workflow Example
|
||||||
|
|
||||||
Integrate with Jamie YouTube Manager to log video info:
|
Integrate with Jamie YouTube Manager to log video info:
|
||||||
|
|||||||
@@ -189,6 +189,52 @@ print("Hello")
|
|||||||
|
|
||||||
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
Notion's URL validator requires absolute URLs for link annotations. The parser converts TOC-style anchor links to bold to preserve navigation intent and silently strips relative paths.
|
||||||
|
|
||||||
|
### File uploads
|
||||||
|
|
||||||
|
Standalone local-image lines (``) are auto-uploaded to Notion and embedded as `file_upload` image blocks. Remote images (``) are left as external links unchanged.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- `ntn` CLI installed: `curl -fsSL https://ntn.dev | bash`
|
||||||
|
|
||||||
|
Uploads run as the **same integration** that writes the page (`NOTION_API_KEY`). The script injects `NOTION_API_TOKEN=$NOTION_API_KEY` into every `ntn` subprocess, so the file upload and the page share one identity — no separate `ntn login` or workspace matching is needed. `ntn` only needs to be installed, not logged in.
|
||||||
|
|
||||||
|
### Engines
|
||||||
|
|
||||||
|
Two write engines, selected with `--engine {blocks,markdown}`. Default is `blocks`.
|
||||||
|
|
||||||
|
| Engine | Flag | When to use |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **blocks** (default) | `--engine blocks` | General use; images embed at their exact position; full table + container support |
|
||||||
|
| **markdown** | `--engine markdown` | Richer Notion-native formatting via enhanced-markdown endpoints |
|
||||||
|
|
||||||
|
**blocks engine** converts markdown to Notion block objects locally (via `markdown_to_notion_blocks`). Local images are uploaded via `ntn` and embedded at their exact position in the document.
|
||||||
|
|
||||||
|
**markdown engine** posts the document through Notion's native enhanced-markdown endpoints (`Notion-Version: 2026-03-11`, set automatically). The skill's authoring dialect is auto-translated before posting:
|
||||||
|
|
||||||
|
| Skill dialect | Translated to |
|
||||||
|
|---------------|---------------|
|
||||||
|
| `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | `<callout icon="..." color="...">` |
|
||||||
|
| `::: columns` / `::: column` / `:::` | `<columns><column>...</column></columns>` |
|
||||||
|
| `<details><summary>...</summary>` | `<details>` (Notion toggle) |
|
||||||
|
| `@[Title](id-or-url)` | `<mention-page url="...">` |
|
||||||
|
|
||||||
|
**Local image limitation with `--engine markdown`**: local images cannot be placed inline via the enhanced-markdown API. They are appended at the end of the page as a second write pass. Use `--engine blocks` when image placement matters.
|
||||||
|
|
||||||
|
**`--notion-version`**: Override the API version for the markdown engine (default: `2026-03-11`).
|
||||||
|
|
||||||
|
**`--allow-deleting-content`**: Required when `--replace` needs to delete child pages or databases under the target page; the Notion API refuses such deletions unless this flag is present. Applies only to the markdown engine's `--replace` path (`--engine markdown --replace`); has no effect with `--engine blocks`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Markdown engine, create a row from a doc with callouts/columns
|
||||||
|
python notion_writer.py -d DB_URL -t "Notes" --engine markdown -f notes.md
|
||||||
|
|
||||||
|
# Blocks engine with a local image (auto-uploaded via ntn)
|
||||||
|
python notion_writer.py -p PAGE_URL -f post.md # post.md contains 
|
||||||
|
|
||||||
|
# Markdown engine, replace page allowing child deletion
|
||||||
|
python notion_writer.py -p PAGE_URL -f doc.md --replace --engine markdown --allow-deleting-content
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -398,9 +444,10 @@ python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Version 1.2.0 | Claude Code | 2026-04-27*
|
*Version 1.3.0 | Claude Code | 2026-06-27*
|
||||||
|
|
||||||
Changelog:
|
Changelog:
|
||||||
|
- 1.3.0 — Local file/image uploads via the `ntn` CLI (`` → file_upload image blocks). New `--engine markdown` path writing through Notion's native enhanced-markdown endpoints with a dialect translator. Added `--notion-version` and `--allow-deleting-content`.
|
||||||
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
|
||||||
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
|
||||||
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
- 1.0.0 — Initial release with markdown→Notion block conversion.
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIErrorCode, APIResponseError
|
from notion_client.errors import APIErrorCode, APIResponseError
|
||||||
|
|
||||||
|
|
||||||
def make_client(api_key: str) -> Client:
|
def make_client(api_key: str, notion_version: str = None) -> Client:
|
||||||
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
|
"""Build a sync Notion client. Pass notion_version to override the SDK
|
||||||
|
default (needed: 2026-03-11 for the markdown content endpoints)."""
|
||||||
|
if notion_version:
|
||||||
|
return Client(auth=api_key, notion_version=notion_version)
|
||||||
return Client(auth=api_key)
|
return Client(auth=api_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +213,10 @@ def explain_api_error(exc: APIResponseError, context: str = "") -> str:
|
|||||||
"https://www.notion.so/my-integrations."
|
"https://www.notion.so/my-integrations."
|
||||||
)
|
)
|
||||||
if code == APIErrorCode.ValidationError:
|
if code == APIErrorCode.ValidationError:
|
||||||
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}"
|
msg = exc.body.get('message', str(exc))
|
||||||
|
if 'delet' in msg.lower():
|
||||||
|
msg += " — re-run with --allow-deleting-content to permit this."
|
||||||
|
return f"Validation error{suffix}: {msg}"
|
||||||
if code == APIErrorCode.RateLimited:
|
if code == APIErrorCode.RateLimited:
|
||||||
return f"Rate limited{suffix}. Back off and retry."
|
return f"Rate limited{suffix}. Back off and retry."
|
||||||
return f"Notion API error [{code}]{suffix}: {exc}"
|
return f"Notion API error [{code}]{suffix}: {exc}"
|
||||||
@@ -253,3 +259,33 @@ def find_existing_page(
|
|||||||
)
|
)
|
||||||
results = response.get("results") or []
|
results = response.get("results") or []
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_page_markdown(client, parent, properties, markdown):
|
||||||
|
"""POST /v1/pages with the enhanced-markdown body param.
|
||||||
|
`markdown` is only included in the body when non-empty to avoid sending
|
||||||
|
a blank markdown field when no --file/--stdin was supplied."""
|
||||||
|
body: Dict[str, Any] = {"parent": parent, "properties": properties}
|
||||||
|
if markdown:
|
||||||
|
body["markdown"] = markdown
|
||||||
|
return client.request(path="pages", method="POST", body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def append_markdown(client, page_id, markdown):
|
||||||
|
"""PATCH /v1/pages/:id/markdown — append at end (insert_content)."""
|
||||||
|
return client.request(
|
||||||
|
path=f"pages/{page_id}/markdown", method="PATCH",
|
||||||
|
body={"type": "insert_content",
|
||||||
|
"insert_content": {"content": markdown,
|
||||||
|
"position": {"type": "end"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_markdown(client, page_id, markdown, allow_deleting=False):
|
||||||
|
"""PATCH /v1/pages/:id/markdown — replace all content."""
|
||||||
|
return client.request(
|
||||||
|
path=f"pages/{page_id}/markdown", method="PATCH",
|
||||||
|
body={"type": "replace_content",
|
||||||
|
"replace_content": {"new_str": markdown,
|
||||||
|
"allow_deleting_content": allow_deleting}},
|
||||||
|
)
|
||||||
|
|||||||
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
135
custom-skills/32-notion-writer/code/scripts/md_translate.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/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)
|
||||||
@@ -17,6 +17,11 @@ from notion_client import Client
|
|||||||
from notion_client.errors import APIResponseError
|
from notion_client.errors import APIResponseError
|
||||||
|
|
||||||
import _notion_compat as compat
|
import _notion_compat as compat
|
||||||
|
import ntn_files
|
||||||
|
from ntn_files import NtnUploadError
|
||||||
|
import md_translate
|
||||||
|
|
||||||
|
MARKDOWN_NOTION_VERSION = "2026-03-11"
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv(Path(__file__).parent / '.env')
|
load_dotenv(Path(__file__).parent / '.env')
|
||||||
@@ -56,6 +61,7 @@ def format_id_with_dashes(raw_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$')
|
||||||
|
IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$')
|
||||||
|
|
||||||
|
|
||||||
def _is_table_row(line: str) -> bool:
|
def _is_table_row(line: str) -> bool:
|
||||||
@@ -210,6 +216,13 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
|
|||||||
blocks.append(create_column_list_block(column_blocks))
|
blocks.append(create_column_list_block(column_blocks))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
image_match = IMAGE_RE.match(line)
|
||||||
|
if image_match:
|
||||||
|
blocks.append(create_image_block(
|
||||||
|
image_match.group(1), image_match.group(2)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
|
||||||
header_cells = _split_table_row(line)
|
header_cells = _split_table_row(line)
|
||||||
i += 2
|
i += 2
|
||||||
@@ -507,6 +520,55 @@ def create_divider_block() -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_image_block(alt: str, target: str) -> Dict[str, Any]:
|
||||||
|
"""Image block in external shape. Local targets are converted to
|
||||||
|
file_upload shape later by ntn_files.materialize_local_media."""
|
||||||
|
block: Dict[str, Any] = {
|
||||||
|
"object": "block",
|
||||||
|
"type": "image",
|
||||||
|
"image": {"type": "external", "external": {"url": target}},
|
||||||
|
}
|
||||||
|
if alt:
|
||||||
|
block["image"]["caption"] = parse_rich_text(alt)
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def _content_has_local_images(content: str) -> bool:
|
||||||
|
in_fence = False
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip().startswith('```'):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue # fence delimiter lines are never image lines
|
||||||
|
if in_fence:
|
||||||
|
continue
|
||||||
|
m = IMAGE_RE.match(line)
|
||||||
|
if m and not m.group(2).startswith(('http://', 'https://')):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def extract_local_images(content: str):
|
||||||
|
"""Remove standalone LOCAL image lines; keep remote ones inline.
|
||||||
|
Returns (content_without_local_images, [(alt, target), ...]).
|
||||||
|
Lines inside fenced code blocks are passed through unchanged."""
|
||||||
|
kept, imgs = [], []
|
||||||
|
in_fence = False
|
||||||
|
for line in content.split('\n'):
|
||||||
|
if line.strip().startswith('```'):
|
||||||
|
in_fence = not in_fence
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
if in_fence:
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
m = IMAGE_RE.match(line)
|
||||||
|
if m and not m.group(2).startswith(('http://', 'https://')):
|
||||||
|
imgs.append((m.group(1), m.group(2)))
|
||||||
|
continue
|
||||||
|
kept.append(line)
|
||||||
|
return "\n".join(kept), imgs
|
||||||
|
|
||||||
|
|
||||||
def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> 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.
|
"""Build a Notion `table` block with header + body rows.
|
||||||
|
|
||||||
@@ -746,21 +808,6 @@ def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
|
|
||||||
"""Write markdown content to a Notion page."""
|
|
||||||
blocks = markdown_to_notion_blocks(markdown_content)
|
|
||||||
|
|
||||||
if not blocks:
|
|
||||||
print("No content to write")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if mode == 'replace':
|
|
||||||
if not clear_page_content(notion, page_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return append_to_page(notion, page_id, blocks)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Push markdown content to Notion pages or databases',
|
description='Push markdown content to Notion pages or databases',
|
||||||
@@ -796,9 +843,28 @@ Examples:
|
|||||||
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
parser.add_argument('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
|
||||||
help='List accessible pages and/or databases (default: all)')
|
help='List accessible pages and/or databases (default: all)')
|
||||||
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
parser.add_argument('--info', action='store_true', help='Show page/database info')
|
||||||
|
parser.add_argument('--engine', choices=['blocks', 'markdown'],
|
||||||
|
default='blocks',
|
||||||
|
help='Write engine: blocks (default) or markdown')
|
||||||
|
parser.add_argument('--notion-version', dest='notion_version',
|
||||||
|
help='Override Notion API version')
|
||||||
|
parser.add_argument('--allow-deleting-content', dest='allow_deleting',
|
||||||
|
action='store_true',
|
||||||
|
help='Markdown replace may delete child pages/dbs')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_main_body(parser, args)
|
||||||
|
except NtnUploadError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
if e.stderr:
|
||||||
|
print(e.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _main_body(parser, args):
|
||||||
|
"""Body of main() after argparse; separated so NtnUploadError can be caught cleanly."""
|
||||||
if not NOTION_TOKEN:
|
if not NOTION_TOKEN:
|
||||||
print("Error: NOTION_API_KEY not set in environment.")
|
print("Error: NOTION_API_KEY not set in environment.")
|
||||||
print("Preferred: fetch from 1Password at runtime —")
|
print("Preferred: fetch from 1Password at runtime —")
|
||||||
@@ -885,23 +951,64 @@ Examples:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
content = file_path.read_text(encoding='utf-8')
|
content = file_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
base_dir = Path(args.file).parent if args.file else Path.cwd()
|
||||||
|
|
||||||
|
def _md_client():
|
||||||
|
version = args.notion_version or MARKDOWN_NOTION_VERSION
|
||||||
|
return compat.make_client(NOTION_TOKEN, notion_version=version)
|
||||||
|
|
||||||
|
def _upload_blocks_for(images):
|
||||||
|
"""Build + materialize image blocks for two-phase markdown writes."""
|
||||||
|
blocks = [create_image_block(alt, target) for alt, target in images]
|
||||||
|
return ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
||||||
|
|
||||||
# Write to page
|
# Write to page
|
||||||
if args.page:
|
if args.page:
|
||||||
if not content:
|
if not content:
|
||||||
print("Error: No content provided. Use --file or --stdin")
|
print("Error: No content provided. Use --file or --stdin")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
page_id = extract_notion_id(args.page)
|
page_id = extract_notion_id(args.page)
|
||||||
if not page_id:
|
if not page_id:
|
||||||
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
print(f"Error: Invalid Notion page URL/ID: {args.page}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
formatted_id = format_id_with_dashes(page_id)
|
||||||
|
|
||||||
mode = 'replace' if args.replace else 'append'
|
if args.engine == 'markdown':
|
||||||
print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...")
|
if _content_has_local_images(content):
|
||||||
|
ntn_files.preflight()
|
||||||
|
md_body, local_imgs = extract_local_images(content)
|
||||||
|
md_body = md_translate.translate(md_body)
|
||||||
|
mc = _md_client()
|
||||||
|
try:
|
||||||
|
if args.replace:
|
||||||
|
compat.replace_markdown(mc, formatted_id, md_body,
|
||||||
|
allow_deleting=args.allow_deleting)
|
||||||
|
else:
|
||||||
|
compat.append_markdown(mc, formatted_id, md_body)
|
||||||
|
except APIResponseError as exc:
|
||||||
|
print(f"Error: {compat.explain_api_error(exc, formatted_id)}")
|
||||||
|
sys.exit(1)
|
||||||
|
if local_imgs and not append_to_page(notion, page_id, _upload_blocks_for(local_imgs)):
|
||||||
|
print("❌ Text was written but image append failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Successfully wrote content to page (markdown engine)")
|
||||||
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
|
return
|
||||||
|
|
||||||
if write_to_page(notion, page_id, content, mode):
|
# blocks engine (default)
|
||||||
print(f"✅ Successfully wrote content to page")
|
blocks = markdown_to_notion_blocks(content)
|
||||||
formatted_id = format_id_with_dashes(page_id)
|
if _content_has_local_images(content):
|
||||||
|
ntn_files.preflight()
|
||||||
|
blocks = ntn_files.materialize_local_media(blocks, base_dir, ntn_files.upload)
|
||||||
|
if not blocks:
|
||||||
|
print("No content to write")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"{'Replacing' if args.replace else 'Appending'} content to page...")
|
||||||
|
if args.replace and not clear_page_content(notion, page_id):
|
||||||
|
print("❌ Failed to write content")
|
||||||
|
sys.exit(1)
|
||||||
|
if append_to_page(notion, page_id, blocks):
|
||||||
|
print("✅ Successfully wrote content to page")
|
||||||
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
print(f" https://notion.so/{formatted_id.replace('-', '')}")
|
||||||
else:
|
else:
|
||||||
print("❌ Failed to write content")
|
print("❌ Failed to write content")
|
||||||
@@ -960,6 +1067,8 @@ Examples:
|
|||||||
|
|
||||||
content_blocks = markdown_to_notion_blocks(content) if content else None
|
content_blocks = markdown_to_notion_blocks(content) if content else None
|
||||||
|
|
||||||
|
existing = None
|
||||||
|
|
||||||
# Upsert path: look for existing row by the named property
|
# Upsert path: look for existing row by the named property
|
||||||
if args.upsert_by:
|
if args.upsert_by:
|
||||||
if args.upsert_by not in schema_props:
|
if args.upsert_by not in schema_props:
|
||||||
@@ -980,19 +1089,52 @@ Examples:
|
|||||||
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if existing:
|
if args.engine == 'markdown':
|
||||||
page_id = existing['id']
|
md_content = content or ""
|
||||||
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
if _content_has_local_images(md_content):
|
||||||
if not update_page_properties(notion, page_id, properties):
|
ntn_files.preflight()
|
||||||
|
md_body, local_imgs = extract_local_images(md_content)
|
||||||
|
md_body = md_translate.translate(md_body)
|
||||||
|
mc = _md_client()
|
||||||
|
try:
|
||||||
|
if args.upsert_by and existing:
|
||||||
|
compat.replace_markdown(mc, existing['id'], md_body,
|
||||||
|
allow_deleting=args.allow_deleting)
|
||||||
|
if not update_page_properties(notion, existing['id'], properties):
|
||||||
|
sys.exit(1)
|
||||||
|
new_id = existing['id']
|
||||||
|
else:
|
||||||
|
parent = compat.build_data_source_parent(data_source_id)
|
||||||
|
result = compat.create_page_markdown(mc, parent, properties, md_body)
|
||||||
|
new_id = result['id']
|
||||||
|
except APIResponseError as exc:
|
||||||
|
print(f"Error: {compat.explain_api_error(exc)}")
|
||||||
|
sys.exit(1)
|
||||||
|
if local_imgs and not append_to_page(notion, new_id, _upload_blocks_for(local_imgs)):
|
||||||
|
print("❌ Text was written but image append failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("✅ Successfully wrote database row (markdown engine)")
|
||||||
|
print(f" https://notion.so/{new_id.replace('-', '')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if content_blocks and _content_has_local_images(content or ""):
|
||||||
|
ntn_files.preflight()
|
||||||
|
content_blocks = ntn_files.materialize_local_media(
|
||||||
|
content_blocks, base_dir, ntn_files.upload)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
page_id = existing['id']
|
||||||
|
print(f"Updating existing row (matched on {args.upsert_by}={lookup_value!r})...")
|
||||||
|
if not update_page_properties(notion, page_id, properties):
|
||||||
|
sys.exit(1)
|
||||||
|
if content_blocks:
|
||||||
|
if not clear_page_content(notion, page_id):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if content_blocks:
|
if not append_to_page(notion, page_id, content_blocks):
|
||||||
if not clear_page_content(notion, page_id):
|
sys.exit(1)
|
||||||
sys.exit(1)
|
print(f"✅ Successfully updated database row")
|
||||||
if not append_to_page(notion, page_id, content_blocks):
|
print(f" https://notion.so/{page_id.replace('-', '')}")
|
||||||
sys.exit(1)
|
return
|
||||||
print(f"✅ Successfully updated database row")
|
|
||||||
print(f" https://notion.so/{page_id.replace('-', '')}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Creating database row...")
|
print(f"Creating database row...")
|
||||||
row_id = create_database_row(notion, data_source_id, properties, content_blocks)
|
row_id = create_database_row(notion, data_source_id, properties, content_blocks)
|
||||||
|
|||||||
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
154
custom-skills/32-notion-writer/code/scripts/ntn_files.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local file uploads to Notion via the `ntn` CLI.
|
||||||
|
|
||||||
|
Owns all subprocess I/O for the skill. The CLI handles the full File Upload
|
||||||
|
lifecycle (create -> send bytes -> complete) and prints the upload ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Callable, Any
|
||||||
|
|
||||||
|
_WORKSPACE: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ntn_env():
|
||||||
|
"""Env for ntn subprocesses: force ntn to authenticate as the SAME
|
||||||
|
integration the script uses (NOTION_API_KEY/NOTION_TOKEN), so an uploaded
|
||||||
|
file and the page that references it share one identity. Notion scopes
|
||||||
|
file uploads to the creating integration, so a mismatch makes the upload
|
||||||
|
un-attachable."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
token = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_TOKEN")
|
||||||
|
if token:
|
||||||
|
env["NOTION_API_TOKEN"] = token
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
class NtnUploadError(Exception):
|
||||||
|
"""Raised when `ntn` is unavailable or a file upload fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, path: str = "", stderr: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
self.path = path
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def preflight() -> Dict[str, str]:
|
||||||
|
"""Verify `ntn` is installed and logged in; return its target workspace.
|
||||||
|
|
||||||
|
Cached for the process. Raises NtnUploadError with an actionable hint
|
||||||
|
on failure. Prints one informational line naming the workspace `ntn`
|
||||||
|
targets (file uploads are workspace-scoped).
|
||||||
|
"""
|
||||||
|
global _WORKSPACE
|
||||||
|
if _WORKSPACE is not None:
|
||||||
|
return _WORKSPACE
|
||||||
|
|
||||||
|
if shutil.which("ntn") is None:
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn CLI not found. Install it with: "
|
||||||
|
"curl -fsSL https://ntn.dev | bash"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["ntn", "api", "v1/users/me"],
|
||||||
|
capture_output=True, text=True, env=_ntn_env(),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn is not logged in. Run: ntn login\n" + result.stderr.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
me = json.loads(result.stdout)
|
||||||
|
bot = me.get("bot", {})
|
||||||
|
info = {
|
||||||
|
"workspace_name": bot.get("workspace_name", "unknown"),
|
||||||
|
"workspace_id": bot.get("workspace_id", "unknown"),
|
||||||
|
}
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
info = {"workspace_name": "unknown", "workspace_id": "unknown"}
|
||||||
|
|
||||||
|
print(f'ntn -> workspace "{info["workspace_name"]}"', file=sys.stderr)
|
||||||
|
_WORKSPACE = info
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def upload(path: Path) -> str:
|
||||||
|
"""Upload a local file via `ntn files create --plain`; return its id."""
|
||||||
|
path = Path(path)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ntn", "files", "create", "--plain"],
|
||||||
|
stdin=fh, capture_output=True, text=True, env=_ntn_env(),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise NtnUploadError(
|
||||||
|
f"ntn upload failed for {path}", path=str(path),
|
||||||
|
stderr=result.stderr.strip(),
|
||||||
|
)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
raise NtnUploadError(
|
||||||
|
"ntn returned no output", path=str(path), stderr=result.stderr.strip()
|
||||||
|
)
|
||||||
|
first_line = result.stdout.strip().splitlines()[0]
|
||||||
|
upload_id = first_line.split("\t")[0].strip()
|
||||||
|
return upload_id
|
||||||
|
|
||||||
|
|
||||||
|
_REMOTE_PREFIXES = ("http://", "https://")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_local(url: str, base_dir: Path) -> Optional[Path]:
|
||||||
|
"""Return the resolved path for a local media url, or None if remote."""
|
||||||
|
if url.startswith(_REMOTE_PREFIXES):
|
||||||
|
return None
|
||||||
|
p = Path(url)
|
||||||
|
return p if p.is_absolute() else Path(base_dir) / p
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_local_media(
|
||||||
|
blocks: List[Dict[str, Any]],
|
||||||
|
base_dir: Path,
|
||||||
|
upload_fn: Callable[[Path], str] = upload,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Walk blocks (and nested children); upload local image files and
|
||||||
|
rewrite them to file_upload shape. Remote images are left as-is."""
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("type") == "image":
|
||||||
|
img = block["image"]
|
||||||
|
if img.get("type") == "external":
|
||||||
|
url = img.get("external", {}).get("url", "")
|
||||||
|
resolved = _resolve_local(url, base_dir)
|
||||||
|
if resolved is not None:
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise NtnUploadError(
|
||||||
|
f"image references missing file: {url}",
|
||||||
|
path=str(resolved),
|
||||||
|
)
|
||||||
|
upload_id = upload_fn(resolved)
|
||||||
|
caption = img.get("caption")
|
||||||
|
new_img: Dict[str, Any] = {
|
||||||
|
"type": "file_upload",
|
||||||
|
"file_upload": {"id": upload_id},
|
||||||
|
}
|
||||||
|
if caption:
|
||||||
|
new_img["caption"] = caption
|
||||||
|
block["image"] = new_img
|
||||||
|
# Recurse into any nested children list.
|
||||||
|
btype = block.get("type")
|
||||||
|
nested = block.get(btype, {}) if isinstance(block.get(btype), dict) else {}
|
||||||
|
if isinstance(nested.get("children"), list):
|
||||||
|
materialize_local_media(nested["children"], base_dir, upload_fn)
|
||||||
|
if btype == "column_list":
|
||||||
|
for col in nested.get("children", []):
|
||||||
|
col_children = col.get("column", {}).get("children", [])
|
||||||
|
materialize_local_media(col_children, base_dir, upload_fn)
|
||||||
|
return blocks
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for markdown transport helpers — run with `python test_engine_routing.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import _notion_compat as compat
|
||||||
|
import notion_writer
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_client():
|
||||||
|
c = mock.MagicMock()
|
||||||
|
c.request.return_value = {"object": "page", "id": "p1"}
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_page_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
parent = {"type": "data_source_id", "data_source_id": "ds1"}
|
||||||
|
compat.create_page_markdown(c, parent, {"Name": {"title": []}}, "# Hi")
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages"
|
||||||
|
assert kwargs["method"] == "POST"
|
||||||
|
assert kwargs["body"]["markdown"] == "# Hi"
|
||||||
|
assert kwargs["body"]["parent"] == parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
compat.append_markdown(c, "page-123", "## More")
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages/page-123/markdown"
|
||||||
|
assert kwargs["method"] == "PATCH"
|
||||||
|
assert kwargs["body"]["type"] == "insert_content"
|
||||||
|
assert kwargs["body"]["insert_content"]["content"] == "## More"
|
||||||
|
assert kwargs["body"]["insert_content"]["position"] == {"type": "end"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_markdown():
|
||||||
|
c = _fake_client()
|
||||||
|
compat.replace_markdown(c, "page-123", "# Fresh", allow_deleting=True)
|
||||||
|
_, kwargs = c.request.call_args
|
||||||
|
assert kwargs["path"] == "pages/page-123/markdown"
|
||||||
|
assert kwargs["body"]["type"] == "replace_content"
|
||||||
|
assert kwargs["body"]["replace_content"]["new_str"] == "# Fresh"
|
||||||
|
assert kwargs["body"]["replace_content"]["allow_deleting_content"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_local_images_splits():
|
||||||
|
content = ("# Title\n\n\n\n"
|
||||||
|
"\n\nbody")
|
||||||
|
without, imgs = notion_writer.extract_local_images(content)
|
||||||
|
assert imgs == [("local", "./b.png")]
|
||||||
|
assert "" not in without
|
||||||
|
assert "" in without # remote stays
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_has_local_images():
|
||||||
|
assert notion_writer._content_has_local_images("") is True
|
||||||
|
assert notion_writer._content_has_local_images("") is False
|
||||||
|
assert notion_writer._content_has_local_images("no images") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_image_inside_fence_ignored():
|
||||||
|
"""A local image reference inside a fenced code block must NOT be detected
|
||||||
|
as a real image by _content_has_local_images or extracted by extract_local_images."""
|
||||||
|
fenced = "```markdown\n\n```"
|
||||||
|
assert notion_writer._content_has_local_images(fenced) is False, \
|
||||||
|
"_content_has_local_images should return False for image inside fence"
|
||||||
|
without, imgs = notion_writer.extract_local_images(fenced)
|
||||||
|
assert len(imgs) == 0, "extract_local_images should not extract image inside fence"
|
||||||
|
# The fenced lines (including the image line) should be preserved verbatim
|
||||||
|
assert "" in without, "fence content must be preserved in output"
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_create_page_markdown,
|
||||||
|
test_append_markdown,
|
||||||
|
test_replace_markdown,
|
||||||
|
test_extract_local_images_splits,
|
||||||
|
test_content_has_local_images,
|
||||||
|
test_local_image_inside_fence_ignored,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
101
custom-skills/32-notion-writer/code/scripts/test_md_translate.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for md_translate.py — run with `python test_md_translate.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import md_translate
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_basics():
|
||||||
|
src = "# Heading\n\n- item\n\n```python\nx=1\n```"
|
||||||
|
assert md_translate.translate(src) == src
|
||||||
|
|
||||||
|
|
||||||
|
def test_callout_note():
|
||||||
|
out = md_translate.translate("> [!NOTE]\n> Be careful")
|
||||||
|
assert '<callout icon="ℹ️" color="blue_bg">' in out
|
||||||
|
assert "\tBe careful" in out
|
||||||
|
assert "</callout>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_callout_warning_color():
|
||||||
|
out = md_translate.translate("> [!WARNING]\n> Danger")
|
||||||
|
assert 'color="yellow_bg"' in out
|
||||||
|
assert 'icon="⚠️"' in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_columns():
|
||||||
|
src = "::: columns\n::: column\nLeft\n:::\n::: column\nRight\n:::\n:::"
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
assert "<columns>" in out
|
||||||
|
assert out.count("<column>") == 2
|
||||||
|
assert "</columns>" in out
|
||||||
|
assert "\tLeft" in out or "\t\tLeft" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_children_indented():
|
||||||
|
src = "<details>\n<summary>More</summary>\nBody line\n</details>"
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
assert "<summary>More</summary>" in out
|
||||||
|
assert "\tBody line" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_url():
|
||||||
|
out = md_translate.translate(
|
||||||
|
"See @[ADR](https://notion.so/X-abcdef0123456789abcdef0123456789).")
|
||||||
|
assert "<mention-page url=" in out
|
||||||
|
assert ">ADR</mention-page>" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_mention_invalid_plain():
|
||||||
|
out = md_translate.translate("ping @[Bob](not-an-id)")
|
||||||
|
assert "@Bob" in out
|
||||||
|
assert "mention-page" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_fence_passthrough_no_transform():
|
||||||
|
"""Lines inside fenced code blocks must pass through verbatim — no callout,
|
||||||
|
columns, or mention transformation applied."""
|
||||||
|
raw_id = "abcdef0123456789abcdef0123456789"
|
||||||
|
src = (
|
||||||
|
"```\n"
|
||||||
|
"> [!NOTE]\n"
|
||||||
|
"::: columns\n"
|
||||||
|
f"@[Page]({raw_id})\n"
|
||||||
|
"```"
|
||||||
|
)
|
||||||
|
out = md_translate.translate(src)
|
||||||
|
# No transformation should have occurred
|
||||||
|
assert "<callout" not in out, "callout must not be emitted inside fence"
|
||||||
|
assert "<columns>" not in out, "columns must not be emitted inside fence"
|
||||||
|
assert "<mention-page" not in out, "mention must not be emitted inside fence"
|
||||||
|
# Original lines must be present verbatim
|
||||||
|
assert "> [!NOTE]" in out
|
||||||
|
assert "::: columns" in out
|
||||||
|
assert f"@[Page]({raw_id})" in out
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_passthrough_basics,
|
||||||
|
test_callout_note,
|
||||||
|
test_callout_warning_color,
|
||||||
|
test_columns,
|
||||||
|
test_toggle_children_indented,
|
||||||
|
test_mention_url,
|
||||||
|
test_mention_invalid_plain,
|
||||||
|
test_fence_passthrough_no_transform,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
152
custom-skills/32-notion-writer/code/scripts/test_ntn_files.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for ntn_files.py — run with `python test_ntn_files.py`."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
import ntn_files
|
||||||
|
from ntn_files import NtnUploadError
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_cache():
|
||||||
|
ntn_files._WORKSPACE = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_missing_ntn():
|
||||||
|
_reset_cache()
|
||||||
|
with mock.patch("shutil.which", return_value=None):
|
||||||
|
try:
|
||||||
|
ntn_files.preflight()
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert "ntn" in str(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_returns_workspace():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout='{"bot":{"workspace_name":"D.Intelligence",'
|
||||||
|
'"workspace_id":"ws-123"}}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
with mock.patch("shutil.which", return_value="/usr/bin/ntn"), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
info = ntn_files.preflight()
|
||||||
|
assert info["workspace_name"] == "D.Intelligence"
|
||||||
|
assert info["workspace_id"] == "ws-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_returns_id():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout="43833259-72ae-404e-8441-b6577f3159b4\tphoto.png\tuploaded\n",
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
# upload() opens the file before subprocess.run; mock open so the file
|
||||||
|
# need not exist (subprocess.run is mocked and never reads the handle).
|
||||||
|
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
upload_id = ntn_files.upload(Path("/tmp/photo.png"))
|
||||||
|
assert upload_id == "43833259-72ae-404e-8441-b6577f3159b4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_failure_raises():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr="boom: invalid file",
|
||||||
|
)
|
||||||
|
with mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake):
|
||||||
|
try:
|
||||||
|
ntn_files.upload(Path("/tmp/bad.png"))
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert e.path == "/tmp/bad.png"
|
||||||
|
assert "boom" in e.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _img(url):
|
||||||
|
return {"object": "block", "type": "image",
|
||||||
|
"image": {"type": "external", "external": {"url": url}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_remote_untouched():
|
||||||
|
blocks = [_img("https://ex.com/a.png")]
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
blocks, Path("/tmp"), upload_fn=lambda p: "SHOULD_NOT_RUN")
|
||||||
|
assert out[0]["image"]["type"] == "external"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_local_uploaded(tmp_path=None):
|
||||||
|
import tempfile, os
|
||||||
|
d = Path(tempfile.mkdtemp())
|
||||||
|
(d / "x.png").write_bytes(b"fake")
|
||||||
|
blocks = [_img("x.png")]
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
blocks, d, upload_fn=lambda p: "UP123")
|
||||||
|
assert out[0]["image"]["type"] == "file_upload"
|
||||||
|
assert out[0]["image"]["file_upload"]["id"] == "UP123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_nested_children():
|
||||||
|
import tempfile
|
||||||
|
d = Path(tempfile.mkdtemp())
|
||||||
|
(d / "y.png").write_bytes(b"fake")
|
||||||
|
toggle = {"object": "block", "type": "toggle",
|
||||||
|
"toggle": {"rich_text": [], "children": [_img("y.png")]}}
|
||||||
|
out = ntn_files.materialize_local_media(
|
||||||
|
[toggle], d, upload_fn=lambda p: "UPNESTED")
|
||||||
|
child = out[0]["toggle"]["children"][0]
|
||||||
|
assert child["image"]["file_upload"]["id"] == "UPNESTED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_missing_file_raises():
|
||||||
|
blocks = [_img("nope.png")]
|
||||||
|
try:
|
||||||
|
ntn_files.materialize_local_media(
|
||||||
|
blocks, Path("/tmp"), upload_fn=lambda p: "x")
|
||||||
|
assert False, "expected NtnUploadError"
|
||||||
|
except NtnUploadError as e:
|
||||||
|
assert "nope.png" in str(e) or "nope.png" in e.path
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_passes_token_env():
|
||||||
|
_reset_cache()
|
||||||
|
fake = subprocess.CompletedProcess(args=[], returncode=0,
|
||||||
|
stdout="ID123\tphoto.png\tuploaded\n", stderr="")
|
||||||
|
with mock.patch.dict("os.environ", {"NOTION_API_KEY": "tok-abc"}, clear=False), \
|
||||||
|
mock.patch("builtins.open", mock.mock_open(read_data=b"x")), \
|
||||||
|
mock.patch("subprocess.run", return_value=fake) as m:
|
||||||
|
ntn_files.upload(Path("/tmp/p.png"))
|
||||||
|
passed_env = m.call_args.kwargs["env"]
|
||||||
|
assert passed_env["NOTION_API_TOKEN"] == "tok-abc"
|
||||||
|
|
||||||
|
|
||||||
|
def run_all():
|
||||||
|
tests = [
|
||||||
|
test_preflight_missing_ntn,
|
||||||
|
test_preflight_returns_workspace,
|
||||||
|
test_upload_returns_id,
|
||||||
|
test_upload_failure_raises,
|
||||||
|
test_materialize_remote_untouched,
|
||||||
|
test_materialize_local_uploaded,
|
||||||
|
test_materialize_nested_children,
|
||||||
|
test_materialize_missing_file_raises,
|
||||||
|
test_upload_passes_token_env,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"\n{t.__name__}")
|
||||||
|
t()
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"✅ All {len(tests)} tests passed")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_all()
|
||||||
@@ -413,6 +413,28 @@ def test_no_literal_markers_leak():
|
|||||||
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
_assert("bold" in joined and "link" in joined, "visible words preserved")
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_remote_external():
|
||||||
|
blocks = markdown_to_notion_blocks("")
|
||||||
|
assert len(blocks) == 1
|
||||||
|
b = blocks[0]
|
||||||
|
assert b["type"] == "image"
|
||||||
|
assert b["image"]["type"] == "external"
|
||||||
|
assert b["image"]["external"]["url"] == "https://ex.com/c.png"
|
||||||
|
assert b["image"]["caption"][0]["text"]["content"] == "a chart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_local_external_shape_preupload():
|
||||||
|
blocks = markdown_to_notion_blocks("")
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0]["image"]["external"]["url"] == "./pics/x.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_only_when_standalone():
|
||||||
|
# An inline bang-bracket inside prose is NOT an image block.
|
||||||
|
blocks = markdown_to_notion_blocks("see  inline")
|
||||||
|
assert blocks[0]["type"] == "paragraph"
|
||||||
|
|
||||||
|
|
||||||
def run_all():
|
def run_all():
|
||||||
tests = [
|
tests = [
|
||||||
test_rich_text_plain,
|
test_rich_text_plain,
|
||||||
@@ -447,6 +469,9 @@ def run_all():
|
|||||||
test_rich_text_relative_link_becomes_plain,
|
test_rich_text_relative_link_becomes_plain,
|
||||||
test_rich_text_absolute_link_preserved,
|
test_rich_text_absolute_link_preserved,
|
||||||
test_no_literal_markers_leak,
|
test_no_literal_markers_leak,
|
||||||
|
test_image_remote_external,
|
||||||
|
test_image_local_external_shape_preupload,
|
||||||
|
test_image_only_when_standalone,
|
||||||
]
|
]
|
||||||
for t in tests:
|
for t in tests:
|
||||||
print(f"\n{t.__name__}")
|
print(f"\n{t.__name__}")
|
||||||
|
|||||||
Reference in New Issue
Block a user