feat(notion): migrate 31+32 to API 2025-09-03 + add property writes, upsert, anchor-link fix

Phase 1 — docs alignment:
- Fix path drift in 32 CLAUDE.md (02-notion-writer → 32-notion-writer)
- Align env var to NOTION_API_KEY in 31 docs (NOTION_TOKEN still accepted)
- Document Phase 3 roadmap in 31 (metadata-aware migration, Notion-as-RAG)

Phase 2 — multi-source database support:
- New 32/scripts/_notion_compat.py: client factory, data_source resolution
  with cache, property coercion, find_existing_page (for upsert),
  explain_api_error for friendlier failure messages
- 32 notion_writer.py: drop the 2022-06-28 pin, route schema introspection
  through data_sources.retrieve, build data_source_id parent shape, accept
  arbitrary properties via --properties JSON-or-file flag, add --upsert-by
  for idempotent re-runs
- 32 markdown parser: anchor-link fix — Notion's URL validator rejects
  fragment URLs and relative paths; fragments now render as bold to preserve
  TOC nav intent, relatives drop the link, absolute URLs preserved
- 31 async_organizer.py + schema_migrator.py: same data_sources migration
  with cached resolution; pages.create now uses data_source_id parent
- 16/16 parser tests pass (was 13; +3 link-handling regression guards)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 09:57:20 +09:00
parent 2667304bca
commit 144a17c88d
7 changed files with 678 additions and 69 deletions

View File

@@ -79,11 +79,40 @@ python scripts/async_organizer.py --database [ID] --action restructure
Environment variables: Environment variables:
```bash ```bash
NOTION_TOKEN=secret_xxx # Preferred (matches 32-notion-writer)
NOTION_API_KEY=secret_xxx
# Legacy fallback also accepted
# NOTION_TOKEN=secret_xxx
``` ```
The scripts read `NOTION_TOKEN` first, then fall back to `NOTION_API_KEY`. Use `NOTION_API_KEY` for new setups so the same `.env` works across all Notion skills.
## Notes ## Notes
- Always use `--dry-run` first for destructive operations - Always use `--dry-run` first for destructive operations
- Large operations (1000+ pages) use async with progress reporting - Large operations (1000+ pages) use async with progress reporting
- Scripts implement automatic rate limiting - Scripts implement automatic rate limiting
## Roadmap (Phase 3)
The current scripts cover schema migration and async bulk ops. Two larger goals are parked for a future iteration:
### Goal 1 — Metadata-aware page move/integration
Search source pages, compare their property metadata against a target database schema, and move/migrate while transforming property values to fit. Beyond the existing `schema_migrator.py` (which expects a hand-written mapping file), this would:
- Auto-suggest property mappings using name + type similarity
- Surface unmappable properties before the move (no silent data loss)
- Support cross-database moves (not just same-schema migrations)
### Goal 2 — Notion as personal RAG source
Treat Notion as a small, personal knowledge base for AI agents:
- Search pages by query across databases, filter by property
- Merge results into a single context (with source citations back to page URLs)
- Summarize / distill via LLM into agent-ready snippets
- Export as JSONL or markdown for fine-tuning or RAG indexing
This dovetails with `90-reference-curator` (which does the same for web sources) — Phase 3 would make Notion a first-class source type for that pipeline.

View File

