AI Newsletter Digest improvements: fixed QP soft line break decoding, URL extraction, and content cleaning

This commit is contained in:
Krilly
2026-03-04 13:29:22 +00:00
parent 29a98137a7
commit 57dd294675
13706 changed files with 2114953 additions and 237629 deletions

View File

@@ -0,0 +1,425 @@
---
name: notion-sync
description: Bi-directional sync and management for Notion pages and databases. Use when working with Notion workspaces for collaborative editing, research tracking, project management, or when you need to sync markdown files to/from Notion pages or monitor Notion pages for changes.
---
# Notion Sync
Bi-directional sync between markdown files and Notion pages, plus database management utilities for research tracking and project management.
## Setup
### API Key Configuration
Store the Notion API key in macOS Keychain:
```bash
# Add to keychain (will prompt for the secret)
security add-generic-password -a "$USER" -s "openclaw.notion_api_key" -w
# Add to environment loader (e.g., ~/.openclaw/bin/openclaw-env.sh)
export NOTION_API_KEY="$(security find-generic-password -a "$USER" -s "openclaw.notion_api_key" -w)"
# Restart gateway to load the key
openclaw gateway restart
```
### Integration Setup
1. Go to https://www.notion.so/my-integrations
2. Create a new integration or use an existing one
3. Copy the "Internal Integration Token" (starts with `secret_`)
4. Store it in Keychain as shown above
5. Share your Notion pages/databases with the integration:
- Open the page/database in Notion
- Click "Share" → "Invite"
- Select your integration
## Core Operations
### 1. Search Pages and Databases
Search across your Notion workspace by title or content.
```bash
node scripts/search-notion.js "<query>" [--filter page|database] [--limit 10]
```
**Examples:**
```bash
# Search for newsletter-related pages
node scripts/search-notion.js "newsletter"
# Find only databases
node scripts/search-notion.js "research" --filter database
# Limit results
node scripts/search-notion.js "AI" --limit 5
```
**Output:**
```json
[
{
"id": "page-id-here",
"object": "page",
"title": "Newsletter Draft",
"url": "https://notion.so/...",
"lastEdited": "2026-02-01T09:00:00.000Z"
}
]
```
### 2. Query Databases with Filters
Query database contents with advanced filters and sorting.
```bash
node scripts/query-database.js <database-id> [--filter <json>] [--sort <json>] [--limit 10]
```
**Examples:**
```bash
# Get all items
node scripts/query-database.js xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Filter by Status = "Complete"
node scripts/query-database.js <db-id> \
--filter '{"property": "Status", "select": {"equals": "Complete"}}'
# Filter by Tags containing "AI"
node scripts/query-database.js <db-id> \
--filter '{"property": "Tags", "multi_select": {"contains": "AI"}}'
# Sort by Date descending
node scripts/query-database.js <db-id> \
--sort '[{"property": "Date", "direction": "descending"}]'
# Combine filter + sort
node scripts/query-database.js <db-id> \
--filter '{"property": "Status", "select": {"equals": "Complete"}}' \
--sort '[{"property": "Date", "direction": "descending"}]'
```
**Common filter patterns:**
- Select equals: `{"property": "Status", "select": {"equals": "Done"}}`
- Multi-select contains: `{"property": "Tags", "multi_select": {"contains": "AI"}}`
- Date after: `{"property": "Date", "date": {"after": "2024-01-01"}}`
- Checkbox is true: `{"property": "Published", "checkbox": {"equals": true}}`
- Number greater than: `{"property": "Count", "number": {"greater_than": 100}}`
### 3. Update Page Properties
Update properties for database pages (status, tags, dates, etc.).
```bash
node scripts/update-page-properties.js <page-id> <property-name> <value> [--type <type>]
```
**Supported types:** select, multi_select, checkbox, number, url, email, date, rich_text
**Examples:**
```bash
# Set status
node scripts/update-page-properties.js <page-id> Status "Complete" --type select
# Add multiple tags
node scripts/update-page-properties.js <page-id> Tags "AI,Leadership,Research" --type multi_select
# Set checkbox
node scripts/update-page-properties.js <page-id> Published true --type checkbox
# Set date
node scripts/update-page-properties.js <page-id> "Publish Date" "2024-02-01" --type date
# Set URL
node scripts/update-page-properties.js <page-id> "Source URL" "https://example.com" --type url
# Set number
node scripts/update-page-properties.js <page-id> "Word Count" 1200 --type number
```
### 4. Markdown → Notion Sync
Push markdown content to Notion with full formatting support.
```bash
node scripts/md-to-notion.js \
"<markdown-file-path>" \
"<notion-parent-page-id>" \
"<page-title>"
```
**Example:**
```bash
node scripts/md-to-notion.js \
"projects/newsletter-draft.md" \
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \
"Newsletter Draft - Feb 2026"
```
**Supported formatting:**
- Headings (H1-H3)
- Bold/italic text
- Links
- Bullet lists
- Code blocks with syntax highlighting
- Horizontal dividers
- Paragraphs
**Features:**
- Batched uploads (100 blocks per request)
- Automatic rate limiting (350ms between batches)
- Returns Notion page URL and ID
**Output:**
```
Parsed 294 blocks from markdown
✓ Created page: https://www.notion.so/[title-and-id]
✓ Appended 100 blocks (100-200)
✓ Appended 94 blocks (200-294)
✅ Successfully created Notion page!
```
### 5. Notion → Markdown Sync
Pull Notion page content and convert to markdown.
```bash
node scripts/notion-to-md.js <page-id> [output-file]
```
**Example:**
```bash
node scripts/notion-to-md.js \
"abc123-example-page-id-456def" \
"newsletter-updated.md"
```
**Features:**
- Converts Notion blocks to markdown
- Preserves formatting (headings, lists, code, quotes)
- Optional file output (writes to file or stdout)
### 6. Change Detection & Monitoring
Monitor Notion pages for edits and compare with local markdown files.
```bash
node scripts/watch-notion.js "<page-id>" "<local-markdown-path>"
# Or use environment variables
export NOTION_WATCH_PAGE_ID="<your-page-id>"
export NOTION_WATCH_LOCAL_PATH="/path/to/your/draft.md"
node scripts/watch-notion.js
```
**Example:**
```bash
node scripts/watch-notion.js \
"abc123-example-page-id-456def" \
"projects/newsletter-draft.md"
```
**State tracking:** Maintains state in `memory/notion-watch-state.json`:
```json
{
"pages": {
"<page-id>": {
"lastEditedTime": "2026-01-30T08:57:00.000Z",
"lastChecked": "2026-01-31T19:41:54.000Z",
"title": "Your Page Title"
}
}
}
```
**Output:**
```json
{
"pageId": "<page-id>",
"title": "Your Page Title",
"lastEditedTime": "2026-01-30T08:57:00.000Z",
"hasChanges": false,
"localPath": "/path/to/your-draft.md",
"actions": ["✓ No changes since last check"]
}
```
**Integration with heartbeat:** Add to `HEARTBEAT.md` for automated monitoring:
```markdown
## Notion Page Monitoring (Every 2-3 hours during work hours)
Check if enough time has passed since last Notion check.
If >2 hours since last check AND during work hours (9 AM - 9 PM):
1. Run: `node scripts/watch-notion.js "<your-page-id>" "<your-local-path>"`
2. If `hasChanges: true` → notify user via message tool
3. Update check timestamp
```
### 7. Database Management
#### Add Markdown Content to Database
Add a markdown file as a new page in any Notion database.
```bash
node scripts/add-to-database.js <database-id> "<page-title>" <markdown-file-path>
```
**Examples:**
```bash
# Add research output
node scripts/add-to-database.js \
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \
"Research Report - Feb 2026" \
projects/research-insights.md
# Add project notes
node scripts/add-to-database.js \
<project-db-id> \
"Sprint Retrospective" \
docs/retro-2026-02.md
# Add meeting notes
node scripts/add-to-database.js \
<notes-db-id> \
"Weekly Team Sync" \
notes/sync-2026-02-06.md
```
**Features:**
- Creates database page with title property
- Converts markdown to Notion blocks (headings, paragraphs, dividers)
- Handles large files with batched uploads
- Returns page URL for immediate access
**Note:** Additional properties (Type, Tags, Status, etc.) must be set manually in Notion UI after creation.
#### Inspect Database Schema
```bash
node scripts/get-database-schema.js <database-id>
```
**Example output:**
```json
{
"object": "database",
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"title": [{"plain_text": "Ax Resources"}],
"properties": {
"Name": {"type": "title"},
"Type": {"type": "select"},
"Tags": {"type": "multi_select"}
}
}
```
**Use when:**
- Setting up new database integrations
- Debugging property names/types
- Understanding database structure
#### Archive Pages
```bash
node scripts/delete-notion-page.js <page-id>
```
**Note:** This archives the page (sets `archived: true`), not permanent deletion.
## Common Workflows
### Collaborative Editing Workflow
1. **Push local draft to Notion:**
```bash
node scripts/md-to-notion.js draft.md <parent-id> "Draft Title"
```
2. **User edits in Notion** (anywhere, any device)
3. **Monitor for changes:**
```bash
node scripts/watch-notion.js
# Returns hasChanges: true when edited
```
4. **Pull updates back:**
```bash
node scripts/notion-to-md.js <page-id> draft-updated.md
```
5. **Repeat as needed** (update same page, don't create v2/v3/etc.)
### Research Output Tracking
1. **Generate research locally** (e.g., via sub-agent)
2. **Sync to Notion database:**
```bash
node scripts/add-research-to-db.js
```
3. **User adds metadata in Notion UI** (Type, Tags, Status properties)
4. **Access from anywhere** via Notion web/mobile
### Page ID Extraction
From Notion URL: `https://notion.so/Page-Title-abc123-example-page-id-456def`
Extract: `abc123-example-page-id-456def` (last part after title)
Or use the 32-char format: `abc123examplepageid456def` (hyphens optional)
## Limitations
- **Property updates:** Database properties (Type, Tags, Status) must be added manually in Notion UI after page creation. API property updates can be temperamental with inline databases.
- **Block limits:** Very large markdown files (>1000 blocks) may take several minutes to sync due to rate limiting.
- **Formatting:** Some complex markdown (tables, nested lists >3 levels) may not convert perfectly.
## Troubleshooting
**"Could not find page" error:**
- Ensure page/database is shared with your integration
- Check page ID format (32 chars, alphanumeric + hyphens)
**"Module not found" error:**
- Scripts use built-in Node.js https module (no npm install needed)
- Ensure running from correct directory with `cd ~/clawd`
**Rate limiting:**
- Notion API has rate limits (~3 requests/second)
- Scripts handle this automatically with 350ms delays between batches
## Resources
### scripts/
**Core Sync:**
- **md-to-notion.js** - Markdown → Notion sync with full formatting
- **notion-to-md.js** - Notion → Markdown conversion
- **watch-notion.js** - Change detection and monitoring
**Search & Query:**
- **search-notion.js** - Search pages and databases by query
- **query-database.js** - Query databases with filters and sorting
- **update-page-properties.js** - Update database page properties
**Database Management:**
- **add-to-database.js** - Add markdown files as database pages
- **get-database-schema.js** - Inspect database structure
- **delete-notion-page.js** - Archive pages
**Utilities:**
- **notion-utils.js** - Shared utilities (error handling, property formatting, API requests)
All scripts use only built-in Node.js modules (https, fs) - no external dependencies required.
### references/
- **database-patterns.md** - Common database schemas and property patterns

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn7ey4tpcw1w2ermtp9a4hs19x80bmmg",
"slug": "notion-sync",
"version": "1.0.3",
"publishedAt": 1770493761411
}

View File

@@ -0,0 +1,258 @@
# Notion Sync API Reference
Detailed technical reference for all Notion sync scripts and utilities.
## Environment Setup
### Required Environment Variable
```bash
export NOTION_API_KEY="secret_..."
```
### Keychain Storage (macOS)
```bash
# Store in keychain
security add-generic-password -a "$USER" -s "openclaw.notion_api_key" -w
# Load from keychain
export NOTION_API_KEY="$(security find-generic-password -a "$USER" -s "openclaw.notion_api_key" -w)"
```
## Scripts Reference
### search-notion.js
Search pages and databases by title or content.
**Signature:**
```bash
node scripts/search-notion.js "<query>" [--filter page|database] [--limit 10]
```
**Options:**
- `query` (required): Search term
- `--filter`: Restrict to `page` or `database`
- `--limit`: Max results (default: 10)
**Returns:** JSON array of matching pages/databases with id, title, url, lastEdited
### query-database.js
Query database contents with advanced filters and sorting.
**Signature:**
```bash
node scripts/query-database.js <database-id> [--filter <json>] [--sort <json>] [--limit 10]
```
**Filter Patterns:**
| Type | Example |
|------|---------|
| Select equals | `{"property": "Status", "select": {"equals": "Done"}}` |
| Multi-select contains | `{"property": "Tags", "multi_select": {"contains": "AI"}}` |
| Date after | `{"property": "Date", "date": {"after": "2024-01-01"}}` |
| Checkbox | `{"property": "Published", "checkbox": {"equals": true}}` |
| Number | `{"property": "Count", "number": {"greater_than": 100}}` |
**Sort Format:**
```json
[{"property": "Date", "direction": "descending"}]
```
### update-page-properties.js
Update database page properties.
**Signature:**
```bash
node scripts/update-page-properties.js <page-id> <property-name> <value> [--type <type>]
```
**Property Types:**
- `select`: Single selection (e.g., Status)
- `multi_select`: Multiple tags (comma-separated)
- `checkbox`: Boolean (true/false)
- `number`: Numeric value
- `url`: URL string
- `email`: Email address
- `date`: ISO date (YYYY-MM-DD)
- `rich_text`: Plain text
### md-to-notion.js
Convert markdown to Notion page.
**Signature:**
```bash
node scripts/md-to-notion.js "<markdown-file>" "<parent-page-id>" "<title>"
```
**Supported Markdown:**
- Headings: `#`, `##`, `###`
- Bold: `**text**`
- Italic: `*text*`
- Links: `[text](url)`
- Lists: `- item`
- Code: ` ```lang ... ``` `
- Dividers: `---`
**Output:** Notion page URL and ID
**Rate Limiting:** 350ms between batch uploads (100 blocks per batch)
### notion-to-md.js
Convert Notion page to markdown.
**Signature:**
```bash
node scripts/notion-to-md.js <page-id> [output-file]
```
**Output:** Writes markdown to file or stdout
### watch-notion.js
Monitor page for changes.
**Signature:**
```bash
node scripts/watch-notion.js
```
**State File:** `memory/notion-watch-state.json`
**State Schema:**
```json
{
"pages": {
"<page-id>": {
"lastEditedTime": "ISO timestamp",
"lastChecked": "ISO timestamp",
"title": "Page Title"
}
}
}
```
**Returns:** JSON with `hasChanges`, `localDiffers`, `actions`
### get-database-schema.js
Inspect database structure.
**Signature:**
```bash
node scripts/get-database-schema.js <database-id>
```
**Returns:** JSON with database properties and their types
### delete-notion-page.js
Archive page (soft delete).
**Signature:**
```bash
node scripts/delete-notion-page.js <page-id>
```
**Note:** Sets `archived: true`, doesn't permanently delete
## Notion API Utilities
### notion-utils.js
Shared utilities for all scripts.
**Exports:**
#### `notionRequest(path, method, body)`
Makes authenticated API requests to Notion.
**Parameters:**
- `path`: API endpoint (e.g., `/v1/pages`)
- `method`: HTTP method (GET, POST, PATCH, DELETE)
- `body`: Optional request body (object)
**Returns:** Promise resolving to response JSON
**Error Handling:** Throws with detailed Notion API error messages
#### `formatProperty(type, value)`
Formats property values for Notion API.
**Supported Types:**
- select, multi_select, checkbox, number, url, email, date, rich_text
**Returns:** Notion API property object
#### `extractPageId(input)`
Extracts clean page ID from URL or ID string.
**Input Formats:**
- URL: `https://notion.so/Title-abc123...`
- With hyphens: `abc123-example-page-id-456def`
- Without hyphens: `abc123examplepageid456def`
**Returns:** 32-char UUID with hyphens
## Rate Limits
Notion API limits:
- ~3 requests/second
- Scripts implement 350ms delays between batches
- Large operations (>100 blocks) auto-batch with delays
## Common Issues
### Authentication Errors
**Error:** `"Could not find page"`
**Solutions:**
1. Verify page/database is shared with your integration
2. Check page ID format (32 chars, no extra characters)
3. Confirm `NOTION_API_KEY` is set and valid
### Property Update Failures
**Issue:** Property updates don't persist
**Cause:** Database is inline (embedded in a page) rather than standalone
**Solution:** Create standalone database or update properties manually in Notion UI
### Module Not Found
**Error:** `Cannot find module 'https'`
**Solution:** Ensure using Node.js v18+ (built-in modules)
## Page ID Extraction
From Notion URL:
```
https://notion.so/Page-Title-abc123-example-page-id-456def
└─────────── Extract this part ──────────┘
```
Both formats work:
- With hyphens: `abc123-example-page-id-456def`
- Without hyphens: `abc123examplepageid456def`
## Integration Permissions
Required Notion integration capabilities:
- ✓ Read content
- ✓ Update content
- ✓ Insert content
- ✓ Read comments (optional)
Share settings:
- Must explicitly share each page/database with the integration
- Child pages inherit parent permissions
- Databases require explicit sharing even if parent is shared

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* Add markdown file as a page in a Notion database
* Usage: node add-to-database.js <database-id> <page-title> <markdown-file-path>
*/
const fs = require('fs');
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
function notionRequest(path, method, data) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
}
});
});
req.on('error', reject);
if (data) req.write(JSON.stringify(data));
req.end();
});
}
function parseRichText(text) {
const richText = [];
const maxLength = 2000;
if (text.length <= maxLength) {
richText.push({
type: 'text',
text: { content: text }
});
} else {
richText.push({
type: 'text',
text: { content: text.substring(0, maxLength) }
});
}
return richText;
}
function parseMarkdown(markdown) {
const lines = markdown.split('\n');
const blocks = [];
let currentParagraph = [];
const flushParagraph = () => {
if (currentParagraph.length > 0) {
const text = currentParagraph.join('\n').trim();
if (text && text.length > 0) {
blocks.push({
type: 'paragraph',
paragraph: {
rich_text: parseRichText(text)
}
});
}
currentParagraph = [];
}
};
for (let line of lines) {
if (line.startsWith('### ')) {
flushParagraph();
blocks.push({
type: 'heading_3',
heading_3: { rich_text: parseRichText(line.substring(4)) }
});
} else if (line.startsWith('## ')) {
flushParagraph();
blocks.push({
type: 'heading_2',
heading_2: { rich_text: parseRichText(line.substring(3)) }
});
} else if (line.startsWith('# ')) {
flushParagraph();
blocks.push({
type: 'heading_1',
heading_1: { rich_text: parseRichText(line.substring(2)) }
});
} else if (line.startsWith('---')) {
flushParagraph();
blocks.push({ type: 'divider', divider: {} });
} else if (line.trim() === '') {
flushParagraph();
} else {
currentParagraph.push(line);
}
}
flushParagraph();
return blocks;
}
(async () => {
// Parse arguments
const args = process.argv.slice(2);
if (args.length < 3) {
console.error('Usage: node add-to-database.js <database-id> <page-title> <markdown-file-path>');
console.error('\nExample:');
console.error(' node add-to-database.js abc123-db-id "Research Report" research.md');
process.exit(1);
}
const [dbId, title, mdPath] = args;
// Validate inputs
if (!fs.existsSync(mdPath)) {
console.error(`Error: File not found: ${mdPath}`);
process.exit(1);
}
console.log('Adding page to database...');
console.log(` Database: ${dbId}`);
console.log(` Title: ${title}`);
console.log(` Source: ${mdPath}\n`);
// Create database page
const pageData = {
parent: {
type: 'database_id',
database_id: dbId
},
properties: {
'Name': {
title: [{ text: { content: title } }]
}
}
};
console.log('Creating database entry...');
const page = await notionRequest('/v1/pages', 'POST', pageData);
console.log(`✓ Page created: ${page.id}`);
console.log(` URL: https://notion.so/${page.id.replace(/-/g, '')}`);
// Read and parse markdown
const markdown = fs.readFileSync(mdPath, 'utf8');
const blocks = parseMarkdown(markdown);
console.log(`\nParsed ${blocks.length} blocks from markdown`);
// Add content in batches
const batchSize = 100;
for (let i = 0; i < blocks.length; i += batchSize) {
const batch = blocks.slice(i, i + batchSize);
console.log(`Adding blocks ${i + 1}-${Math.min(i + batchSize, blocks.length)}...`);
await notionRequest(`/v1/blocks/${page.id}/children`, 'PATCH', {
children: batch
});
if (i + batchSize < blocks.length) {
await new Promise(resolve => setTimeout(resolve, 350));
}
}
console.log(`\n✅ Successfully added to database!`);
console.log(`📄 URL: https://notion.so/${page.id.replace(/-/g, '')}`);
console.log(`\n💡 Add additional properties (Type, Tags, Status, etc.) manually in Notion`);
})().catch(error => {
console.error('Error:', error.message);
process.exit(1);
});

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Archive/delete a Notion page
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
function notionRequest(path, method, data) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
}
});
});
req.on('error', reject);
if (data) req.write(JSON.stringify(data));
req.end();
});
}
(async () => {
const pageId = process.argv[2];
if (!pageId) {
console.error('Usage: delete-notion-page.js <page-id>');
process.exit(1);
}
console.log(`Archiving page: ${pageId}`);
const result = await notionRequest(`/v1/pages/${pageId}`, 'PATCH', {
archived: true
});
console.log('✓ Page archived successfully');
console.log('Page ID:', result.id);
console.log('Archived:', result.archived);
})().catch(error => {
console.error('Error:', error.message);
process.exit(1);
});

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env node
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
function notionRequest(path, method) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
}
});
});
req.on('error', reject);
req.end();
});
}
(async () => {
const dbId = process.argv[2];
if (!dbId) {
console.error('Usage: get-database-schema.js <database-id>');
process.exit(1);
}
const db = await notionRequest(`/v1/databases/${dbId}`, 'GET');
console.log(JSON.stringify(db, null, 2));
})();

