AI Newsletter Digest improvements: fixed QP soft line break decoding, URL extraction, and content cleaning
This commit is contained in:
185
archive/inactive-skills/notion-sync/scripts/add-to-database.js
Normal file
185
archive/inactive-skills/notion-sync/scripts/add-to-database.js
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
})();
|
||||
287
archive/inactive-skills/notion-sync/scripts/md-to-notion.js
Normal file
287
archive/inactive-skills/notion-sync/scripts/md-to-notion.js
Normal 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();
|
||||
267
archive/inactive-skills/notion-sync/scripts/notion-to-md.js
Normal file
267
archive/inactive-skills/notion-sync/scripts/notion-to-md.js
Normal 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 };
|
||||
}
|
||||
258
archive/inactive-skills/notion-sync/scripts/notion-utils.js
Normal file
258
archive/inactive-skills/notion-sync/scripts/notion-utils.js
Normal 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
|
||||
};
|
||||
201
archive/inactive-skills/notion-sync/scripts/query-database.js
Normal file
201
archive/inactive-skills/notion-sync/scripts/query-database.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
144
archive/inactive-skills/notion-sync/scripts/search-notion.js
Normal file
144
archive/inactive-skills/notion-sync/scripts/search-notion.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
156
archive/inactive-skills/notion-sync/scripts/watch-notion.js
Normal file
156
archive/inactive-skills/notion-sync/scripts/watch-notion.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user