#!/usr/bin/env python3 """ Notion Writer - Push markdown content to Notion pages or databases. 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, Union 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') or os.getenv('NOTION_TOKEN') def extract_notion_id(url_or_id: str) -> Optional[str]: """Extract Notion page/database ID from URL or raw ID.""" # Already a raw ID (32 chars hex, with or without dashes) clean_id = url_or_id.replace('-', '') if re.match(r'^[a-f0-9]{32}$', clean_id): return clean_id # Notion URL patterns patterns = [ r'notion\.so/(?:[^/]+/)?([a-f0-9]{32})', # notion.so/workspace/page-id r'notion\.so/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})', # notion.so/workspace/Page-Title-id r'notion\.site/(?:[^/]+/)?([a-f0-9]{32})', # public notion.site r'notion\.site/(?:[^/]+/)?[^/]+?-([a-f0-9]{32})', r'([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})', # UUID format ] for pattern in patterns: match = re.search(pattern, url_or_id) if match: return match.group(1).replace('-', '') return None def format_id_with_dashes(raw_id: str) -> str: """Format 32-char ID to UUID format with dashes.""" if len(raw_id) == 32: return f"{raw_id[:8]}-{raw_id[8:12]}-{raw_id[12:16]}-{raw_id[16:20]}-{raw_id[20:]}" return raw_id TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$') def _is_table_row(line: str) -> bool: stripped = line.strip() return stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2 def _split_table_row(line: str) -> List[str]: stripped = line.strip() if stripped.startswith('|'): stripped = stripped[1:] if stripped.endswith('|'): stripped = stripped[:-1] return [cell.strip() for cell in stripped.split('|')] def markdown_to_notion_blocks(content: Union[str, List[str]]) -> List[Dict[str, Any]]: """Convert markdown text (or pre-split lines) to Notion block objects. Reentrant: container detectors recursively call _parse_lines on inner content. """ if isinstance(content, str): lines = content.split('\n') else: lines = list(content) return _parse_lines(lines) def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: """Walk lines and emit Notion blocks. Called recursively by container detectors.""" blocks = [] i = 0 while i < len(lines): line = lines[i] if not line.strip(): i += 1 continue # Toggle (HTML5
) — multi-line form, depth-tracked for nesting if line.strip() == '
': i += 1 # Optional ... on next non-blank line summary_text = '' while i < len(lines) and not lines[i].strip(): i += 1 if i < len(lines): summary_match = re.match(r'^\s*(.*)\s*$', lines[i]) if summary_match: summary_text = summary_match.group(1) i += 1 # Collect body lines until matching
, depth-tracked inner_lines: List[str] = [] depth = 1 while i < len(lines) and depth > 0: stripped_inner = lines[i].strip() if stripped_inner == '
': depth += 1 inner_lines.append(lines[i]) elif stripped_inner == '
': depth -= 1 if depth > 0: inner_lines.append(lines[i]) else: inner_lines.append(lines[i]) i += 1 if depth > 0: # Unclosed
at EOF — degrade to paragraphs print("Warning: unclosed
at EOF; emitting body as paragraphs", file=sys.stderr) if summary_text: blocks.append(create_paragraph_block(summary_text)) for inner in inner_lines: if inner.strip(): blocks.append(create_paragraph_block(inner)) continue children = _parse_lines(inner_lines) blocks.append(create_toggle_block(summary_text, children)) continue # Columns (Pandoc fenced div ::: columns) if line.strip() == '::: columns': columns_lines: List[List[str]] = [] current_col: Optional[List[str]] = None i += 1 depth = 1 # we are inside our own wrapper while i < len(lines) and depth > 0: stripped_col = lines[i].strip() if stripped_col == '::: columns': depth += 1 if current_col is not None: current_col.append(lines[i]) i += 1 continue if stripped_col == '::: column': depth += 1 if depth == 2: # Top-level column inside our wrapper if current_col is not None: columns_lines.append(current_col) current_col = [] else: # Column inside a nested wrapper — record verbatim if current_col is not None: current_col.append(lines[i]) i += 1 continue if stripped_col == ':::': depth -= 1 if depth == 0: # Closing our wrapper if current_col is not None: columns_lines.append(current_col) current_col = None i += 1 break if depth == 1: # Closing a top-level column if current_col is not None: columns_lines.append(current_col) current_col = None else: # Closing something nested — record verbatim if current_col is not None: current_col.append(lines[i]) i += 1 continue # Regular content line — append to current column if open if current_col is not None: current_col.append(lines[i]) i += 1 if depth > 0: # Unclosed wrapper at EOF — degrade to paragraphs print("Warning: unclosed ::: columns at EOF; emitting as paragraphs", file=sys.stderr) if current_col is not None: columns_lines.append(current_col) for col in columns_lines: for inner in col: if inner.strip(): blocks.append(create_paragraph_block(inner)) continue # Drop columns that are pure whitespace columns_lines = [c for c in columns_lines if any(li.strip() for li in c)] if len(columns_lines) < 2: # Notion requires >= 2 columns; single column degrades to paragraphs for col in columns_lines: for inner in col: if inner.strip(): blocks.append(create_paragraph_block(inner)) continue column_blocks = [_parse_lines(col_lines) for col_lines in columns_lines] blocks.append(create_column_list_block(column_blocks)) continue if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]): header_cells = _split_table_row(line) i += 2 body_rows: List[List[str]] = [] while i < len(lines) and _is_table_row(lines[i]): body_rows.append(_split_table_row(lines[i])) i += 1 blocks.append(create_table_block(header_cells, body_rows)) continue if line.startswith('######'): blocks.append(create_heading_block(line[6:].strip(), 3)) elif line.startswith('#####'): blocks.append(create_heading_block(line[5:].strip(), 3)) elif line.startswith('####'): blocks.append(create_heading_block(line[4:].strip(), 3)) elif line.startswith('###'): blocks.append(create_heading_block(line[3:].strip(), 3)) elif line.startswith('##'): blocks.append(create_heading_block(line[2:].strip(), 2)) elif line.startswith('#'): blocks.append(create_heading_block(line[1:].strip(), 1)) elif line.startswith('```'): language = line[3:].strip() or 'plain text' code_lines = [] i += 1 while i < len(lines) and not lines[i].startswith('```'): code_lines.append(lines[i]) i += 1 blocks.append(create_code_block('\n'.join(code_lines), language)) # Checkbox / Todo (must come before generic bullet match) elif line.strip().startswith('- [ ]'): text = line.strip()[5:].strip() blocks.append(create_todo_block(text, False)) elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'): text = line.strip()[5:].strip() blocks.append(create_todo_block(text, True)) elif line.strip().startswith('- ') or line.strip().startswith('* '): text = line.strip()[2:] blocks.append(create_bulleted_list_block(text)) elif re.match(r'^\d+\.\s', line.strip()): text = re.sub(r'^\d+\.\s', '', line.strip()) blocks.append(create_numbered_list_block(text)) # Blockquote — GitHub-alert callout takes priority over generic quote elif line.startswith('>'): first_body = line[1:].strip() alert_match = ALERT_RE.match(first_body) if alert_match: alert_type = alert_match.group(1) body_lines: List[str] = [] i += 1 while i < len(lines) and lines[i].lstrip().startswith('>'): body_lines.append(lines[i].lstrip()[1:].lstrip()) i += 1 body_text = '\n'.join(body_lines) blocks.append(create_callout_block(body_text, alert_type)) continue blocks.append(create_quote_block(first_body)) elif line.strip() in ['---', '***', '___']: blocks.append(create_divider_block()) else: blocks.append(create_paragraph_block(line)) i += 1 return blocks INLINE_PATTERNS = [ ('code', re.compile(r'`([^`\n]+)`')), ('mention', re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')), ('link', re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')), ('bold_star', re.compile(r'\*\*([^*\n]+)\*\*')), ('bold_under', re.compile(r'__([^_\n]+)__')), ('strike', re.compile(r'~~([^~\n]+)~~')), ('italic_star', re.compile(r'\*([^*\n]+)\*')), ('italic_under', re.compile(r'(? 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] = { "type": "text", "text": {"content": content[:2000]}, } link = annotations.pop('link', None) if link: span["text"]["link"] = {"url": link} if annotations: span["annotations"] = {k: v for k, v in annotations.items() if v} return span def parse_rich_text(text: str) -> List[Dict[str, Any]]: """Parse markdown inline formatting into Notion rich-text spans. Supports: **bold**, __bold__, *italic*, _italic_, `code`, ~~strike~~, [text](url). No nesting (Notion rich-text flattens annotations onto each span anyway). """ if not text: return [] spans: List[Dict[str, Any]] = [] remaining = text while remaining: # Find the earliest inline token in what's left. best: Optional[tuple] = None # (kind, match_obj) for kind, pattern in INLINE_PATTERNS: m = pattern.search(remaining) if m and (best is None or m.start() < best[1].start()): best = (kind, m) if not best: spans.append(_rich_span(remaining)) break kind, m = best if m.start() > 0: spans.append(_rich_span(remaining[:m.start()])) if kind == 'code': spans.append(_rich_span(m.group(1), code=True)) elif kind == 'mention': mention_title, mention_target = m.group(1), m.group(2) page_id = extract_notion_id(mention_target) if page_id: spans.append({ "type": "mention", "mention": { "type": "page", "page": {"id": format_id_with_dashes(page_id)}, }, "plain_text": mention_title, }) else: # Invalid ID — degrade to plain text "@Title" spans.append(_rich_span(f"@{mention_title}")) elif kind == 'link': 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': spans.append(_rich_span(m.group(1), strikethrough=True)) elif kind in ('italic_star', 'italic_under'): spans.append(_rich_span(m.group(1), italic=True)) remaining = remaining[m.end():] return spans def create_paragraph_block(text: str) -> Dict[str, Any]: return { "object": "block", "type": "paragraph", "paragraph": { "rich_text": parse_rich_text(text) } } def create_heading_block(text: str, level: int) -> Dict[str, Any]: heading_type = f"heading_{level}" return { "object": "block", "type": heading_type, heading_type: { "rich_text": parse_rich_text(text) } } def create_bulleted_list_block(text: str) -> Dict[str, Any]: return { "object": "block", "type": "bulleted_list_item", "bulleted_list_item": { "rich_text": parse_rich_text(text) } } def create_numbered_list_block(text: str) -> Dict[str, Any]: return { "object": "block", "type": "numbered_list_item", "numbered_list_item": { "rich_text": parse_rich_text(text) } } def create_todo_block(text: str, checked: bool) -> Dict[str, Any]: return { "object": "block", "type": "to_do", "to_do": { "rich_text": parse_rich_text(text), "checked": checked } } def create_quote_block(text: str) -> Dict[str, Any]: return { "object": "block", "type": "quote", "quote": { "rich_text": parse_rich_text(text) } } def create_callout_block(text: str, alert_type: str) -> Dict[str, Any]: """Create a callout block with icon + color from a GitHub alert type.""" icon, color = ALERT_TYPES[alert_type] return { "object": "block", "type": "callout", "callout": { "rich_text": parse_rich_text(text), "icon": {"type": "emoji", "emoji": icon}, "color": color, }, } def create_toggle_block(summary_text: str, children_blocks: List[Dict[str, Any]]) -> Dict[str, Any]: """Create a toggle block with summary rich-text and child blocks.""" return { "object": "block", "type": "toggle", "toggle": { "rich_text": parse_rich_text(summary_text), "children": children_blocks, }, } def create_column_list_block(columns: List[List[Dict[str, Any]]]) -> Dict[str, Any]: """Create a column_list block; each item in `columns` is the children-list for one column.""" return { "object": "block", "type": "column_list", "column_list": { "children": [ {"object": "block", "type": "column", "column": {"children": col_blocks}} for col_blocks in columns ], }, } def create_code_block(code: str, language: str) -> Dict[str, Any]: return { "object": "block", "type": "code", "code": { "rich_text": parse_rich_text(code), "language": language.lower() } } def create_divider_block() -> Dict[str, Any]: return { "object": "block", "type": "divider", "divider": {} } def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]: """Build a Notion `table` block with header + body rows. All rows are padded to the header width so Notion accepts the payload. Each cell renders through `parse_rich_text` so inline formatting works inside tables too. """ width = max(len(header_cells), max((len(r) for r in body_rows), default=0)) def row_block(cells: List[str]) -> Dict[str, Any]: padded = cells + [''] * (width - len(cells)) return { "object": "block", "type": "table_row", "table_row": { "cells": [parse_rich_text(c) for c in padded] }, } children = [row_block(header_cells)] + [row_block(r) for r in body_rows] return { "object": "block", "type": "table", "table": { "table_width": width, "has_column_header": True, "has_row_header": False, "children": children, }, } def list_accessible_content(notion: Client, filter_type: str = 'all') -> None: """List all accessible pages and databases.""" print("=" * 70) print("Accessible Notion Content (Claude-D.intelligence)") print("=" * 70) databases = [] pages = [] try: # Search all content and filter locally response = notion.search() all_results = response.get('results', []) # Handle pagination while response.get('has_more'): response = notion.search(start_cursor=response.get('next_cursor')) all_results.extend(response.get('results', [])) # Separate databases and pages for item in all_results: if item.get('object') == 'database': databases.append(item) elif item.get('object') == 'page': pages.append(item) # Show databases if filter_type in ['all', 'database']: print("\n📊 DATABASES") print("-" * 70) if databases: for i, db in enumerate(databases, 1): title = "" if db.get('title'): title = db['title'][0].get('plain_text', 'Untitled') if db['title'] else 'Untitled' db_id = db['id'] url = db.get('url', '') props = list(db.get('properties', {}).keys())[:5] print(f"\n{i}. {title}") print(f" ID: {db_id}") print(f" URL: {url}") print(f" Properties: {', '.join(props)}") else: print(" No databases accessible") # Show pages if filter_type in ['all', 'page']: print("\n\n📄 PAGES") print("-" * 70) if pages: for i, page in enumerate(pages, 1): title = "Untitled" if 'properties' in page: for prop in page['properties'].values(): if prop.get('type') == 'title': title_arr = prop.get('title', []) if title_arr: title = title_arr[0].get('plain_text', 'Untitled') break page_id = page['id'] url = page.get('url', '') parent_type = page.get('parent', {}).get('type', 'unknown') print(f"\n{i}. {title}") print(f" ID: {page_id}") print(f" URL: {url}") print(f" Parent: {parent_type}") else: print(" No pages accessible") print("\n" + "=" * 70) db_count = len(databases) if filter_type != 'page' else 0 page_count = len(pages) if filter_type != 'database' else 0 print(f"Total: {db_count} databases, {page_count} pages") print("=" * 70) except Exception as e: print(f"Error listing content: {e}") def get_page_info(notion: Client, page_id: str) -> Optional[Dict]: """Get page information.""" try: formatted_id = format_id_with_dashes(page_id) return notion.pages.retrieve(page_id=formatted_id) except Exception as e: print(f"Error retrieving page: {e}") return None 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(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 def append_to_page(notion: Client, page_id: str, blocks: List[Dict]) -> bool: """Append blocks to an existing Notion page.""" try: formatted_id = format_id_with_dashes(page_id) # Notion API limits to 100 blocks per request for i in range(0, len(blocks), 100): batch = blocks[i:i+100] notion.blocks.children.append( block_id=formatted_id, children=batch ) return True except Exception as e: print(f"Error appending to page: {e}") return False def clear_page_content(notion: Client, page_id: str) -> bool: """Clear all content from a page.""" try: formatted_id = format_id_with_dashes(page_id) # Get all child blocks children = notion.blocks.children.list(block_id=formatted_id) # Delete each block (skip already archived blocks) for block in children.get('results', []): if block.get('archived'): continue notion.blocks.delete(block_id=block['id']) return True except Exception as e: print(f"Error clearing page: {e}") return False 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": compat.build_data_source_parent(data_source_id), "properties": properties, } if content_blocks: page_data["children"] = content_blocks[:100] # API limit on creation result = notion.pages.create(**page_data) # If more than 100 blocks, append the rest if content_blocks and len(content_blocks) > 100: 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) if not blocks: print("No content to write") return False if mode == 'replace': if not clear_page_content(notion, page_id): return False return append_to_page(notion, page_id, blocks) def main(): parser = argparse.ArgumentParser( description='Push markdown content to Notion pages or databases', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: # Append markdown file to a page python notion_writer.py --page https://notion.so/My-Page-abc123 --file content.md # Replace page content python notion_writer.py --page PAGE_ID --file content.md --replace # Pipe content from stdin cat content.md | python notion_writer.py --page PAGE_ID --stdin # Create database row with title python notion_writer.py --database DB_URL --title "New Entry" --file content.md # Test connection python notion_writer.py --test ''' ) parser.add_argument('--page', '-p', help='Notion page 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 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)') parser.add_argument('--info', action='store_true', help='Show page/database info') args = parser.parse_args() if not NOTION_TOKEN: print("Error: NOTION_API_KEY not set in .env file") print("Get your integration token from: https://www.notion.so/my-integrations") sys.exit(1) notion = compat.make_client(NOTION_TOKEN) # Test connection if args.test: try: me = notion.users.me() print("=" * 50) print("Notion API Connection Test") print("=" * 50) print(f"\n✅ Connected successfully!") print(f" Bot: {me.get('name', 'Unknown')}") print(f" Type: {me.get('type', 'Unknown')}") print("\n" + "=" * 50) return except Exception as e: print(f"❌ Connection failed: {e}") sys.exit(1) # List accessible content if args.list: filter_map = {'all': 'all', 'pages': 'page', 'databases': 'database'} list_accessible_content(notion, filter_map.get(args.list, 'all')) return # Show info if args.info: if args.page: page_id = extract_notion_id(args.page) if page_id: info = get_page_info(notion, page_id) if info: print("=" * 50) print("Page Information") print("=" * 50) title = "" if 'properties' in info: for prop in info['properties'].values(): if prop.get('type') == 'title': title_arr = prop.get('title', []) if title_arr: title = title_arr[0].get('plain_text', '') print(f"Title: {title}") print(f"ID: {info['id']}") print(f"Created: {info.get('created_time', 'N/A')}") print(f"URL: {info.get('url', 'N/A')}") return if args.database: db_id = extract_notion_id(args.database) if db_id: info = get_database_info(notion, db_id) if info: print("=" * 50) print("Database Information") print("=" * 50) title = "" if info.get('title'): title = info['title'][0].get('plain_text', '') if info['title'] else '' print(f"Title: {title}") print(f"ID: {info['id']}") print(f"Properties: {', '.join(info.get('properties', {}).keys())}") return print("Please specify --page or --database with --info") return # Get content content = None if args.stdin: content = sys.stdin.read() elif args.file: file_path = Path(args.file) if not file_path.exists(): print(f"Error: File not found: {args.file}") sys.exit(1) content = file_path.read_text(encoding='utf-8') # Write to page if args.page: if not content: print("Error: No content provided. Use --file or --stdin") sys.exit(1) page_id = extract_notion_id(args.page) if not page_id: print(f"Error: Invalid Notion page URL/ID: {args.page}") sys.exit(1) mode = 'replace' if args.replace else 'append' print(f"{'Replacing' if mode == 'replace' else 'Appending'} content to page...") if write_to_page(notion, page_id, content, mode): print(f"✅ Successfully wrote content to page") formatted_id = format_id_with_dashes(page_id) print(f" https://notion.so/{formatted_id.replace('-', '')}") else: print("❌ Failed to write content") sys.exit(1) return # Create or upsert database row if args.database: 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) # 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) # 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: Data source has no title property; cannot create row.") sys.exit(1) # 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, data_source_id, properties, content_blocks) if row_id: print(f"✅ Successfully created database row") print(f" https://notion.so/{row_id.replace('-', '')}") else: print("❌ Failed to create database row") sys.exit(1) return # No action specified parser.print_help() if __name__ == "__main__": main()