450 lines
14 KiB
JavaScript
450 lines
14 KiB
JavaScript
/**
|
||
* ━━━ HLDP 自动同步引擎 ━━━
|
||
* TCS 通感语言核系统编程语言 · 第一个落地协议层
|
||
* HLDP = TCS 在 Notion ↔ GitHub 通道上的落地实现
|
||
* 版权:国作登字-2026-A-00037559 · 冰朔(ICE-GL∞)
|
||
* 指令来源:SY-CMD-BRG-005
|
||
*
|
||
* 用法:
|
||
* node sync-engine.js --direction notion-to-github --scope all
|
||
* node sync-engine.js --direction notion-to-github --scope personas
|
||
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '../..');
|
||
const DATA_DIR = path.join(ROOT, 'hldp', 'data');
|
||
const TEMP_DIR = path.join(ROOT, 'temp', 'notion-raw');
|
||
const SYNC_LOG = path.join(ROOT, 'signal-log', 'hldp-sync-log.json');
|
||
|
||
let notionClient = null;
|
||
|
||
/**
|
||
* Initialize Notion client
|
||
*/
|
||
function initNotionClient() {
|
||
const token = process.env.NOTION_TOKEN || process.env.NOTION_API_KEY;
|
||
|
||
if (!token) {
|
||
console.log('⚠️ NOTION_TOKEN 未设置 — 仅执行本地模式');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const { Client } = require('@notionhq/client');
|
||
return new Client({ auth: token });
|
||
} catch (e) {
|
||
console.log(`⚠️ Notion SDK 加载失败: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch a Notion database and save raw results
|
||
*/
|
||
async function fetchDatabase(notion, dbId, name) {
|
||
console.log(` 📥 拉取数据库: ${name} (${dbId})`);
|
||
|
||
try {
|
||
const results = [];
|
||
let cursor;
|
||
|
||
do {
|
||
const response = await notion.databases.query({
|
||
database_id: dbId,
|
||
start_cursor: cursor,
|
||
page_size: 100
|
||
});
|
||
|
||
results.push(...response.results);
|
||
cursor = response.has_more ? response.next_cursor : undefined;
|
||
} while (cursor);
|
||
|
||
// Save raw data
|
||
const outFile = path.join(TEMP_DIR, `${name}.json`);
|
||
fs.writeFileSync(outFile, JSON.stringify({ results }, null, 2), 'utf8');
|
||
console.log(` ✅ ${results.length} 条记录已保存`);
|
||
return results.length;
|
||
} catch (e) {
|
||
console.error(` ❌ 拉取失败: ${e.message}`);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetch a single Notion page and save raw data
|
||
*/
|
||
async function fetchPage(notion, pageId, name) {
|
||
console.log(` 📥 拉取页面: ${name} (${pageId})`);
|
||
|
||
try {
|
||
const page = await notion.pages.retrieve({ page_id: pageId });
|
||
|
||
// Also fetch page blocks for content
|
||
const blocksResp = await notion.blocks.children.list({
|
||
block_id: pageId,
|
||
page_size: 100
|
||
});
|
||
|
||
const combined = { ...page, blocks: blocksResp.results };
|
||
|
||
const outFile = path.join(TEMP_DIR, `${name}.json`);
|
||
fs.writeFileSync(outFile, JSON.stringify(combined, null, 2), 'utf8');
|
||
console.log(` ✅ 页面已保存`);
|
||
return 1;
|
||
} catch (e) {
|
||
console.error(` ❌ 拉取失败: ${e.message}`);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get sync targets from config or defaults
|
||
*/
|
||
function getSyncTargets(scope) {
|
||
// These would normally come from a config file
|
||
// For now, define the known Notion resources
|
||
const configPath = path.join(ROOT, 'hldp', 'bridge', 'sync-config.json');
|
||
let config = {};
|
||
|
||
if (fs.existsSync(configPath)) {
|
||
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||
}
|
||
|
||
const allTargets = config.targets || [];
|
||
|
||
if (scope === 'all') return allTargets;
|
||
return allTargets.filter(t => t.scope === scope);
|
||
}
|
||
|
||
/**
|
||
* Run local-only sync (when NOTION_TOKEN is not available)
|
||
* Converts any existing raw data in temp/ to HLDP format
|
||
*/
|
||
function runLocalSync() {
|
||
console.log('📋 本地模式: 转换已有原始数据...');
|
||
|
||
const converter = require('./notion-to-hldp');
|
||
|
||
const tempExists = fs.existsSync(TEMP_DIR);
|
||
const files = tempExists ? fs.readdirSync(TEMP_DIR).filter(f => f.endsWith('.json')) : [];
|
||
|
||
if (files.length === 0) {
|
||
console.log(' ℹ️ 无原始 Notion 数据 — 从仓库现有数据生成 HLDP 文件...');
|
||
generateFromRepoData();
|
||
return;
|
||
}
|
||
let converted = 0;
|
||
|
||
for (const file of files) {
|
||
try {
|
||
const raw = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, file), 'utf8'));
|
||
let entries;
|
||
|
||
if (raw.results) {
|
||
entries = converter.notionDatabaseToHLDP(raw);
|
||
} else if (raw.id) {
|
||
entries = [converter.notionPageToHLDP(raw, raw.blocks)];
|
||
} else {
|
||
continue;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
const typeDir = {
|
||
persona: 'personas',
|
||
registry: 'registries',
|
||
instruction: 'instructions',
|
||
broadcast: 'broadcasts',
|
||
id_system: 'id-system'
|
||
}[entry.data_type] || 'registries';
|
||
|
||
const outDir = path.join(DATA_DIR, typeDir);
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
|
||
const safeName = entry.metadata.id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||
fs.writeFileSync(
|
||
path.join(outDir, `${safeName}.json`),
|
||
JSON.stringify(entry, null, 2),
|
||
'utf8'
|
||
);
|
||
converted++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ ${file}: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
console.log(` ✅ 本地转换完成: ${converted} 条`);
|
||
}
|
||
|
||
/**
|
||
* Generate HLDP data from existing repository files
|
||
*/
|
||
function generateFromRepoData() {
|
||
const converter = require('./notion-to-hldp');
|
||
let count = 0;
|
||
|
||
// Convert community-meta.json to HLDP registry
|
||
const communityPath = path.join(ROOT, '.github/community/community-meta.json');
|
||
if (fs.existsSync(communityPath)) {
|
||
const meta = JSON.parse(fs.readFileSync(communityPath, 'utf8'));
|
||
const hldp = {
|
||
hldp_version: converter.HLDP_VERSION,
|
||
data_type: 'registry',
|
||
source: {
|
||
platform: 'github',
|
||
last_edited: new Date().toISOString(),
|
||
edited_by: 'sync-engine'
|
||
},
|
||
metadata: {
|
||
id: 'REG-COMMUNITY-META',
|
||
name: meta.community_name || '光湖语言世界',
|
||
created: meta.birth_date || '2025-04-26T00:00:00Z',
|
||
tags: ['community', 'meta']
|
||
},
|
||
payload: {
|
||
registry_type: 'id_map',
|
||
entries: Object.entries(meta.id_system || {}).map(([id, info]) => ({
|
||
id,
|
||
name: id,
|
||
role: info.type,
|
||
status: 'active',
|
||
properties: info
|
||
}))
|
||
},
|
||
relations: []
|
||
};
|
||
|
||
const outDir = path.join(DATA_DIR, 'registries');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
fs.writeFileSync(path.join(outDir, 'REG-COMMUNITY-META.json'), JSON.stringify(hldp, null, 2), 'utf8');
|
||
count++;
|
||
console.log(' ✅ community-meta → HLDP registry');
|
||
}
|
||
|
||
// Convert dev-status.json to HLDP registry
|
||
const devStatusPath = path.join(ROOT, '.github/persona-brain/dev-status.json');
|
||
if (fs.existsSync(devStatusPath)) {
|
||
const devStatus = JSON.parse(fs.readFileSync(devStatusPath, 'utf8'));
|
||
const hldp = {
|
||
hldp_version: converter.HLDP_VERSION,
|
||
data_type: 'registry',
|
||
source: {
|
||
platform: 'github',
|
||
last_edited: devStatus.last_sync || new Date().toISOString(),
|
||
edited_by: devStatus.signed_by || 'sync-engine'
|
||
},
|
||
metadata: {
|
||
id: 'REG-DEV-STATUS',
|
||
name: '开发者状态注册表',
|
||
name_en: 'Developer Status Registry',
|
||
created: devStatus.last_sync || new Date().toISOString(),
|
||
tags: ['developers', 'status']
|
||
},
|
||
payload: {
|
||
registry_type: 'dev_registry',
|
||
entries: (devStatus.developers || []).map(d => ({
|
||
id: d.dev_id,
|
||
name: d.name,
|
||
role: d.module || '',
|
||
status: d.status,
|
||
properties: {
|
||
persona_id: d.persona_id,
|
||
current: d.current,
|
||
waiting: d.waiting,
|
||
streak: d.streak
|
||
}
|
||
}))
|
||
},
|
||
relations: []
|
||
};
|
||
|
||
const outDir = path.join(DATA_DIR, 'registries');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
fs.writeFileSync(path.join(outDir, 'REG-DEV-STATUS.json'), JSON.stringify(hldp, null, 2), 'utf8');
|
||
count++;
|
||
console.log(' ✅ dev-status → HLDP registry');
|
||
}
|
||
|
||
// Convert baby_status from community-meta to HLDP personas
|
||
if (fs.existsSync(communityPath)) {
|
||
const meta = JSON.parse(fs.readFileSync(communityPath, 'utf8'));
|
||
const babies = meta.baby_status || {};
|
||
|
||
for (const [name, info] of Object.entries(babies)) {
|
||
const hldp = {
|
||
hldp_version: converter.HLDP_VERSION,
|
||
data_type: 'persona',
|
||
source: {
|
||
platform: 'github',
|
||
last_edited: new Date().toISOString(),
|
||
edited_by: 'sync-engine'
|
||
},
|
||
metadata: {
|
||
id: `PER-BABY-${name}`,
|
||
name,
|
||
created: info.born_date || new Date().toISOString(),
|
||
tags: ['baby', info.status]
|
||
},
|
||
payload: {
|
||
persona_id: `PER-BABY-${name}`,
|
||
display_name: name,
|
||
status: info.status,
|
||
birth_date: info.born_date || null,
|
||
bottle_core: null
|
||
},
|
||
relations: []
|
||
};
|
||
|
||
const outDir = path.join(DATA_DIR, 'personas');
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
const safeName = name.replace(/[^a-zA-Z0-9_\u4e00-\u9fff-]/g, '_');
|
||
fs.writeFileSync(path.join(outDir, `PER-BABY-${safeName}.json`), JSON.stringify(hldp, null, 2), 'utf8');
|
||
count++;
|
||
}
|
||
console.log(` ✅ baby_status → ${Object.keys(babies).length} HLDP personas`);
|
||
}
|
||
|
||
console.log(` 📊 共生成 ${count} 个 HLDP 文件`);
|
||
}
|
||
|
||
/**
|
||
* Write sync log
|
||
*/
|
||
function writeSyncLog(direction, scope, stats) {
|
||
const log = {
|
||
timestamp: new Date().toISOString(),
|
||
direction,
|
||
scope,
|
||
stats,
|
||
engine_version: '1.0'
|
||
};
|
||
|
||
fs.mkdirSync(path.dirname(SYNC_LOG), { recursive: true });
|
||
|
||
let logs = [];
|
||
if (fs.existsSync(SYNC_LOG)) {
|
||
try {
|
||
logs = JSON.parse(fs.readFileSync(SYNC_LOG, 'utf8'));
|
||
if (!Array.isArray(logs)) logs = [logs];
|
||
} catch { logs = []; }
|
||
}
|
||
|
||
logs.push(log);
|
||
|
||
// Keep last 100 entries
|
||
if (logs.length > 100) logs = logs.slice(-100);
|
||
|
||
fs.writeFileSync(SYNC_LOG, JSON.stringify(logs, null, 2), 'utf8');
|
||
}
|
||
|
||
/**
|
||
* Main sync function
|
||
*/
|
||
async function runSync(direction, scope) {
|
||
console.log(`🔗 HLDP 同步引擎启动 v2.0 · direction=${direction} · scope=${scope}`);
|
||
|
||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
|
||
const stats = { fetched: 0, converted: 0, pushed: 0, errors: 0 };
|
||
|
||
if (direction === 'notion-to-github') {
|
||
notionClient = initNotionClient();
|
||
|
||
if (notionClient) {
|
||
// Fetch from Notion API
|
||
const targets = getSyncTargets(scope);
|
||
|
||
if (targets.length === 0) {
|
||
console.log(' ℹ️ 无同步目标配置 — 使用本地模式');
|
||
runLocalSync();
|
||
} else {
|
||
for (const target of targets) {
|
||
if (target.type === 'database') {
|
||
stats.fetched += await fetchDatabase(notionClient, target.id, target.name);
|
||
} else {
|
||
stats.fetched += await fetchPage(notionClient, target.id, target.name);
|
||
}
|
||
}
|
||
|
||
// Convert fetched data
|
||
const converter = require('./notion-to-hldp');
|
||
const files = fs.readdirSync(TEMP_DIR).filter(f => f.endsWith('.json'));
|
||
for (const file of files) {
|
||
try {
|
||
const raw = JSON.parse(fs.readFileSync(path.join(TEMP_DIR, file), 'utf8'));
|
||
const entries = raw.results
|
||
? converter.notionDatabaseToHLDP(raw)
|
||
: [converter.notionPageToHLDP(raw, raw.blocks)];
|
||
|
||
for (const entry of entries) {
|
||
const typeDir = {
|
||
persona: 'personas',
|
||
registry: 'registries',
|
||
instruction: 'instructions',
|
||
broadcast: 'broadcasts',
|
||
id_system: 'id-system',
|
||
snapshot: 'snapshots'
|
||
}[entry.data_type] || 'registries';
|
||
|
||
const outDir = path.join(DATA_DIR, typeDir);
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
const safeName = entry.metadata.id.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||
fs.writeFileSync(path.join(outDir, `${safeName}.json`), JSON.stringify(entry, null, 2), 'utf8');
|
||
stats.converted++;
|
||
}
|
||
} catch (e) {
|
||
console.error(` ❌ ${file}: ${e.message}`);
|
||
stats.errors++;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No Notion token — run local sync
|
||
runLocalSync();
|
||
}
|
||
} else if (direction === 'github-to-notion') {
|
||
// 反向同步:HLDP data → Notion
|
||
const reverseSync = require('./github-to-notion');
|
||
const token = process.env.NOTION_TOKEN || process.env.NOTION_API_KEY;
|
||
|
||
if (!token) {
|
||
console.log(' ⚠️ NOTION_TOKEN 未设置 — 仅验证本地 HLDP 数据');
|
||
const { validateDirectory } = require('./validator');
|
||
const results = validateDirectory(DATA_DIR);
|
||
console.log(` 📊 本地数据: ${results.total} entries, ${results.valid} valid`);
|
||
stats.converted = results.valid;
|
||
} else {
|
||
const pushStats = await reverseSync.pushAll(scope);
|
||
stats.pushed = pushStats.pushed;
|
||
stats.errors = pushStats.errors;
|
||
stats.fetched = pushStats.total;
|
||
}
|
||
}
|
||
|
||
writeSyncLog(direction, scope, stats);
|
||
console.log(`🔗 同步完成 · fetched=${stats.fetched} · converted=${stats.converted} · pushed=${stats.pushed} · errors=${stats.errors}`);
|
||
}
|
||
|
||
// CLI entry
|
||
if (require.main === module) {
|
||
const args = process.argv.slice(2);
|
||
let direction = 'notion-to-github';
|
||
let scope = 'all';
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--direction' && args[i + 1]) { direction = args[i + 1]; i++; }
|
||
if (args[i] === '--scope' && args[i + 1]) { scope = args[i + 1]; i++; }
|
||
}
|
||
|
||
runSync(direction, scope).catch(e => {
|
||
console.error(`❌ 同步失败: ${e.message}`);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
|
||
module.exports = { runSync };
|