268 lines
7.0 KiB
JavaScript
268 lines
7.0 KiB
JavaScript
#!/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 };
|
|
}
|