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

327 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ━━━ 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();
}