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:
255
custom-skills/32-notion-writer/code/scripts/_notion_compat.py
Normal file
255
custom-skills/32-notion-writer/code/scripts/_notion_compat.py
Normal 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
|
||||
Reference in New Issue
Block a user