Skill Numbering Changes: - 01-03: OurDigital core (was 30-32) - 31-32: Notion tools (was 01-02) - 99_archive: Renamed from _archive for sorting New Files: - AGENTS.md: Claude Code agent routing guide - requirements.txt for 00-claude-code-setting, 32-notion-writer, 43-jamie-youtube-manager Documentation Updates: - CLAUDE.md: Updated skill inventory (23 skills) - AUDIT_REPORT.md: Current completion status (91%) - Archived REFACTORING_PLAN.md (most tasks complete) Removed: - ga-agent-skills/ (moved to separate repo ~/Project/dintel-ga4-agent) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
595 lines
19 KiB
Python
595 lines
19 KiB
Python
#!/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 argparse
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from dotenv import load_dotenv
|
|
from notion_client import Client
|
|
|
|
# Load environment variables
|
|
load_dotenv(Path(__file__).parent / '.env')
|
|
|
|
NOTION_TOKEN = os.getenv('NOTION_API_KEY')
|
|
|
|
|
|
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
|
|
|
|
|
|
def markdown_to_notion_blocks(markdown_text: str) -> List[Dict[str, Any]]:
|
|
"""Convert markdown text to Notion block objects."""
|
|
blocks = []
|
|
lines = markdown_text.split('\n')
|
|
i = 0
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
# Skip empty lines
|
|
if not line.strip():
|
|
i += 1
|
|
continue
|
|
|
|
# Headers
|
|
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))
|
|
|
|
# Code blocks
|
|
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))
|
|
|
|
# Bullet list
|
|
elif line.strip().startswith('- ') or line.strip().startswith('* '):
|
|
text = line.strip()[2:]
|
|
blocks.append(create_bulleted_list_block(text))
|
|
|
|
# Numbered list
|
|
elif re.match(r'^\d+\.\s', line.strip()):
|
|
text = re.sub(r'^\d+\.\s', '', line.strip())
|
|
blocks.append(create_numbered_list_block(text))
|
|
|
|
# Checkbox / Todo
|
|
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))
|
|
|
|
# Blockquote
|
|
elif line.startswith('>'):
|
|
text = line[1:].strip()
|
|
blocks.append(create_quote_block(text))
|
|
|
|
# Horizontal rule
|
|
elif line.strip() in ['---', '***', '___']:
|
|
blocks.append(create_divider_block())
|
|
|
|
# Regular paragraph
|
|
else:
|
|
blocks.append(create_paragraph_block(line))
|
|
|
|
i += 1
|
|
|
|
return blocks
|
|
|
|
|
|
def parse_rich_text(text: str) -> List[Dict[str, Any]]:
|
|
"""Parse markdown inline formatting to Notion rich text."""
|
|
rich_text = []
|
|
|
|
# Simple implementation - just plain text for now
|
|
# TODO: Add support for bold, italic, code, links
|
|
if text:
|
|
rich_text.append({
|
|
"type": "text",
|
|
"text": {"content": text[:2000]} # Notion limit
|
|
})
|
|
|
|
return rich_text
|
|
|
|
|
|
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_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 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, database_id: str) -> Optional[Dict]:
|
|
"""Get database information."""
|
|
try:
|
|
formatted_id = format_id_with_dashes(database_id)
|
|
return notion.databases.retrieve(database_id=formatted_id)
|
|
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
|
|
for block in children.get('results', []):
|
|
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, 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)
|
|
|
|
page_data = {
|
|
"parent": {"database_id": formatted_id},
|
|
"properties": properties
|
|
}
|
|
|
|
if content_blocks:
|
|
page_data["children"] = content_blocks[:100] # 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 Exception as e:
|
|
print(f"Error creating database row: {e}")
|
|
return None
|
|
|
|
|
|
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 or 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('--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 = Client(auth=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 database row
|
|
if args.database:
|
|
if not content and not args.title:
|
|
print("Error: Provide --title 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)
|
|
|
|
# Get database schema to find title property
|
|
db_info = get_database_info(notion, db_id)
|
|
if not db_info:
|
|
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
|
|
|
|
if not title_prop:
|
|
print("Error: Could not find title property in database")
|
|
sys.exit(1)
|
|
|
|
properties = {
|
|
title_prop: {
|
|
"title": [{"text": {"content": args.title or "Untitled"}}]
|
|
}
|
|
}
|
|
|
|
content_blocks = markdown_to_notion_blocks(content) if content else None
|
|
|
|
print(f"Creating database row...")
|
|
row_id = create_database_row(notion, db_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()
|