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:
@@ -53,6 +53,7 @@ class SchemaMigrator:
|
||||
"pages_skipped": 0,
|
||||
"errors": 0,
|
||||
}
|
||||
self._data_source_cache: dict[str, str] = {}
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
@@ -63,24 +64,59 @@ class SchemaMigrator:
|
||||
await asyncio.sleep(REQUEST_DELAY)
|
||||
return await coro
|
||||
|
||||
async def get_schema(self, database_id: str) -> dict:
|
||||
"""Get database schema."""
|
||||
async def _resolve_data_source_id(self, target_id: str) -> str:
|
||||
"""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(
|
||||
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]:
|
||||
"""Fetch all pages from source database."""
|
||||
async def fetch_all_pages(self, target_id: str) -> list[dict]:
|
||||
"""Fetch all pages from source data source."""
|
||||
data_source_id = await self._resolve_data_source_id(target_id)
|
||||
pages = []
|
||||
has_more = True
|
||||
cursor = None
|
||||
|
||||
while has_more:
|
||||
params = {"database_id": database_id, "page_size": 100}
|
||||
params = {"data_source_id": data_source_id, "page_size": 100}
|
||||
if 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"])
|
||||
has_more = response.get("has_more", False)
|
||||
cursor = response.get("next_cursor")
|
||||
@@ -235,7 +271,10 @@ class SchemaMigrator:
|
||||
|
||||
result = await self._request(
|
||||
self.client.pages.create(
|
||||
parent={"database_id": target_database_id},
|
||||
parent={
|
||||
"type": "data_source_id",
|
||||
"data_source_id": target_database_id,
|
||||
},
|
||||
properties=properties,
|
||||
)
|
||||
)
|
||||
@@ -253,22 +292,26 @@ class SchemaMigrator:
|
||||
target_db: str,
|
||||
mapping: 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...")
|
||||
source_schema = await self.get_schema(source_db)
|
||||
target_schema = await self.get_schema(target_db)
|
||||
source_schema = await self.get_schema(source_ds)
|
||||
target_schema = await self.get_schema(target_ds)
|
||||
|
||||
logger.info(f"Source: {len(source_schema['properties'])} properties")
|
||||
logger.info(f"Target: {len(target_schema['properties'])} properties")
|
||||
|
||||
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")
|
||||
|
||||
results = []
|
||||
for page in tqdm(pages, desc="Migrating"):
|
||||
result = await self.migrate_page(
|
||||
page, target_db, mapping, source_schema, target_schema
|
||||
page, target_ds, mapping, source_schema, target_schema
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user