@@ -55,6 +55,45 @@ class NotionAsyncOrganizer:
self.semaphore = Semaphore(MAX_CONCURRENT_REQUESTS) self.semaphore = Semaphore(MAX_CONCURRENT_REQUESTS)
self.dry_run = dry_run self.dry_run = dry_run
self.stats = {"fetched": 0, "updated": 0, "created": 0, "errors": 0} self.stats = {"fetched": 0, "updated": 0, "created": 0, "errors": 0}
self._data_source_cache: dict[str, str] = {}
async def _resolve_data_source_id(self, target_id: str) -> str:
"""
Accept either a database_id or data_source_id; return data_source_id.
Caches results so repeated calls within one organizer run don't re-hit
the API. Errors propagate to the caller for proper handling.
"""
if target_id in self._data_source_cache:
return self._data_source_cache[target_id]
from notion_client.errors import APIErrorCode, APIResponseError
try:
await self._rate_limited_request(
self.client.data_sources.retrieve(data_source_id=target_id)
)
self._data_source_cache[target_id] = target_id
return target_id
except APIResponseError as exc:
if exc.code != APIErrorCode.ObjectNotFound:
raise
db = await self._rate_limited_request(
self.client.databases.retrieve(database_id=target_id)
)
sources = db.get("data_sources") or []
if not sources:
raise ValueError(f"Database {target_id} has no data sources attached")
if len(sources) > 1:
names = ", ".join(s.get("name", s["id"]) for s in sources)
raise ValueError(
f"Database {target_id} has multiple data sources ({names}); "
"pass a specific data_source_id"
)
ds_id = sources[0]["id"]
self._data_source_cache[target_id] = ds_id
return ds_id
@retry( @retry(
stop=stop_after_attempt(3), stop=stop_after_attempt(3),
@@ -67,31 +106,33 @@ class NotionAsyncOrganizer:
await asyncio.sleep(REQUEST_DELAY) await asyncio.sleep(REQUEST_DELAY)
return await coro return await coro
async def fetch_database_schema(self, database_id: str) -> dict: async def fetch_database_schema(self, target_id: str) -> dict:
"""Fetch database schema/properties.""" """Fetch full schema (properties) for a database or data source."""
logger.info(f"Fetching database schema: {database_id}") data_source_id = await self._resolve_data_source_id(target_id)
logger.info(f"Fetching schema: {data_source_id}")
response = await self._rate_limited_request( response = await self._rate_limited_request(
self.client.databases.retrieve(database_id=database_id) self.client.data_sources.retrieve(data_source_id=data_source_id)
) )
self.stats["fetched"] += 1 self.stats["fetched"] += 1
return response return response
async def fetch_all_pages( async def fetch_all_pages(
self, self,
database_id: str, target_id: str,
filter_obj: dict | None = None, filter_obj: dict | None = None,
sorts: list | None = None, sorts: list | None = None,
) -> list[dict]: ) -> list[dict]:
"""Fetch all pages from a database with pagination.""" """Fetch all pages from a data source with pagination."""
data_source_id = await self._resolve_data_source_id(target_id)
all_pages = [] all_pages = []
has_more = True has_more = True
start_cursor = None start_cursor = None
logger.info(f"Fetching pages from database: {database_id}") logger.info(f"Fetching pages from data source: {data_source_id}")
while has_more: while has_more:
query_params = { query_params = {
"database_id": database_id, "data_source_id": data_source_id,
"page_size": 100, "page_size": 100,
} }
if start_cursor: if start_cursor:
@@ -102,7 +143,7 @@ class NotionAsyncOrganizer:
query_params["sorts"] = sorts query_params["sorts"] = sorts
response = await self._rate_limited_request( response = await self._rate_limited_request(
self.client.databases.query(**query_params) self.client.data_sources.query(**query_params)
) )
all_pages.extend(response["results"]) all_pages.extend(response["results"])
@@ -287,7 +328,10 @@ async def example_bulk_status_update(
async def main(): async def main():
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser(description="Notion Async Organizer") parser = argparse.ArgumentParser(description="Notion Async Organizer")
parser.add_argument("--database-id", "-d", required=True, help="Database ID") parser.add_argument(
"--database-id", "-d", required=True,
help="Database ID or data source ID (resolved automatically)",
)
parser.add_argument( parser.add_argument(
"--dry-run", action="store_true", help="Preview changes without executing" "--dry-run", action="store_true", help="Preview changes without executing"
) )

View File

@@ -53,6 +53,7 @@ class SchemaMigrator:
"pages_skipped": 0, "pages_skipped": 0,
"errors": 0, "errors": 0,
} }
self._data_source_cache: dict[str, str] = {}
@retry( @retry(
stop=stop_after_attempt(3), stop=stop_after_attempt(3),
@@ -63,24 +64,59 @@ class SchemaMigrator:
await asyncio.sleep(REQUEST_DELAY) await asyncio.sleep(REQUEST_DELAY)
return await coro return await coro
async def get_schema(self, database_id: str) -> dict: async def _resolve_data_source_id(self, target_id: str) -> str:
"""Get database schema.""" """Accept database_id or data_source_id; return data_source_id."""
if target_id in self._data_source_cache:
return self._data_source_cache[target_id]
from notion_client.errors import APIErrorCode, APIResponseError
try:
await self._request(
self.client.data_sources.retrieve(data_source_id=target_id)
)
self._data_source_cache[target_id] = target_id
return target_id
except APIResponseError as exc:
if exc.code != APIErrorCode.ObjectNotFound:
raise
db = await self._request(self.client.databases.retrieve(database_id=target_id))
sources = db.get("data_sources") or []
if not sources:
raise ValueError(f"Database {target_id} has no data sources attached")
if len(sources) > 1:
names = ", ".join(s.get("name", s["id"]) for s in sources)
raise ValueError(
f"Database {target_id} has multiple data sources ({names}); "
"pass a specific data_source_id"
)
ds_id = sources[0]["id"]
self._data_source_cache[target_id] = ds_id
return ds_id
async def get_schema(self, target_id: str) -> dict:
"""Get full schema for a database or data source."""
data_source_id = await self._resolve_data_source_id(target_id)
return await self._request( return await self._request(
self.client.databases.retrieve(database_id=database_id) self.client.data_sources.retrieve(data_source_id=data_source_id)
) )
async def fetch_all_pages(self, database_id: str) -> list[dict]: async def fetch_all_pages(self, target_id: str) -> list[dict]:
"""Fetch all pages from source database.""" """Fetch all pages from source data source."""
data_source_id = await self._resolve_data_source_id(target_id)
pages = [] pages = []
has_more = True has_more = True
cursor = None cursor = None
while has_more: while has_more:
params = {"database_id": database_id, "page_size": 100} params = {"data_source_id": data_source_id, "page_size": 100}
if cursor: if cursor:
params["start_cursor"] = cursor params["start_cursor"] = cursor
response = await self._request(self.client.databases.query(**params)) response = await self._request(
self.client.data_sources.query(**params)
)
pages.extend(response["results"]) pages.extend(response["results"])
has_more = response.get("has_more", False) has_more = response.get("has_more", False)
cursor = response.get("next_cursor") cursor = response.get("next_cursor")
@@ -235,7 +271,10 @@ class SchemaMigrator:
result = await self._request( result = await self._request(
self.client.pages.create( self.client.pages.create(
parent={"database_id": target_database_id}, parent={
"type": "data_source_id",
"data_source_id": target_database_id,
},
properties=properties, properties=properties,
) )
) )
@@ -253,22 +292,26 @@ class SchemaMigrator:
target_db: str, target_db: str,
mapping: dict, mapping: dict,
) -> list[dict]: ) -> list[dict]:
"""Execute full migration.""" """Execute full migration. Resolves both IDs to data_source_ids once."""
logger.info("Resolving source and target IDs...")
source_ds = await self._resolve_data_source_id(source_db)
target_ds = await self._resolve_data_source_id(target_db)
logger.info("Fetching schemas...") logger.info("Fetching schemas...")
source_schema = await self.get_schema(source_db) source_schema = await self.get_schema(source_ds)
target_schema = await self.get_schema(target_db) target_schema = await self.get_schema(target_ds)
logger.info(f"Source: {len(source_schema['properties'])} properties") logger.info(f"Source: {len(source_schema['properties'])} properties")
logger.info(f"Target: {len(target_schema['properties'])} properties") logger.info(f"Target: {len(target_schema['properties'])} properties")
logger.info("Fetching source pages...") logger.info("Fetching source pages...")
pages = await self.fetch_all_pages(source_db) pages = await self.fetch_all_pages(source_ds)
logger.info(f"Found {len(pages)} pages to migrate") logger.info(f"Found {len(pages)} pages to migrate")
results = [] results = []
for page in tqdm(pages, desc="Migrating"): for page in tqdm(pages, desc="Migrating"):
result = await self.migrate_page( result = await self.migrate_page(
page, target_db, mapping, source_schema, target_schema page, target_ds, mapping, source_schema, target_schema
) )
results.append(result) results.append(result)

View File

@@ -39,7 +39,7 @@
### 3. Configure Environment ### 3. Configure Environment
```bash ```bash
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
# Create .env from example # Create .env from example
cp .env.example .env cp .env.example .env
@@ -100,8 +100,47 @@ python notion_writer.py --database DB_URL --title "New Entry" --file content.md
# Title only # Title only
python notion_writer.py --database DB_URL --title "Empty Entry" python notion_writer.py --database DB_URL --title "Empty Entry"
# Set arbitrary properties (JSON literal)
python notion_writer.py \
--database DB_URL \
--title "Research Notes" \
--properties '{"Status": "In progress", "Topic": ["AI", "MCP"], "Account Code": "OurDigital"}' \
--file notes.md
# Set properties from a JSON file
python notion_writer.py \
--database DB_URL \
--title "Research Notes" \
--properties ./props.json \
--file notes.md
``` ```
`--database` accepts a database URL, a database_id, or a data_source_id. It is resolved to a data_source_id automatically.
### Upsert (avoid duplicates)
```bash
# If a row with Name="My Title" already exists, update it; otherwise create.
python notion_writer.py \
--database DB_URL \
--title "My Title" \
--upsert-by "Name" \
--properties '{"Status": "Done"}' \
--file content.md
```
When upserting, `--file` (or `--stdin`) replaces the page's body blocks; `--properties` updates the named columns. `--upsert-by` supports title, rich_text, select, status, number, url, email, and phone_number properties.
### Two-tool split: when to use what
| Goal | Tool | Why |
|------|------|-----|
| Push markdown content into a page or new DB row | **`notion_writer.py`** | Converts markdown → Notion blocks (tables, headings, code, lists, rich-text, anchors) |
| Create a DB row with both content and metadata in one shot | **`notion_writer.py`** with `--properties` | Single API call, full schema validation |
| Update properties on many existing pages (bulk column edit) | **MCP `notion-update-page`** | Native schema awareness, no markdown work needed |
| Search / move pages with metadata transition | 31-notion-organizer (Phase 3 work) | Cross-database operations |
--- ---
## Markdown Support ## Markdown Support
@@ -131,6 +170,20 @@ print("Hello")
``` ```
``` ```
### Inline rich-text
| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |
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.
--- ---
## Examples ## Examples
@@ -160,6 +213,40 @@ python jamie_video_info.py VIDEO_ID --json | \
python notion_writer.py --page PAGE_URL --stdin python notion_writer.py --page PAGE_URL --stdin
``` ```
### Test against the "Working with AI" database
The user's primary export target is the AI research database (data_source_id `f8f19ede-32bd-43ac-9f60-0651f6f40afe`). End-to-end check:
```bash
# 1) Sanity: connection works
python notion_writer.py --test
# 2) Inspect the schema (confirms data_source resolution)
python notion_writer.py \
--database f8f19ede-32bd-43ac-9f60-0651f6f40afe \
--info
# 3) Upsert a test row with full metadata
python notion_writer.py \
--database f8f19ede-32bd-43ac-9f60-0651f6f40afe \
--title "[smoke test] notion_writer v3 migration" \
--upsert-by "Name" \
--properties '{
"Status": "Done",
"AI used": ["Claude Code"],
"Topic": ["AI"],
"Category": ["Research"],
"Account Code": "OurDigital",
"Type": "Memo",
"AI summary": "Verifying the data_sources API migration end-to-end."
}' \
--stdin <<< "## Smoke test
Migration verified — properties + content land in one shot."
```
Re-running step 3 should update the existing row instead of creating a duplicate (upsert).
--- ---
## API Limits ## API Limits
@@ -176,13 +263,22 @@ The script automatically batches large content.
## Troubleshooting ## Troubleshooting
### "Could not find page" ### "Notion object not found"
- Ensure page is shared with your integration - Ensure the page or database is shared with your integration (Notion → "..." → Connections → add integration)
- Check URL/ID is correct - Check URL/ID is correct
- For databases, the integration must have access to *each* data source under it
### "Invalid token" ### "Unauthorized"
- Verify NOTION_API_KEY in .env - Verify `NOTION_API_KEY` in `.env`
- Token should start with `secret_` - Token should start with `secret_` (internal integration token)
### "Database has multiple data sources"
- Pass a specific `data_source_id` instead of the database URL/id
- List data sources via the Notion API or open the data source individually in Notion to grab its ID
### "Property 'X' has unsupported type"
- The script handles common property types but skips computed/read-only ones (formula, rollup, created_by, last_edited_by, last_edited_time, unique_id)
- Drop the unsupported key from `--properties` JSON
### "Rate limited" ### "Rate limited"
- Wait and retry - Wait and retry
@@ -193,11 +289,13 @@ The script automatically batches large content.
## File Structure ## File Structure
``` ```
02-notion-writer/ 32-notion-writer/
├── code/ ├── code/
│ ├── CLAUDE.md # This skill document │ ├── CLAUDE.md # This skill document
│ ├── scripts/ │ ├── scripts/
│ │ ├── notion_writer.py # Main script │ │ ├── notion_writer.py # Main script
│ │ ├── _notion_compat.py # API compat helper (data_sources resolution)
│ │ ├── test_parser.py # Markdown parser tests
│ │ ├── venv/ # Python environment │ │ ├── venv/ # Python environment
│ │ ├── .env # API token (not committed) │ │ ├── .env # API token (not committed)
│ │ └── .env.example # Template │ │ └── .env.example # Template
@@ -212,7 +310,7 @@ The script automatically batches large content.
```bash ```bash
# Navigate # Navigate
cd ~/Project/our-claude-skills/custom-skills/02-notion-writer/code/scripts cd ~/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
source venv/bin/activate source venv/bin/activate
# Test # Test
@@ -226,8 +324,18 @@ python notion_writer.py -p PAGE_URL -f content.md -r
# Create DB row # Create DB row
python notion_writer.py -d DB_URL -t "Title" -f content.md python notion_writer.py -d DB_URL -t "Title" -f content.md
# Create DB row with full metadata
python notion_writer.py -d DB_URL -t "Title" --properties '{"Status": "Done"}' -f content.md
# Upsert (avoid duplicates)
python notion_writer.py -d DB_URL -t "Title" --upsert-by "Name" -f content.md
``` ```
--- ---
*Version 1.0.0 | Claude Code | 2025-12-26* *Version 1.1.0 | Claude Code | 2026-04-27*
Changelog:
- 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.

View File

@@ -0,0 +1,255 @@
"""
Notion API compatibility helpers for v3 SDK + 2025-09-03 multi-source databases.
Shared by 32-notion-writer and 31-notion-organizer. Pure functions where
possible; sync API helpers for the writer; async resolution is inlined in
31's scripts to keep this module sync-only.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from notion_client import Client
from notion_client.errors import APIErrorCode, APIResponseError
def make_client(api_key: str) -> Client:
"""Build a sync Notion client on the SDK default API version (2025-09-03+)."""
return Client(auth=api_key)
def resolve_data_source_id(client: Client, target_id: str) -> str:
"""
Accept either a database_id or a data_source_id, return a data_source_id.
Strategy: try data_sources.retrieve first; on ObjectNotFound, fall back to
databases.retrieve and return the first attached data source. Most
workspaces have single-source databases so this is one extra round-trip
only on legacy IDs.
"""
try:
client.data_sources.retrieve(data_source_id=target_id)
return target_id
except APIResponseError as exc:
if exc.code != APIErrorCode.ObjectNotFound:
raise
db = client.databases.retrieve(database_id=target_id)
sources = db.get("data_sources") or []
if not sources:
raise ValueError(
f"Database {target_id} has no data_sources attached. "
"Either it is archived or the integration lacks access."
)
if len(sources) > 1:
names = ", ".join(s.get("name", s["id"]) for s in sources)
raise ValueError(
f"Database {target_id} has multiple data sources ({names}). "
"Pass a specific data_source_id instead of the database_id."
)
return sources[0]["id"]
def get_schema(client: Client, data_source_id: str) -> Dict[str, Any]:
"""Fetch a data source's full schema (properties, title, parent)."""
return client.data_sources.retrieve(data_source_id=data_source_id)
def find_title_property(properties: Dict[str, Any]) -> Optional[str]:
"""Return the name of the title property in a schema, or None."""
for name, prop in properties.items():
if prop.get("type") == "title":
return name
return None
def build_data_source_parent(data_source_id: str) -> Dict[str, str]:
"""Parent shape for pages.create when targeting a data source."""
return {"type": "data_source_id", "data_source_id": data_source_id}
def parse_properties_arg(arg: str) -> Dict[str, Any]:
"""
Parse the --properties CLI value. Accepts either:
- A JSON object literal: '{"Status": "In progress"}'
- A path to a JSON file: './props.json'
"""
arg = arg.strip()
if arg.startswith("{"):
return json.loads(arg)
path = Path(arg).expanduser()
if not path.is_file():
raise ValueError(
f"--properties value is neither a JSON object nor an existing file: {arg!r}"
)
return json.loads(path.read_text(encoding="utf-8"))
def coerce_property_value(
name: str, schema_entry: Dict[str, Any], value: Any
) -> Dict[str, Any]:
"""
Convert a Python-friendly value into a Notion property body, given the
schema entry from data_sources.retrieve().
Supported types: title, rich_text, status, select, multi_select, date,
checkbox, number, url, email, phone_number, people, files (URL form),
relation. Unsupported types raise ValueError with a clear message so the
caller can surface it to the user.
"""
prop_type = schema_entry.get("type")
if prop_type == "title":
return {"title": [{"type": "text", "text": {"content": str(value)}}]}
if prop_type == "rich_text":
return {"rich_text": [{"type": "text", "text": {"content": str(value)}}]}
if prop_type == "status":
return {"status": {"name": str(value)}}
if prop_type == "select":
return {"select": {"name": str(value)}}
if prop_type == "multi_select":
items = value if isinstance(value, list) else [value]
return {"multi_select": [{"name": str(v)} for v in items]}
if prop_type == "date":
if isinstance(value, dict):
return {"date": value}
return {"date": {"start": str(value)}}
if prop_type == "checkbox":
return {"checkbox": bool(value)}
if prop_type == "number":
return {"number": float(value) if value is not None else None}
if prop_type == "url":
return {"url": str(value) if value else None}
if prop_type == "email":
return {"email": str(value) if value else None}
if prop_type == "phone_number":
return {"phone_number": str(value) if value else None}
if prop_type == "people":
ids = value if isinstance(value, list) else [value]
return {"people": [{"id": str(i)} for i in ids]}
if prop_type == "files":
items = value if isinstance(value, list) else [value]
files: List[Dict[str, Any]] = []
for i, item in enumerate(items):
url = item if isinstance(item, str) else item.get("url")
label = f"file_{i}" if isinstance(item, str) else item.get("name", f"file_{i}")
files.append({"name": label, "type": "external", "external": {"url": url}})
return {"files": files}
if prop_type == "relation":
ids = value if isinstance(value, list) else [value]
return {"relation": [{"id": str(i)} for i in ids]}
raise ValueError(
f"Property {name!r} has unsupported type {prop_type!r} "
"(read-only or not yet handled). Supported: title, rich_text, status, "
"select, multi_select, date, checkbox, number, url, email, "
"phone_number, people, files, relation."
)
def coerce_properties(
schema: Dict[str, Any], values: Dict[str, Any]
) -> Dict[str, Any]:
"""
Coerce a flat {prop_name: python_value} dict into Notion properties body.
Unknown property names raise ValueError listing the valid options so
callers can fix their input quickly.
"""
schema_props = schema.get("properties") or {}
out: Dict[str, Any] = {}
for name, value in values.items():
if name not in schema_props:
valid = ", ".join(sorted(schema_props.keys()))
raise ValueError(
f"Unknown property {name!r}. Valid properties: {valid}"
)
out[name] = coerce_property_value(name, schema_props[name], value)
return out
def explain_api_error(exc: APIResponseError, context: str = "") -> str:
"""
Translate a notion-client APIResponseError into a human-friendly message.
The default exc message often hides the root cause behind validation jargon.
"""
code = exc.code
suffix = f" ({context})" if context else ""
if code == APIErrorCode.ObjectNotFound:
return (
f"Notion object not found{suffix}. The ID may be wrong, the page "
"may be archived, or the integration is not connected to it. "
"Open the page/database in Notion and add your integration via "
"'... -> Connections'."
)
if code == APIErrorCode.Unauthorized:
return (
f"Unauthorized{suffix}. Check that NOTION_API_KEY is set and "
"starts with 'secret_' (internal integration token)."
)
if code == APIErrorCode.RestrictedResource:
return (
f"Restricted resource{suffix}. The integration's capabilities do "
"not allow this action. Update capabilities at "
"https://www.notion.so/my-integrations."
)
if code == APIErrorCode.ValidationError:
return f"Validation error{suffix}: {exc.body.get('message', str(exc))}"
if code == APIErrorCode.RateLimited:
return f"Rate limited{suffix}. Back off and retry."
return f"Notion API error [{code}]{suffix}: {exc}"
def find_existing_page(
client: Client,
data_source_id: str,
property_name: str,
schema_entry: Dict[str, Any],
value: Any,
) -> Optional[Dict[str, Any]]:
"""
Look up an existing page in a data source by an exact property value.
Returns the first matching page object or None. Used by --upsert-by.
Supported filter types: title, rich_text, select, status, number, url, email.
"""
prop_type = schema_entry.get("type")
str_value = str(value)
if prop_type in ("title", "rich_text"):
prop_filter = {prop_type: {"equals": str_value}}
elif prop_type in ("select", "status"):
prop_filter = {prop_type: {"equals": str_value}}
elif prop_type == "number":
prop_filter = {"number": {"equals": float(value)}}
elif prop_type in ("url", "email", "phone_number"):
prop_filter = {prop_type: {"equals": str_value}}
else:
raise ValueError(
f"Cannot upsert by property type {prop_type!r}. Use title, "
"rich_text, select, status, number, url, email, or phone_number."
)
response = client.data_sources.query(
data_source_id=data_source_id,
filter={"property": property_name, **prop_filter},
page_size=1,
)
results = response.get("results") or []
return results[0] if results else None

View File

@@ -7,17 +7,21 @@ Supports both page content updates and database row creation.
import os import os
import sys import sys
import re import re
import json
import argparse import argparse
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from dotenv import load_dotenv from dotenv import load_dotenv
from notion_client import Client from notion_client import Client
from notion_client.errors import APIResponseError
import _notion_compat as compat
# Load environment variables # Load environment variables
load_dotenv(Path(__file__).parent / '.env') load_dotenv(Path(__file__).parent / '.env')
NOTION_TOKEN = os.getenv('NOTION_API_KEY') NOTION_TOKEN = os.getenv('NOTION_API_KEY') or os.getenv('NOTION_TOKEN')
def extract_notion_id(url_or_id: str) -> Optional[str]: def extract_notion_id(url_or_id: str) -> Optional[str]:
@@ -164,6 +168,14 @@ INLINE_PATTERNS = [
] ]
_ABSOLUTE_URL_RE = re.compile(r'^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)')
def _is_absolute_url(url: str) -> bool:
"""Notion's link annotation rejects fragment-only and relative URLs."""
return bool(_ABSOLUTE_URL_RE.match(url))
def _rich_span(content: str, **annotations: Any) -> Dict[str, Any]: def _rich_span(content: str, **annotations: Any) -> Dict[str, Any]:
"""Build one rich-text span with optional annotations / link.""" """Build one rich-text span with optional annotations / link."""
span: Dict[str, Any] = { span: Dict[str, Any] = {
@@ -209,7 +221,15 @@ def parse_rich_text(text: str) -> List[Dict[str, Any]]:
if kind == 'code': if kind == 'code':
spans.append(_rich_span(m.group(1), code=True)) spans.append(_rich_span(m.group(1), code=True))
elif kind == 'link': elif kind == 'link':
spans.append(_rich_span(m.group(1), link=m.group(2))) link_text, link_url = m.group(1), m.group(2)
if _is_absolute_url(link_url):
spans.append(_rich_span(link_text, link=link_url))
elif link_url.startswith('#'):
# TOC-style fragment link — Notion rejects, preserve nav intent as bold.
spans.append(_rich_span(link_text, bold=True))
else:
# Relative path or unrecognized scheme — drop link, keep text.
spans.append(_rich_span(link_text))
elif kind in ('bold_star', 'bold_under'): elif kind in ('bold_star', 'bold_under'):
spans.append(_rich_span(m.group(1), bold=True)) spans.append(_rich_span(m.group(1), bold=True))
elif kind == 'strike': elif kind == 'strike':
@@ -427,11 +447,21 @@ def get_page_info(notion: Client, page_id: str) -> Optional[Dict]:
return None return None
def get_database_info(notion: Client, database_id: str) -> Optional[Dict]: def get_database_info(notion: Client, target_id: str) -> Optional[Dict]:
"""Get database information.""" """
Fetch full schema for a database or data source.
Accepts either a database_id or a data_source_id; resolves to the
data_source_id first, then returns the data source object (which contains
the property schema under the 2025-09-03 API).
"""
try: try:
formatted_id = format_id_with_dashes(database_id) formatted_id = format_id_with_dashes(target_id)
return notion.databases.retrieve(database_id=formatted_id) data_source_id = compat.resolve_data_source_id(notion, formatted_id)
return compat.get_schema(notion, data_source_id)
except APIResponseError as exc:
print(f"Error retrieving database: {compat.explain_api_error(exc, formatted_id)}")
return None
except Exception as e: except Exception as e:
print(f"Error retrieving database: {e}") print(f"Error retrieving database: {e}")
return None return None
@@ -476,18 +506,21 @@ def clear_page_content(notion: Client, page_id: str) -> bool:
return False return False
def create_database_row(notion: Client, database_id: str, properties: Dict, content_blocks: List[Dict] = None) -> Optional[str]: def create_database_row(notion: Client, data_source_id: str, properties: Dict, content_blocks: List[Dict] = None) -> Optional[str]:
"""Create a new row in a Notion database.""" """
try: Create a new row in a Notion data source (multi-source database model).
formatted_id = format_id_with_dashes(database_id)
`data_source_id` must be a resolved data_source_id, not a database_id.
Use compat.resolve_data_source_id() upstream.
"""
try:
page_data = { page_data = {
"parent": {"database_id": formatted_id}, "parent": compat.build_data_source_parent(data_source_id),
"properties": properties "properties": properties,
} }
if content_blocks: if content_blocks:
page_data["children"] = content_blocks[:100] # Limit on creation page_data["children"] = content_blocks[:100] # API limit on creation
result = notion.pages.create(**page_data) result = notion.pages.create(**page_data)
@@ -496,11 +529,27 @@ def create_database_row(notion: Client, database_id: str, properties: Dict, cont
append_to_page(notion, result['id'], content_blocks[100:]) append_to_page(notion, result['id'], content_blocks[100:])
return result['id'] return result['id']
except APIResponseError as exc:
print(f"Error creating database row: {compat.explain_api_error(exc)}")
return None
except Exception as e: except Exception as e:
print(f"Error creating database row: {e}") print(f"Error creating database row: {e}")
return None return None
def update_page_properties(notion: Client, page_id: str, properties: Dict) -> bool:
"""Update properties on an existing page (used by upsert)."""
try:
notion.pages.update(page_id=page_id, properties=properties)
return True
except APIResponseError as exc:
print(f"Error updating page properties: {compat.explain_api_error(exc)}")
return False
except Exception as e:
print(f"Error updating page properties: {e}")
return False
def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool: def write_to_page(notion: Client, page_id: str, markdown_content: str, mode: str = 'append') -> bool:
"""Write markdown content to a Notion page.""" """Write markdown content to a Notion page."""
blocks = markdown_to_notion_blocks(markdown_content) blocks = markdown_to_notion_blocks(markdown_content)
@@ -540,11 +589,13 @@ Examples:
) )
parser.add_argument('--page', '-p', help='Notion page URL or ID') parser.add_argument('--page', '-p', help='Notion page URL or ID')
parser.add_argument('--database', '-d', help='Notion database URL or ID') parser.add_argument('--database', '-d', help='Notion database URL, database_id, or data_source_id')
parser.add_argument('--file', '-f', help='Markdown file to push') parser.add_argument('--file', '-f', help='Markdown file to push')
parser.add_argument('--stdin', action='store_true', help='Read content from stdin') parser.add_argument('--stdin', action='store_true', help='Read content from stdin')
parser.add_argument('--replace', '-r', action='store_true', help='Replace page content instead of append') parser.add_argument('--replace', '-r', action='store_true', help='Replace page content instead of append')
parser.add_argument('--title', '-t', help='Title for database row (required for database)') parser.add_argument('--title', '-t', help='Title for database row (required for database unless --properties sets it)')
parser.add_argument('--properties', help='Extra properties as JSON object literal or path to .json file (e.g. \'{"Status": "In progress", "Topic": ["AI"]}\')')
parser.add_argument('--upsert-by', dest='upsert_by', help='Property name to match for upsert; updates existing row instead of creating duplicate')
parser.add_argument('--test', action='store_true', help='Test Notion API connection') parser.add_argument('--test', action='store_true', help='Test Notion API connection')
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)')
@@ -557,7 +608,7 @@ Examples:
print("Get your integration token from: https://www.notion.so/my-integrations") print("Get your integration token from: https://www.notion.so/my-integrations")
sys.exit(1) sys.exit(1)
notion = Client(auth=NOTION_TOKEN) notion = compat.make_client(NOTION_TOKEN)
# Test connection # Test connection
if args.test: if args.test:
@@ -657,43 +708,94 @@ Examples:
sys.exit(1) sys.exit(1)
return return
# Create database row # Create or upsert database row
if args.database: if args.database:
if not content and not args.title: if not content and not args.title and not args.properties:
print("Error: Provide --title and/or --file for database row") print("Error: Provide --title, --properties, and/or --file for database row")
sys.exit(1) sys.exit(1)
db_id = extract_notion_id(args.database) db_id = extract_notion_id(args.database)
if not db_id: if not db_id:
print(f"Error: Invalid Notion database URL/ID: {args.database}") print(f"Error: Invalid Notion database URL/ID: {args.database}")
sys.exit(1) sys.exit(1)
formatted_id = format_id_with_dashes(db_id)
# Get database schema to find title property # Resolve to a data_source_id (handles both database_id and data_source_id input)
db_info = get_database_info(notion, db_id) try:
if not db_info: data_source_id = compat.resolve_data_source_id(notion, formatted_id)
except APIResponseError as exc:
print(f"Error: {compat.explain_api_error(exc, formatted_id)}")
sys.exit(1)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1) sys.exit(1)
# Find the title property # Fetch schema
title_prop = None schema = compat.get_schema(notion, data_source_id)
for prop_name, prop_config in db_info.get('properties', {}).items(): schema_props = schema.get('properties', {})
if prop_config.get('type') == 'title': title_prop = compat.find_title_property(schema_props)
title_prop = prop_name
break
if not title_prop: if not title_prop:
print("Error: Could not find title property in database") print("Error: Data source has no title property; cannot create row.")
sys.exit(1) sys.exit(1)
properties = { # Build properties body: start from --properties, layer --title on top
title_prop: { property_values: Dict[str, Any] = {}
"title": [{"text": {"content": args.title or "Untitled"}}] if args.properties:
} try:
} property_values = compat.parse_properties_arg(args.properties)
except (ValueError, json.JSONDecodeError) as exc:
print(f"Error parsing --properties: {exc}")
sys.exit(1)
if args.title:
property_values[title_prop] = args.title
elif title_prop not in property_values:
property_values[title_prop] = "Untitled"
try:
properties = compat.coerce_properties(schema, property_values)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
content_blocks = markdown_to_notion_blocks(content) if content else None content_blocks = markdown_to_notion_blocks(content) if content else None
# Upsert path: look for existing row by the named property
if args.upsert_by:
if args.upsert_by not in schema_props:
valid = ", ".join(sorted(schema_props.keys()))
print(f"Error: --upsert-by property {args.upsert_by!r} not in schema. Valid: {valid}")
sys.exit(1)
lookup_value = property_values.get(args.upsert_by)
if lookup_value is None:
print(f"Error: --upsert-by {args.upsert_by!r} requires a value in --properties or --title")
sys.exit(1)
try:
existing = compat.find_existing_page(
notion, data_source_id, args.upsert_by,
schema_props[args.upsert_by], lookup_value,
)
except APIResponseError as exc:
print(f"Error during upsert lookup: {compat.explain_api_error(exc)}")
sys.exit(1)
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)
if not append_to_page(notion, page_id, content_blocks):
sys.exit(1)
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, db_id, properties, content_blocks) row_id = create_database_row(notion, data_source_id, properties, content_blocks)
if row_id: if row_id:
print(f"✅ Successfully created database row") print(f"✅ Successfully created database row")