View File

@@ -0,0 +1,287 @@
#!/usr/bin/env node
/**
* Smart Markdown to Notion Converter
* Batches blocks efficiently and handles common markdown structures
*/
const fs = require('fs');
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
if (!NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
process.exit(1);
}
// Parse markdown into structured blocks
function parseMarkdown(markdown) {
const lines = markdown.split('\n');
const blocks = [];
let currentParagraph = [];
let inCodeBlock = false;
let codeLanguage = '';
let codeContent = [];
const flushParagraph = () => {
if (currentParagraph.length > 0) {
const text = currentParagraph.join('\n').trim();
if (text) {
blocks.push({
type: 'paragraph',
paragraph: {
rich_text: parseRichText(text)
}
});
}
currentParagraph = [];
}
};
for (let line of lines) {
// Code blocks
if (line.startsWith('```')) {
if (!inCodeBlock) {
flushParagraph();
inCodeBlock = true;
codeLanguage = line.slice(3).trim() || 'plain text';
codeContent = [];
} else {
blocks.push({
type: 'code',
code: {
language: codeLanguage,
rich_text: [{ type: 'text', text: { content: codeContent.join('\n') } }]
}
});
inCodeBlock = false;
codeLanguage = '';
codeContent = [];
}
continue;
}
if (inCodeBlock) {
codeContent.push(line);
continue;
}
// Headings
if (line.match(/^#{1,3}\s+/)) {
flushParagraph();
const level = line.match(/^#+/)[0].length;
const text = line.replace(/^#+\s+/, '').trim();
blocks.push({
type: level === 1 ? 'heading_1' : level === 2 ? 'heading_2' : 'heading_3',
[level === 1 ? 'heading_1' : level === 2 ? 'heading_2' : 'heading_3']: {
rich_text: parseRichText(text)
}
});
continue;
}
// Horizontal rules
if (line.match(/^---+$/)) {
flushParagraph();
blocks.push({
type: 'divider',
divider: {}
});
continue;
}
// Bullet lists
if (line.match(/^[-*]\s+/)) {
flushParagraph();
const text = line.replace(/^[-*]\s+/, '').trim();
blocks.push({
type: 'bulleted_list_item',
bulleted_list_item: {
rich_text: parseRichText(text)
}
});
continue;
}
// Empty lines end paragraphs
if (line.trim() === '') {
flushParagraph();
continue;
}
// Regular text accumulates into paragraphs
currentParagraph.push(line);
}
flushParagraph();
return blocks;
}
// Parse rich text with basic markdown formatting
function parseRichText(text) {
const richText = [];
// Simple parser for bold, italic, links
// This is a basic implementation - can be enhanced
const parts = text.split(/(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))/);
for (let part of parts) {
if (!part) continue;
if (part.startsWith('**') && part.endsWith('**')) {
// Bold
richText.push({
type: 'text',
text: { content: part.slice(2, -2) },
annotations: { bold: true }
});
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
// Italic
richText.push({
type: 'text',
text: { content: part.slice(1, -1) },
annotations: { italic: true }
});
} else if (part.match(/\[(.*?)\]\((.*?)\)/)) {
// Link
const match = part.match(/\[(.*?)\]\((.*?)\)/);
richText.push({
type: 'text',
text: { content: match[1], link: { url: match[2] } }
});
} else {
// Plain text
richText.push({
type: 'text',
text: { content: part }
});
}
}
return richText.length > 0 ? richText : [{ type: 'text', text: { content: text } }];
}
// Create a page in Notion
function createNotionPage(parentId, title, blocks) {
return new Promise((resolve, reject) => {
const data = JSON.stringify({
parent: { page_id: parentId },
properties: {
title: {
title: [{ text: { content: title } }]
}
},
children: blocks.slice(0, 100) // Notion API limit: 100 blocks per request
});
const options = {
hostname: 'api.notion.com',
path: '/v1/pages',
method: 'POST',
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`API error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
// Append remaining blocks to a page
function appendBlocks(pageId, blocks) {
return new Promise((resolve, reject) => {
const data = JSON.stringify({
children: blocks.slice(0, 100)
});
const options = {
hostname: 'api.notion.com',
path: `/v1/blocks/${pageId}/children`,
method: 'PATCH',
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`API error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(data);
req.end();
});
}
// Main execution
async function main() {
const args = process.argv.slice(2);
if (args.length < 3) {
console.error('Usage: md-to-notion.js <markdown-file> <parent-page-id> <page-title>');
process.exit(1);
}
const [mdFile, parentId, pageTitle] = args;
try {
const markdown = fs.readFileSync(mdFile, 'utf8');
const blocks = parseMarkdown(markdown);
console.log(`Parsed ${blocks.length} blocks from markdown`);
// Create page with first 100 blocks
const page = await createNotionPage(parentId, pageTitle, blocks);
console.log(`✓ Created page: ${page.url}`);
// Append remaining blocks in batches of 100
for (let i = 100; i < blocks.length; i += 100) {
const batch = blocks.slice(i, i + 100);
await appendBlocks(page.id, batch);
console.log(`✓ Appended ${batch.length} blocks (${i}-${i + batch.length})`);
// Rate limiting: wait 350ms between requests
await new Promise(resolve => setTimeout(resolve, 350));
}
console.log(`\n✅ Successfully created Notion page!`);
console.log(`📄 URL: ${page.url}`);
console.log(`🆔 Page ID: ${page.id}`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env node
/**
* Notion to Markdown Converter
* Fetches a Notion page and converts it back to markdown
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
// Only check API key when running as main script (not when imported as module)
if (require.main === module && !NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
process.exit(1);
}
// Normalize and encode page/block IDs for URL safety
function normalizeId(id) {
// Remove hyphens if present, then add them back in UUID format
const clean = id.replace(/-/g, '');
if (clean.length === 32) {
return `${clean.slice(0,8)}-${clean.slice(8,12)}-${clean.slice(12,16)}-${clean.slice(16,20)}-${clean.slice(20)}`;
}
return id; // Return as-is if not standard length
}
// Fetch page metadata
function getPage(pageId) {
return new Promise((resolve, reject) => {
const normalizedId = normalizeId(pageId);
const encodedId = encodeURIComponent(normalizedId);
const options = {
hostname: 'api.notion.com',
path: `/v1/pages/${encodedId}`,
method: 'GET',
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`API error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.end();
});
}
// Fetch blocks from a page
function getBlocks(blockId, cursor = null) {
return new Promise((resolve, reject) => {
const normalizedId = normalizeId(blockId);
const encodedId = encodeURIComponent(normalizedId);
const encodedCursor = cursor ? encodeURIComponent(cursor) : null;
const path = `/v1/blocks/${encodedId}/children${encodedCursor ? `?start_cursor=${encodedCursor}` : ''}`;
const options = {
hostname: 'api.notion.com',
path: path,
method: 'GET',
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(body));
} else {
reject(new Error(`API error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.end();
});
}
// Fetch all blocks (handling pagination)
async function getAllBlocks(blockId) {
let allBlocks = [];
let cursor = null;
do {
const response = await getBlocks(blockId, cursor);
allBlocks = allBlocks.concat(response.results);
cursor = response.has_more ? response.next_cursor : null;
} while (cursor);
return allBlocks;
}
// Extract plain text from rich_text array
function richTextToPlain(richText) {
if (!richText || richText.length === 0) return '';
return richText.map(rt => rt.plain_text || '').join('');
}
// Extract formatted text from rich_text array (with markdown)
function richTextToMarkdown(richText) {
if (!richText || richText.length === 0) return '';
return richText.map(rt => {
let text = rt.plain_text || '';
const ann = rt.annotations || {};
// Apply formatting
if (ann.code) text = `\`${text}\``;
if (ann.bold) text = `**${text}**`;
if (ann.italic) text = `*${text}*`;
if (ann.strikethrough) text = `~~${text}~~`;
// Handle links
if (rt.href) {
text = `[${text}](${rt.href})`;
} else if (rt.text && rt.text.link) {
text = `[${text}](${rt.text.link.url})`;
}
return text;
}).join('');
}
// Convert Notion blocks to markdown
function blocksToMarkdown(blocks) {
const lines = [];
for (const block of blocks) {
const type = block.type;
const content = block[type];
switch (type) {
case 'heading_1':
lines.push(`# ${richTextToMarkdown(content.rich_text)}`);
lines.push('');
break;
case 'heading_2':
lines.push(`## ${richTextToMarkdown(content.rich_text)}`);
lines.push('');
break;
case 'heading_3':
lines.push(`### ${richTextToMarkdown(content.rich_text)}`);
lines.push('');
break;
case 'paragraph':
const text = richTextToMarkdown(content.rich_text);
if (text.trim()) {
lines.push(text);
lines.push('');
}
break;
case 'bulleted_list_item':
lines.push(`- ${richTextToMarkdown(content.rich_text)}`);
break;
case 'numbered_list_item':
lines.push(`1. ${richTextToMarkdown(content.rich_text)}`);
break;
case 'code':
const code = richTextToPlain(content.rich_text);
const lang = content.language || 'plain text';
lines.push(`\`\`\`${lang}`);
lines.push(code);
lines.push('```');
lines.push('');
break;
case 'divider':
lines.push('---');
lines.push('');
break;
case 'quote':
lines.push(`> ${richTextToMarkdown(content.rich_text)}`);
lines.push('');
break;
case 'callout':
const emoji = content.icon?.emoji || '📌';
lines.push(`${emoji} ${richTextToMarkdown(content.rich_text)}`);
lines.push('');
break;
// Skip unsupported block types silently
default:
break;
}
}
return lines.join('\n');
}
// Main execution
async function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: notion-to-md.js <page-id> [output-file]');
process.exit(1);
}
const pageId = normalizeId(args[0]);
const outputFile = args[1] || null;
try {
// Fetch page metadata
const page = await getPage(pageId);
const title = page.properties?.title?.title?.[0]?.plain_text || 'Untitled';
// Fetch all blocks
const blocks = await getAllBlocks(pageId);
// Convert to markdown
const markdown = blocksToMarkdown(blocks);
// Output
if (outputFile) {
const fs = require('fs');
const fullMarkdown = `# ${title}\n\n${markdown}`;
fs.writeFileSync(outputFile, fullMarkdown, 'utf8');
console.log(`✓ Saved to ${outputFile}`);
} else {
console.log(markdown);
}
// Return metadata for programmatic use
return {
title,
lastEditedTime: page.last_edited_time,
markdown,
blockCount: blocks.length
};
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
// Allow import as module or CLI use
if (require.main === module) {
main();
} else {
module.exports = { getPage, getAllBlocks, blocksToMarkdown, normalizeId };
}

View File

@@ -0,0 +1,258 @@
/**
* Shared utilities for Notion API scripts
* Common functions for HTTP requests, error handling, and data extraction
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
/**
* Make a Notion API request with proper error handling
*/
function notionRequest(path, method, data = null) {
return new Promise((resolve, reject) => {
const requestData = data ? JSON.stringify(data) : null;
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
}
};
if (requestData) {
options.headers['Content-Length'] = Buffer.byteLength(requestData);
}
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(createDetailedError(res.statusCode, body));
}
});
});
req.on('error', reject);
if (requestData) {
req.write(requestData);
}
req.end();
});
}
/**
* Create detailed error message based on status code and response
*/
function createDetailedError(statusCode, body) {
let error;
try {
error = JSON.parse(body);
} catch (e) {
return new Error(`API error (${statusCode}): ${body}`);
}
const errorCode = error.code;
const errorMessage = error.message;
switch (statusCode) {
case 400:
if (errorCode === 'validation_error') {
return new Error(`Validation error: ${errorMessage}. Check your input data.`);
}
return new Error(`Bad request: ${errorMessage}`);
case 401:
return new Error('Authentication failed. Check your NOTION_API_KEY environment variable.');
case 404:
if (errorCode === 'object_not_found') {
return new Error('Page/database not found. Make sure it is shared with your integration.');
}
return new Error(`Not found: ${errorMessage}`);
case 429:
return new Error('Rate limit exceeded. Wait a moment and try again.');
case 500:
case 503:
return new Error(`Notion server error (${statusCode}). Try again later.`);
default:
return new Error(`API error (${statusCode}): ${errorMessage || body}`);
}
}
/**
* Extract title from a page or database object
*/
function extractTitle(item) {
if (item.object === 'page') {
const titleProp = Object.values(item.properties || {}).find(p => p.type === 'title');
if (titleProp && titleProp.title && titleProp.title.length > 0) {
return titleProp.title[0].plain_text;
}
} else if (item.object === 'database' || item.object === 'data_source') {
if (item.title && item.title.length > 0) {
return item.title[0].plain_text;
}
}
return '(Untitled)';
}
/**
* Extract value from a property based on its type
*/
function extractPropertyValue(property) {
switch (property.type) {
case 'title':
return property.title.map(t => t.plain_text).join('');
case 'rich_text':
return property.rich_text.map(t => t.plain_text).join('');
case 'number':
return property.number;
case 'select':
return property.select?.name || null;
case 'multi_select':
return property.multi_select.map(s => s.name);
case 'date':
return property.date ? {
start: property.date.start,
end: property.date.end
} : null;
case 'checkbox':
return property.checkbox;
case 'url':
return property.url;
case 'email':
return property.email;
case 'phone_number':
return property.phone_number;
case 'relation':
return property.relation.map(r => r.id);
case 'created_time':
return property.created_time;
case 'last_edited_time':
return property.last_edited_time;
default:
return property[property.type];
}
}
/**
* Parse rich text with basic markdown formatting
*/
function parseRichText(text) {
const richText = [];
const maxLength = 2000;
// Split text into chunks if needed
if (text.length <= maxLength) {
richText.push({
type: 'text',
text: { content: text }
});
} else {
// Split into chunks
for (let i = 0; i < text.length; i += maxLength) {
richText.push({
type: 'text',
text: { content: text.substring(i, i + maxLength) }
});
}
}
return richText;
}
/**
* Check if NOTION_API_KEY is set
*/
function checkApiKey() {
if (!NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
console.error('');
console.error('Setup:');
console.error(' 1. Create a Notion integration at https://www.notion.so/my-integrations');
console.error(' 2. Store the API key in macOS Keychain');
console.error(' 3. Add to environment loader (e.g., ~/.openclaw/bin/openclaw-env.sh):');
console.error(' export NOTION_API_KEY="$(security find-generic-password -a "$USER" -s "openclaw.notion_api_key" -w)"');
console.error(' 4. Restart gateway: openclaw gateway restart');
process.exit(1);
}
}
/**
* Format property value for database operations
*/
function formatPropertyValue(propertyType, value) {
switch (propertyType) {
case 'select':
return { select: { name: value } };
case 'multi_select':
const tags = Array.isArray(value) ? value : value.split(',').map(t => t.trim());
return { multi_select: tags.map(name => ({ name })) };
case 'checkbox':
const boolValue = typeof value === 'boolean' ? value :
(value.toLowerCase() === 'true' || value === '1');
return { checkbox: boolValue };
case 'number':
return { number: typeof value === 'number' ? value : parseFloat(value) };
case 'url':
return { url: value };
case 'email':
return { email: value };
case 'date':
if (typeof value === 'string') {
const dates = value.split(',').map(d => d.trim());
return {
date: {
start: dates[0],
end: dates[1] || null
}
};
}
return { date: value };
case 'rich_text':
return {
rich_text: [{ type: 'text', text: { content: value } }]
};
case 'title':
return {
title: [{ type: 'text', text: { content: value } }]
};
default:
throw new Error(`Unsupported property type: ${propertyType}`);
}
}
module.exports = {
notionRequest,
createDetailedError,
extractTitle,
extractPropertyValue,
parseRichText,
checkApiKey,
formatPropertyValue,
NOTION_API_KEY,
NOTION_VERSION
};

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* Query Notion database with filters and sorts
* Usage: query-database.js <database-id> [--filter <json>] [--sort <json>] [--limit 10]
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
if (!NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
process.exit(1);
}
function notionRequest(path, method, data = null) {
return new Promise((resolve, reject) => {
const requestData = data ? JSON.stringify(data) : null;
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json'
}
};
if (requestData) {
options.headers['Content-Length'] = Buffer.byteLength(requestData);
}
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
const error = JSON.parse(body);
if (res.statusCode === 404 && error.code === 'object_not_found') {
reject(new Error('Database not found. Make sure it is shared with your integration.'));
} else if (res.statusCode === 401) {
reject(new Error('Authentication failed. Check your NOTION_API_KEY.'));
} else if (res.statusCode === 429) {
reject(new Error('Rate limit exceeded. Wait a moment and try again.'));
} else {
reject(new Error(`API error (${res.statusCode}): ${error.message || body}`));
}
}
});
});
req.on('error', reject);
if (requestData) {
req.write(requestData);
}
req.end();
});
}
async function queryDatabase(databaseId, filter = null, sorts = null, pageSize = 10) {
// In Notion API 2025-09-03, we need the data_source_id for queries
// Get the database first to extract data_source_id
console.error(`Fetching database info: ${databaseId}`);
const dbInfo = await notionRequest(`/v1/databases/${databaseId}`, 'GET');
const dataSourceId = dbInfo.data_sources && dbInfo.data_sources.length > 0
? dbInfo.data_sources[0].id
: databaseId; // Fallback to database_id if no data_source found
console.error(`Querying data source: ${dataSourceId}`);
const queryPayload = {
page_size: pageSize
};
if (filter) {
queryPayload.filter = filter;
console.error('Filter:', JSON.stringify(filter, null, 2));
}
if (sorts) {
queryPayload.sorts = sorts;
console.error('Sort:', JSON.stringify(sorts, null, 2));
}
const result = await notionRequest(`/v1/data_sources/${dataSourceId}/query`, 'POST', queryPayload);
return result.results.map(page => {
const properties = {};
for (const [key, value] of Object.entries(page.properties)) {
properties[key] = extractPropertyValue(value);
}
return {
id: page.id,
url: page.url,
lastEdited: page.last_edited_time,
properties: properties
};
});
}
function extractPropertyValue(property) {
switch (property.type) {
case 'title':
return property.title.map(t => t.plain_text).join('');
case 'rich_text':
return property.rich_text.map(t => t.plain_text).join('');
case 'number':
return property.number;
case 'select':
return property.select?.name || null;
case 'multi_select':
return property.multi_select.map(s => s.name);
case 'date':
return property.date ? {
start: property.date.start,
end: property.date.end
} : null;
case 'checkbox':
return property.checkbox;
case 'url':
return property.url;
case 'email':
return property.email;
case 'phone_number':
return property.phone_number;
case 'relation':
return property.relation.map(r => r.id);
default:
return property[property.type];
}
}
// Main execution
(async () => {
try {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log('Usage: query-database.js <database-id> [options]');
console.log('');
console.log('Options:');
console.log(' --filter <json> Filter expression (JSON)');
console.log(' --sort <json> Sort expression (JSON)');
console.log(' --limit <num> Maximum results (default: 10)');
console.log('');
console.log('Examples:');
console.log(' # Get all items');
console.log(' query-database.js xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
console.log('');
console.log(' # Filter by status');
console.log(' query-database.js <db-id> --filter \'{"property": "Status", "select": {"equals": "Complete"}}\'');
console.log('');
console.log(' # Filter by tag (multi-select contains)');
console.log(' query-database.js <db-id> --filter \'{"property": "Tags", "multi_select": {"contains": "AI"}}\'');
console.log('');
console.log(' # Sort by date descending');
console.log(' query-database.js <db-id> --sort \'[{"property": "Date", "direction": "descending"}]\'');
console.log('');
console.log(' # Combine filter + sort');
console.log(' query-database.js <db-id> \\');
console.log(' --filter \'{"property": "Status", "select": {"equals": "Complete"}}\' \\');
console.log(' --sort \'[{"property": "Date", "direction": "descending"}]\'');
process.exit(0);
}
const databaseId = args[0];
let filter = null;
let sorts = null;
let limit = 10;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--filter' && args[i + 1]) {
filter = JSON.parse(args[i + 1]);
i++;
} else if (args[i] === '--sort' && args[i + 1]) {
sorts = JSON.parse(args[i + 1]);
i++;
} else if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1]);
i++;
}
}
const results = await queryDatabase(databaseId, filter, sorts, limit);
console.log(JSON.stringify(results, null, 2));
console.error(`\n✓ Found ${results.length} result(s)`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
})();

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* Search Notion for pages and databases
* Usage: search-notion.js <query> [--filter page|database] [--limit 10]
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
if (!NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
process.exit(1);
}
function notionRequest(path, method, data) {
return new Promise((resolve, reject) => {
const requestData = JSON.stringify(data);
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestData)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
const error = JSON.parse(body);
if (res.statusCode === 404 && error.code === 'object_not_found') {
reject(new Error('Page/database not found. Make sure it is shared with your integration.'));
} else if (res.statusCode === 401) {
reject(new Error('Authentication failed. Check your NOTION_API_KEY.'));
} else if (res.statusCode === 429) {
reject(new Error('Rate limit exceeded. Wait a moment and try again.'));
} else {
reject(new Error(`API error (${res.statusCode}): ${error.message || body}`));
}
}
});
});
req.on('error', reject);
req.write(requestData);
req.end();
});
}
async function searchNotion(query, filter = null, pageSize = 10) {
const searchPayload = {
query: query,
page_size: pageSize
};
if (filter) {
searchPayload.filter = {
property: 'object',
value: filter // 'page' or 'database'
};
}
console.error(`Searching for: "${query}"${filter ? ` (filter: ${filter})` : ''}`);
const result = await notionRequest('/v1/search', 'POST', searchPayload);
return result.results.map(item => ({
id: item.id,
object: item.object,
title: extractTitle(item),
url: item.url,
lastEdited: item.last_edited_time,
parent: item.parent
}));
}
function extractTitle(item) {
if (item.object === 'page') {
const titleProp = Object.values(item.properties || {}).find(p => p.type === 'title');
if (titleProp && titleProp.title && titleProp.title.length > 0) {
return titleProp.title[0].plain_text;
}
} else if (item.object === 'database' || item.object === 'data_source') {
if (item.title && item.title.length > 0) {
return item.title[0].plain_text;
}
}
return '(Untitled)';
}
// Main execution
(async () => {
try {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log('Usage: search-notion.js <query> [options]');
console.log('');
console.log('Options:');
console.log(' --filter <page|database> Filter by object type');
console.log(' --limit <number> Maximum results (default: 10)');
console.log('');
console.log('Examples:');
console.log(' search-notion.js "newsletter"');
console.log(' search-notion.js "research" --filter page');
console.log(' search-notion.js "AI" --limit 20');
process.exit(0);
}
const query = args[0];
let filter = null;
let limit = 10;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--filter' && args[i + 1]) {
filter = args[i + 1];
i++;
} else if (args[i] === '--limit' && args[i + 1]) {
limit = parseInt(args[i + 1]);
i++;
}
}
const results = await searchNotion(query, filter, limit);
console.log(JSON.stringify(results, null, 2));
console.error(`\n✓ Found ${results.length} result(s)`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
})();

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Test for normalizeId function
*/
const { normalizeId } = require('./notion-to-md.js');
// Test cases
const testCases = [
// With hyphens (standard UUID format)
{
input: 'abc12345-6789-0123-4567-890abcdef012',
expected: 'abc12345-6789-0123-4567-890abcdef012',
desc: 'Standard UUID with hyphens'
},
// Without hyphens (compact format)
{
input: 'abc12345678901234567890abcdef012',
expected: 'abc12345-6789-0123-4567-890abcdef012',
desc: 'Compact format without hyphens'
},
// Mixed case
{
input: 'ABC12345678901234567890ABCDEF012',
expected: 'ABC12345-6789-0123-4567-890ABCDEF012',
desc: 'Uppercase compact format'
},
// Already normalized with hyphens
{
input: '12a85c78-1e0b-481a-98d5-e122e8e9c5f3',
expected: '12a85c78-1e0b-481a-98d5-e122e8e9c5f3',
desc: 'Real Notion UUID'
},
// Real Notion ID without hyphens
{
input: '12a85c781e0b481a98d5e122e8e9c5f3',
expected: '12a85c78-1e0b-481a-98d5-e122e8e9c5f3',
desc: 'Real Notion UUID without hyphens'
},
// Invalid length (should return as-is)
{
input: 'tooshort',
expected: 'tooshort',
desc: 'Invalid format (too short)'
}
];
console.log('Testing normalizeId function:\n');
let passed = 0;
let failed = 0;
testCases.forEach((test, idx) => {
const result = normalizeId(test.input);
const success = result === test.expected;
if (success) {
console.log(`✓ Test ${idx + 1}: ${test.desc}`);
console.log(` Input: ${test.input}`);
console.log(` Output: ${result}`);
passed++;
} else {
console.log(`✗ Test ${idx + 1}: ${test.desc}`);
console.log(` Input: ${test.input}`);
console.log(` Expected: ${test.expected}`);
console.log(` Got: ${result}`);
failed++;
}
console.log();
});
console.log(`Results: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env node
/**
* Update Notion page properties (for database pages)
* Usage: update-page-properties.js <page-id> <property-name> <value> [--type select|multi_select|checkbox|number|url|email|date]
*/
const https = require('https');
const NOTION_API_KEY = process.env.NOTION_API_KEY;
const NOTION_VERSION = '2025-09-03';
if (!NOTION_API_KEY) {
console.error('Error: NOTION_API_KEY environment variable not set');
process.exit(1);
}
function notionRequest(path, method, data) {
return new Promise((resolve, reject) => {
const requestData = JSON.stringify(data);
const options = {
hostname: 'api.notion.com',
port: 443,
path: path,
method: method,
headers: {
'Authorization': `Bearer ${NOTION_API_KEY}`,
'Notion-Version': NOTION_VERSION,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestData)
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
const error = JSON.parse(body);
if (res.statusCode === 404 && error.code === 'object_not_found') {
reject(new Error('Page not found. Make sure it is shared with your integration.'));
} else if (res.statusCode === 401) {
reject(new Error('Authentication failed. Check your NOTION_API_KEY.'));
} else if (res.statusCode === 400 && error.code === 'validation_error') {
reject(new Error(`Validation error: ${error.message}. Check property name and type.`));
} else if (res.statusCode === 429) {
reject(new Error('Rate limit exceeded. Wait a moment and try again.'));
} else {
reject(new Error(`API error (${res.statusCode}): ${error.message || body}`));
}
}
});
});
req.on('error', reject);
req.write(requestData);
req.end();
});
}
function formatPropertyValue(propertyType, value) {
switch (propertyType) {
case 'select':
return { select: { name: value } };
case 'multi_select':
// Value can be comma-separated: "AI,Leadership,Research"
const tags = value.split(',').map(t => t.trim());
return { multi_select: tags.map(name => ({ name })) };
case 'checkbox':
const boolValue = value.toLowerCase() === 'true' || value === '1';
return { checkbox: boolValue };
case 'number':
return { number: parseFloat(value) };
case 'url':
return { url: value };
case 'email':
return { email: value };
case 'date':
// Value can be "2024-01-15" or "2024-01-15,2024-01-20" for range
const dates = value.split(',').map(d => d.trim());
return {
date: {
start: dates[0],
end: dates[1] || null
}
};
case 'rich_text':
return {
rich_text: [{ type: 'text', text: { content: value } }]
};
default:
throw new Error(`Unsupported property type: ${propertyType}. Supported: select, multi_select, checkbox, number, url, email, date, rich_text`);
}
}
async function updatePageProperties(pageId, propertyName, value, propertyType = 'select') {
const properties = {};
properties[propertyName] = formatPropertyValue(propertyType, value);
console.error(`Updating page: ${pageId}`);
console.error(`Property: ${propertyName} (${propertyType})`);
console.error(`Value: ${value}`);
const result = await notionRequest(`/v1/pages/${pageId}`, 'PATCH', { properties });
return {
id: result.id,
url: result.url,
updated: result.last_edited_time
};
}
// Main execution
(async () => {
try {
const args = process.argv.slice(2);
if (args.length < 3 || args[0] === '--help') {
console.log('Usage: update-page-properties.js <page-id> <property-name> <value> [--type <type>]');
console.log('');
console.log('Options:');
console.log(' --type <type> Property type (default: select)');
console.log('');
console.log('Supported types:');
console.log(' select Single choice (e.g., "Complete")');
console.log(' multi_select Multiple choices, comma-separated (e.g., "AI,Leadership")');
console.log(' checkbox Boolean (e.g., "true" or "false")');
console.log(' number Numeric value (e.g., "1200")');
console.log(' url URL string');
console.log(' email Email address');
console.log(' date Date or range (e.g., "2024-01-15" or "2024-01-15,2024-01-20")');
console.log(' rich_text Text content');
console.log('');
console.log('Examples:');
console.log(' # Set status to Complete');
console.log(' update-page-properties.js <page-id> Status Complete --type select');
console.log('');
console.log(' # Add multiple tags');
console.log(' update-page-properties.js <page-id> Tags "AI,Leadership,Research" --type multi_select');
console.log('');
console.log(' # Set checkbox');
console.log(' update-page-properties.js <page-id> Published true --type checkbox');
console.log('');
console.log(' # Set date');
console.log(' update-page-properties.js <page-id> "Publish Date" 2024-02-01 --type date');
console.log('');
console.log(' # Set URL');
console.log(' update-page-properties.js <page-id> "Source URL" "https://example.com" --type url');
process.exit(0);
}
const pageId = args[0];
const propertyName = args[1];
const value = args[2];
let propertyType = 'select';
for (let i = 3; i < args.length; i++) {
if (args[i] === '--type' && args[i + 1]) {
propertyType = args[i + 1];
i++;
}
}
const result = await updatePageProperties(pageId, propertyName, value, propertyType);
console.log(JSON.stringify(result, null, 2));
console.error(`\n✓ Page updated successfully`);
console.error(` URL: ${result.url}`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
})();

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
/**
* Notion Page Watcher
* Monitors a Notion page for changes and suggests next actions
*/
const fs = require('fs');
const path = require('path');
const { getPage, getAllBlocks, blocksToMarkdown, normalizeId } = require('./notion-to-md.js');
const STATE_FILE = path.join(__dirname, '../memory/notion-watch-state.json');
// Load watch state
function loadState() {
if (!fs.existsSync(STATE_FILE)) {
return { pages: {} };
}
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
}
// Save watch state
function saveState(state) {
const dir = path.dirname(STATE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
}
// Check a specific page for changes
async function checkPage(pageId, localPath) {
try {
// Normalize pageId to handle different formats
const normalizedPageId = normalizeId(pageId);
const state = loadState();
const pageState = state.pages[normalizedPageId] || {};
// Fetch current page state
const page = await getPage(normalizedPageId);
const lastEditedTime = page.last_edited_time;
const title = page.properties?.title?.title?.[0]?.plain_text || 'Untitled';
// Check if page was edited since last check
const hasChanges = !pageState.lastEditedTime ||
new Date(lastEditedTime) > new Date(pageState.lastEditedTime);
const result = {
pageId: normalizedPageId,
title,
lastEditedTime,
hasChanges,
localPath,
actions: []
};
if (hasChanges) {
// Fetch blocks and convert to markdown
const blocks = await getAllBlocks(normalizedPageId);
const notionMarkdown = blocksToMarkdown(blocks);
// Compare with local file if it exists
let localMarkdown = '';
let localDiffers = false;
if (fs.existsSync(localPath)) {
localMarkdown = fs.readFileSync(localPath, 'utf8');
// Simple comparison (could be enhanced with proper diff)
localDiffers = localMarkdown.trim() !== notionMarkdown.trim();
}
result.notionMarkdown = notionMarkdown;
result.localDiffers = localDiffers;
result.blockCount = blocks.length;
// Suggest actions
if (pageState.lastEditedTime) {
result.actions.push(`📝 Page edited since last check (${new Date(pageState.lastEditedTime).toLocaleString()})`);
} else {
result.actions.push('🆕 First time checking this page');
}
if (localDiffers) {
result.actions.push(`⚠️ Local markdown differs from Notion version`);
result.actions.push(`💡 Suggested: Sync Notion → markdown to update local file`);
}
// Update state
pageState.lastEditedTime = lastEditedTime;
pageState.lastChecked = new Date().toISOString();
pageState.title = title;
state.pages[normalizedPageId] = pageState;
saveState(state);
} else {
result.actions.push('✓ No changes since last check');
}
return result;
} catch (error) {
return {
pageId: normalizeId(pageId),
error: error.message,
actions: [`❌ Error checking page: ${error.message}`]
};
}
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
// Get page ID and local path from args or environment
let pageId = args[0];
let localPath = args[1];
// Fallback to environment variables if not provided
if (!pageId) pageId = process.env.NOTION_WATCH_PAGE_ID;
if (!localPath) localPath = process.env.NOTION_WATCH_LOCAL_PATH;
if (!pageId || !localPath) {
console.error(`Usage: watch-notion.js <page-id> <local-path>
Arguments:
page-id Notion page ID to monitor
local-path Local markdown file path for comparison
Environment variables (optional):
NOTION_WATCH_PAGE_ID Default page ID
NOTION_WATCH_LOCAL_PATH Default local path
Examples:
node watch-notion.js "abc123..." "/path/to/draft.md"
# Using environment variables
export NOTION_WATCH_PAGE_ID="abc123..."
export NOTION_WATCH_LOCAL_PATH="/path/to/draft.md"
node watch-notion.js
`);
process.exit(1);
}
const result = await checkPage(pageId, localPath);
console.log(JSON.stringify(result, null, 2));
return result;
}
if (require.main === module) {
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
} else {
module.exports = { checkPage };
}