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,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 };
}