View File

@@ -165,6 +165,31 @@ def test_blocks_table():
"link inline formatting preserved inside table cell") "link inline formatting preserved inside table cell")
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")
bold_spans = [s for s in spans if s.get("annotations", {}).get("bold")]
_assert(len(bold_spans) == 1, "anchor-only link converted to bold span")
_assert(bold_spans[0]["text"]["content"] == "Section A", "anchor link text preserved")
_assert("link" not in bold_spans[0]["text"], "no link annotation on fragment URL")
def test_rich_text_relative_link_becomes_plain():
"""Relative paths can't be Notion links either; render plain text."""
spans = parse_rich_text("see [the spec](../spec.md) please")
spec_span = next(s for s in spans if s["text"]["content"] == "the spec")
_assert("link" not in spec_span["text"], "relative-path link annotation dropped")
_assert("annotations" not in spec_span, "no spurious bold/italic on plain text")
def test_rich_text_absolute_link_preserved():
"""Regression: absolute URLs must still produce a real Notion link."""
spans = parse_rich_text("[docs](https://example.com/x) and [mail](mailto:a@b.co)")
urls = [s["text"].get("link", {}).get("url") for s in spans if s["text"].get("link")]
_assert("https://example.com/x" in urls, "https URL preserved as link")
_assert("mailto:a@b.co" in urls, "mailto URL preserved as link")
def test_no_literal_markers_leak(): def test_no_literal_markers_leak():
"""Regression: before the rich-text rewrite, ** and [ ] leaked as literal text.""" """Regression: before the rich-text rewrite, ** and [ ] leaked as literal text."""
blocks = markdown_to_notion_blocks("A **bold** word and a [link](https://x.com).") blocks = markdown_to_notion_blocks("A **bold** word and a [link](https://x.com).")
@@ -189,6 +214,9 @@ def run_all():
test_blocks_todo, test_blocks_todo,
test_blocks_quote_code_divider, test_blocks_quote_code_divider,
test_blocks_table, test_blocks_table,
test_rich_text_anchor_link_becomes_bold,
test_rich_text_relative_link_becomes_plain,
test_rich_text_absolute_link_preserved,
test_no_literal_markers_leak, test_no_literal_markers_leak,
] ]
for t in tests: for t in tests: