guanghulab/hldp/bridge/notion-to-hldp.js

327 lines
9.8 KiB
JavaScript
Raw Normal View History

/**
* HLDP Notion HLDP 转换器
* TCS 通感语言核系统编程语言 · 第一个落地协议层
* HLDP = TCS Notion GitHub 通道上的落地实现
* 版权国作登字-2026-A-00037559 · 冰朔ICE-GL
* 指令来源SY-CMD-BRG-005
*
* 转换规则
* 1. Notion 页面标题 metadata.name
* 2. Notion 页面属性 payload 中对应字段
* 3. Notion @提及 relations 数组
* 4. Notion 关系属性 relations 数组
* 5. Notion 选择/多选属性 payload 中的字符串/数组
* 6. Notion 日期属性 ISO-8601 字符串
* 7. Notion 页面内容blocks payload.contentMarkdown 格式
*
*/
const fs = require('fs');
const path = require('path');
const HLDP_VERSION = '1.0';
/**
* Extract plain text from Notion rich_text array
*/
function extractPlainText(richTextArr) {
if (!Array.isArray(richTextArr)) return '';
return richTextArr.map(t => t.plain_text || '').join('');
}
/**
* Extract title from Notion page properties
*/
function extractTitle(properties) {
for (const [, prop] of Object.entries(properties || {})) {
if (prop.type === 'title') {
return extractPlainText(prop.title);
}
}
return '';
}
/**
* Convert a Notion property to a plain value
*/
function convertProperty(prop) {
if (!prop) return null;
switch (prop.type) {
case 'title':
return extractPlainText(prop.title);
case 'rich_text':
return extractPlainText(prop.rich_text);
case 'number':
return prop.number;
case 'select':
return prop.select ? prop.select.name : null;
case 'multi_select':
return (prop.multi_select || []).map(s => s.name);
case 'date':
return prop.date ? prop.date.start : null;
case 'checkbox':
return prop.checkbox;
case 'url':
return prop.url;
case 'email':
return prop.email;
case 'phone_number':
return prop.phone_number;
case 'formula':
return prop.formula ? convertFormulaResult(prop.formula) : null;
case 'relation':
return (prop.relation || []).map(r => r.id);
case 'rollup':
return prop.rollup ? prop.rollup.array : null;
case 'people':
return (prop.people || []).map(p => p.name || p.id);
case 'status':
return prop.status ? prop.status.name : null;
default:
return null;
}
}
function convertFormulaResult(formula) {
switch (formula.type) {
case 'string': return formula.string;
case 'number': return formula.number;
case 'boolean': return formula.boolean;
case 'date': return formula.date ? formula.date.start : null;
default: return null;
}
}
/**
* Convert Notion blocks to Markdown
*/
function blocksToMarkdown(blocks) {
if (!Array.isArray(blocks)) return '';
return blocks.map(block => {
const text = extractPlainText(block[block.type]?.rich_text || []);
switch (block.type) {
case 'paragraph': return text;
case 'heading_1': return `# ${text}`;
case 'heading_2': return `## ${text}`;
case 'heading_3': return `### ${text}`;
case 'bulleted_list_item': return `- ${text}`;
case 'numbered_list_item': return `1. ${text}`;
case 'to_do': {
const checked = block.to_do?.checked ? 'x' : ' ';
return `- [${checked}] ${text}`;
}
case 'toggle': return `<details><summary>${text}</summary></details>`;
case 'code': {
const lang = block.code?.language || '';
return `\`\`\`${lang}\n${text}\n\`\`\``;
}
case 'quote': return `> ${text}`;
case 'callout': {
const icon = block.callout?.icon?.emoji || '💡';
return `> ${icon} ${text}`;
}
case 'divider': return '---';
default: return text;
}
}).filter(Boolean).join('\n\n');
}
/**
* Extract relations from Notion properties
*/
function extractRelations(properties) {
const relations = [];
for (const [key, prop] of Object.entries(properties || {})) {
if (prop.type === 'relation' && Array.isArray(prop.relation)) {
for (const rel of prop.relation) {
relations.push({
target_id: rel.id,
relation_type: 'reference',
description: `Notion relation: ${key}`
});
}
}
}
return relations;
}
/**
* Detect data_type from Notion page properties and tags
*/
function detectDataType(properties, tags) {
const allTags = (tags || []).map(t => t.toLowerCase());
const title = extractTitle(properties).toLowerCase();
if (allTags.includes('persona') || allTags.includes('人格体') || title.includes('人格体')) return 'persona';
if (allTags.includes('registry') || allTags.includes('注册表') || title.includes('注册表')) return 'registry';
if (allTags.includes('instruction') || allTags.includes('指令') || title.includes('指令')) return 'instruction';
if (allTags.includes('broadcast') || allTags.includes('广播') || title.includes('广播')) return 'broadcast';
if (allTags.includes('id_system') || allTags.includes('编号') || title.includes('编号')) return 'id_system';
return 'registry'; // default fallback
}
/**
* Convert a Notion page to HLDP JSON format
* @param {Object} page - Notion API page object
* @param {Array} blocks - Notion page blocks (optional)
* @param {string} dataType - Override data_type detection
* @returns {Object} HLDP JSON
*/
function notionPageToHLDP(page, blocks, dataType) {
const properties = page.properties || {};
const title = extractTitle(properties);
// Extract tags from multi_select property named 'Tags' or '标签'
const tagsProp = properties.Tags || properties['标签'];
const tags = tagsProp?.type === 'multi_select'
? (tagsProp.multi_select || []).map(s => s.name)
: [];
const type = dataType || detectDataType(properties, tags);
// Build payload from all non-system properties
const payload = {};
for (const [key, prop] of Object.entries(properties)) {
const val = convertProperty(prop);
if (val !== null && val !== '' && key !== 'Tags' && key !== '标签') {
payload[key] = val;
}
}
// Add content from blocks if provided
if (blocks && blocks.length > 0) {
payload.content = blocksToMarkdown(blocks);
}
// Extract ID from properties or generate from title
const idProp = properties['ID'] || properties['编号'] || properties['id'];
const id = idProp ? convertProperty(idProp) : `HLDP-${Date.now()}`;
return {
hldp_version: HLDP_VERSION,
data_type: type,
source: {
platform: 'notion',
page_url: page.url || '',
page_id: page.id || '',
last_edited: page.last_edited_time || new Date().toISOString(),
edited_by: page.last_edited_by?.name || 'unknown'
},
metadata: {
id: String(id),
name: title,
name_en: '',
created: page.created_time || new Date().toISOString(),
tags
},
payload,
relations: extractRelations(properties)
};
}
/**
* Convert a Notion database query result to an array of HLDP entries
* @param {Object} queryResult - Notion API database query result
* @param {string} dataType - Data type for all entries
* @returns {Array} Array of HLDP JSON objects
*/
function notionDatabaseToHLDP(queryResult, dataType) {
const results = queryResult.results || [];
return results.map(page => notionPageToHLDP(page, null, dataType));
}
/**
* CLI: Convert raw Notion JSON files to HLDP format
*/
function runCLI() {
const args = process.argv.slice(2);
let inputDir = '';
let outputDir = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--input' && args[i + 1]) { inputDir = args[i + 1]; i++; }
if (args[i] === '--output' && args[i + 1]) { outputDir = args[i + 1]; i++; }
}
if (!inputDir || !outputDir) {
console.log('Usage: node notion-to-hldp.js --input <raw-dir> --output <hldp-data-dir>');
process.exit(1);
}
const root = path.resolve(__dirname, '../..');
inputDir = path.resolve(root, inputDir);
outputDir = path.resolve(root, outputDir);
if (!fs.existsSync(inputDir)) {
console.log(`⚠️ 输入目录不存在: ${inputDir}`);
process.exit(0);
}
let converted = 0;
let failed = 0;
const files = fs.readdirSync(inputDir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(inputDir, file), 'utf8'));
// Handle both single page and database query results
let hldpEntries;
if (raw.results) {
hldpEntries = notionDatabaseToHLDP(raw);
} else if (raw.id) {
hldpEntries = [notionPageToHLDP(raw)];
} else {
console.warn(` ⚠️ 无法识别格式: ${file}`);
failed++;
continue;
}
// Determine output subdirectory based on data_type
for (const entry of hldpEntries) {
const typeDir = {
persona: 'personas',
registry: 'registries',
instruction: 'instructions',
broadcast: 'broadcasts',
id_system: 'id-system'
}[entry.data_type] || 'registries';
const outDir = path.join(outputDir, typeDir);
fs.mkdirSync(outDir, { recursive: true });
const safeName = entry.metadata.id.replace(/[^a-zA-Z0-9_-]/g, '_');
const outFile = path.join(outDir, `${safeName}.json`);
fs.writeFileSync(outFile, JSON.stringify(entry, null, 2), 'utf8');
converted++;
}
} catch (e) {
console.error(` ❌ 转换失败 ${file}: ${e.message}`);
failed++;
}
}
console.log(`🔄 Notion → HLDP 转换完成: ${converted} 条成功, ${failed} 条失败`);
}
// Export for programmatic use
module.exports = {
notionPageToHLDP,
notionDatabaseToHLDP,
extractPlainText,
blocksToMarkdown,
convertProperty,
HLDP_VERSION
};
// CLI entry point
if (require.main === module) {
runCLI();
}