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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user