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

@@ -7,17 +7,21 @@ Supports both page content updates and database row creation.
import os
import sys
import re
import json
import argparse
from pathlib import Path
from typing import Optional, List, Dict, Any
from dotenv import load_dotenv
from notion_client import Client
from notion_client.errors import APIResponseError
import _notion_compat as compat
# Load environment variables
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]:
@@ -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]:
"""Build one rich-text span with optional annotations / link."""
span: Dict[str, Any] = {
@@ -209,7 +221,15 @@ def parse_rich_text(text: str) -> List[Dict[str, Any]]:
if kind == 'code':
spans.append(_rich_span(m.group(1), code=True))
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'):
spans.append(_rich_span(m.group(1), bold=True))
elif kind == 'strike':
@@ -427,11 +447,21 @@ def get_page_info(notion: Client, page_id: str) -> Optional[Dict]:
return None
def get_database_info(notion: Client, database_id: str) -> Optional[Dict]:
"""Get database information."""
def get_database_info(notion: Client, target_id: str) -> Optional[Dict]:
"""
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:
formatted_id = format_id_with_dashes(database_id)
return notion.databases.retrieve(database_id=formatted_id)
formatted_id = format_id_with_dashes(target_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:
print(f"Error retrieving database: {e}")
return None
@@ -476,18 +506,21 @@ def clear_page_content(notion: Client, page_id: str) -> bool:
return False
def create_database_row(notion: Client, database_id: str, properties: Dict, content_blocks: List[Dict] = None) -> Optional[str]:
"""Create a new row in a Notion database."""
try:
formatted_id = format_id_with_dashes(database_id)
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 data source (multi-source database model).
`data_source_id` must be a resolved data_source_id, not a database_id.
Use compat.resolve_data_source_id() upstream.
"""
try:
page_data = {
"parent": {"database_id": formatted_id},
"properties": properties
"parent": compat.build_data_source_parent(data_source_id),
"properties": properties,
}
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)
@@ -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:])
return result['id']
except APIResponseError as exc:
print(f"Error creating database row: {compat.explain_api_error(exc)}")
return None
except Exception as e:
print(f"Error creating database row: {e}")
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:
"""Write markdown content to a Notion page."""
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('--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('--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('--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('--list', '-l', nargs='?', const='all', choices=['all', 'pages', 'databases'],
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")
sys.exit(1)
notion = Client(auth=NOTION_TOKEN)
notion = compat.make_client(NOTION_TOKEN)
# Test connection
if args.test:
@@ -657,43 +708,94 @@ Examples:
sys.exit(1)
return
# Create database row
# Create or upsert database row
if args.database:
if not content and not args.title:
print("Error: Provide --title and/or --file for database row")
if not content and not args.title and not args.properties:
print("Error: Provide --title, --properties, and/or --file for database row")
sys.exit(1)
db_id = extract_notion_id(args.database)
if not db_id:
print(f"Error: Invalid Notion database URL/ID: {args.database}")
sys.exit(1)
formatted_id = format_id_with_dashes(db_id)
# Get database schema to find title property
db_info = get_database_info(notion, db_id)
if not db_info:
# Resolve to a data_source_id (handles both database_id and data_source_id input)
try:
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)
# Find the title property
title_prop = None
for prop_name, prop_config in db_info.get('properties', {}).items():
if prop_config.get('type') == 'title':
title_prop = prop_name
break
# Fetch schema
schema = compat.get_schema(notion, data_source_id)
schema_props = schema.get('properties', {})
title_prop = compat.find_title_property(schema_props)
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)
properties = {
title_prop: {
"title": [{"text": {"content": args.title or "Untitled"}}]
}
}
# Build properties body: start from --properties, layer --title on top
property_values: Dict[str, Any] = {}
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
# 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...")
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:
print(f"✅ Successfully created database row")