|
16 | 16 | DEFAULT_NOTION_VERSION = "2026-03-11" |
17 | 17 | MAX_RECORDS = 200 |
18 | 18 | MAX_BLOCKS_PER_PAGE = 80 |
| 19 | +MAX_BLOCK_TREE_DEPTH = 3 |
19 | 20 |
|
20 | 21 |
|
21 | 22 | RequestJSON = Callable[[str, dict[str, str], dict[str, Any] | None, str], Any] |
@@ -172,26 +173,77 @@ def _fetch_page_blocks( |
172 | 173 | if not page_id: |
173 | 174 | return [] |
174 | 175 | blocks: list[dict[str, Any]] = [] |
| 176 | + _append_child_blocks( |
| 177 | + base_url, |
| 178 | + headers, |
| 179 | + parent_id=page_id, |
| 180 | + requester=requester, |
| 181 | + errors=errors, |
| 182 | + secrets=secrets, |
| 183 | + blocks=blocks, |
| 184 | + depth=0, |
| 185 | + seen_parent_ids=set(), |
| 186 | + ) |
| 187 | + return blocks |
| 188 | + |
| 189 | + |
| 190 | +def _append_child_blocks( |
| 191 | + base_url: str, |
| 192 | + headers: dict[str, str], |
| 193 | + *, |
| 194 | + parent_id: str, |
| 195 | + requester: RequestJSON, |
| 196 | + errors: list[dict[str, Any]], |
| 197 | + secrets: list[str | None], |
| 198 | + blocks: list[dict[str, Any]], |
| 199 | + depth: int, |
| 200 | + seen_parent_ids: set[str], |
| 201 | +) -> None: |
| 202 | + if len(blocks) >= MAX_BLOCKS_PER_PAGE or depth > MAX_BLOCK_TREE_DEPTH: |
| 203 | + return |
| 204 | + if parent_id in seen_parent_ids: |
| 205 | + errors.append({"block_id": parent_id, "error": "Notion block tree contained a repeated parent id"}) |
| 206 | + return |
| 207 | + seen_parent_ids.add(parent_id) |
175 | 208 | cursor: str | None = None |
176 | 209 | while len(blocks) < MAX_BLOCKS_PER_PAGE: |
177 | 210 | query = {"page_size": str(min(100, MAX_BLOCKS_PER_PAGE - len(blocks)))} |
178 | 211 | if cursor: |
179 | 212 | query["start_cursor"] = cursor |
180 | | - url = f"{base_url}/blocks/{page_id}/children?{urlencode(query)}" |
| 213 | + url = f"{base_url}/blocks/{parent_id}/children?{urlencode(query)}" |
181 | 214 | try: |
182 | 215 | payload = requester(url, headers, None, "GET") |
183 | 216 | except Exception as exc: |
184 | | - errors.append({"page_id": page_id, "error": redact_error_message(exc, secrets)}) |
| 217 | + errors.append({"block_id": parent_id, "error": redact_error_message(exc, secrets)}) |
185 | 218 | break |
186 | 219 | if not isinstance(payload, dict): |
187 | | - errors.append({"page_id": page_id, "error": "Notion block children response was not an object"}) |
| 220 | + errors.append({"block_id": parent_id, "error": "Notion block children response was not an object"}) |
188 | 221 | break |
189 | 222 | results = payload.get("results") if isinstance(payload.get("results"), list) else [] |
190 | | - blocks.extend(item for item in results if isinstance(item, dict)) |
| 223 | + for item in results: |
| 224 | + if not isinstance(item, dict): |
| 225 | + continue |
| 226 | + block = {**item, "_cortex_depth": depth} |
| 227 | + blocks.append(block) |
| 228 | + block_id = _text(item.get("id")) |
| 229 | + if bool(item.get("has_children")) and block_id and len(blocks) < MAX_BLOCKS_PER_PAGE: |
| 230 | + _append_child_blocks( |
| 231 | + base_url, |
| 232 | + headers, |
| 233 | + parent_id=block_id, |
| 234 | + requester=requester, |
| 235 | + errors=errors, |
| 236 | + secrets=secrets, |
| 237 | + blocks=blocks, |
| 238 | + depth=depth + 1, |
| 239 | + seen_parent_ids=seen_parent_ids, |
| 240 | + ) |
| 241 | + if len(blocks) >= MAX_BLOCKS_PER_PAGE: |
| 242 | + break |
191 | 243 | cursor = str(payload.get("next_cursor") or "").strip() or None |
192 | 244 | if not payload.get("has_more") or not cursor: |
193 | 245 | break |
194 | | - return blocks |
| 246 | + seen_parent_ids.discard(parent_id) |
195 | 247 |
|
196 | 248 |
|
197 | 249 | def _record_from_page(page: dict[str, Any], blocks: list[dict[str, Any]]) -> NotionSyncRecord | None: |
@@ -300,10 +352,20 @@ def _block_text(block: dict[str, Any]) -> str: |
300 | 352 | text = _rich_text(value.get("rich_text") if isinstance(value, dict) else None) |
301 | 353 | if not text and block_type == "child_page" and isinstance(value, dict): |
302 | 354 | text = _clean_text(value.get("title")) |
| 355 | + if not text and block_type == "child_database" and isinstance(value, dict): |
| 356 | + text = _clean_text(value.get("title")) |
| 357 | + if not text and block_type in {"bookmark", "embed", "link_preview"} and isinstance(value, dict): |
| 358 | + text = _clean_text(value.get("url")) |
303 | 359 | if not text: |
304 | 360 | return "" |
305 | 361 | label = block_type.replace("_", " ").title() |
306 | | - return f"{label}: {text}" |
| 362 | + depth = 0 |
| 363 | + try: |
| 364 | + depth = max(0, int(block.get("_cortex_depth") or 0)) |
| 365 | + except (TypeError, ValueError): |
| 366 | + depth = 0 |
| 367 | + prefix = " " * min(depth, MAX_BLOCK_TREE_DEPTH) |
| 368 | + return f"{prefix}{label}: {text}" |
307 | 369 |
|
308 | 370 |
|
309 | 371 | def _rich_text(value: Any) -> str: |
|
0 commit comments