import fs from 'node:fs/promises'; import path from 'node:path'; import { htmlUsesPageDataApi } from './page-data-html-detect.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; const PUBLIC_HTML_SKIP_DIRS = new Set([ '.tmp-images', 'assets', 'images', 'shared', 'node_modules', ]); function normalizePublicHtmlPath(relativePath) { return normalizeWorkspaceRelativePath(relativePath); } function titleFromRelativePath(relativePath) { const basename = path.basename(String(relativePath ?? ''), '.html'); return basename.replace(/[-_]+/g, ' ').trim() || '生成的页面'; } function extractHtmlTitle(content) { const match = String(content).match(/]*>([^<]+)<\/title>/i); return match?.[1]?.trim() ?? ''; } function extractHtmlSummary(content) { const match = String(content).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i); return match?.[1]?.trim().slice(0, 1000) ?? ''; } async function findPageByWorkspaceRelativePath(pool, userId, relativePath) { const [rows] = await pool.query( `SELECT p.id, p.updated_at, pv.version_no FROM h5_page_records p LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id WHERE p.user_id = ? AND p.status <> 'deleted' AND ( p.workspace_relative_path = ? OR ( (p.workspace_relative_path IS NULL OR p.workspace_relative_path = '') AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? ) ) ORDER BY p.updated_at DESC, p.id DESC LIMIT 1`, [userId, relativePath, relativePath], ); const row = rows[0]; if (!row) return null; return { id: row.id, updatedAt: Number(row.updated_at ?? 0), versionNo: Number(row.version_no ?? 0) || null, }; } function parseJsonColumn(value, fallback = {}) { if (value == null || value === '') return { ...fallback }; if (typeof value === 'object' && !Buffer.isBuffer(value)) return value; try { return JSON.parse(String(value)); } catch { return { ...fallback }; } } async function loadIndexedWorkspacePages(pool, userId) { const [rows] = await pool.query( `SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path, pv.version_no, pv.source_snapshot_json FROM h5_page_records p LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id WHERE p.user_id = ? AND p.status <> 'deleted'`, [userId], ); const byPath = new Map(); for (const row of rows) { const snapshot = parseJsonColumn(row.source_snapshot_json); const relativePath = normalizePublicHtmlPath(row.workspace_relative_path) ?? normalizePublicHtmlPath(snapshot.relative_path); if (relativePath) { byPath.set(relativePath, { id: row.id, updatedAt: Number(row.updated_at ?? 0), versionNo: Number(row.version_no ?? 0) || null, }); } if (row.source_asset_id && relativePath) { byPath.set(`asset:${row.source_asset_id}`, { id: row.id, updatedAt: Number(row.updated_at ?? 0), versionNo: Number(row.version_no ?? 0) || null, }); } } return byPath; } async function listPublishPublicHtmlFiles(publishDir) { const publicDir = path.join(publishDir, 'public'); const files = []; async function walk(absDir, relWithinPublic) { let entries; try { entries = await fs.readdir(absDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith('.') || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; const absPath = path.join(absDir, entry.name); const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name; if (entry.isDirectory()) { await walk(absPath, relPath); continue; } if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue; const stat = await fs.stat(absPath); files.push({ relativePath: normalizePublicHtmlPath(`public/${relPath}`), absolutePath: absPath, mtimeMs: stat.mtimeMs, }); } } await walk(publicDir, ''); return files; } async function upsertWorkspaceHtmlPage({ pool, pageService, userId, indexedPages, relativePath, content, assetId = null, sourceMtimeMs = null, }) { const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath); const summary = extractHtmlSummary(content); let existing = indexedPages.get(relativePath) ?? (assetId ? indexedPages.get(`asset:${assetId}`) : null); if (!existing && pool) { existing = await findPageByWorkspaceRelativePath(pool, userId, relativePath); if (existing) { indexedPages.set(relativePath, existing); if (assetId) indexedPages.set(`asset:${assetId}`, existing); } } const pageInput = { title, summary, content, contentFormat: 'html', pageType: 'html', templateId: 'editorial', categoryCode: 'draft', }; const snapshot = { ...(assetId ? { source_asset_id: assetId } : {}), source_category: 'public', content_mode: 'static_html', relative_path: relativePath, auto_synced: true, }; if (existing) { if (sourceMtimeMs != null && existing.updatedAt >= sourceMtimeMs) { return 'skipped'; } await pageService.updatePage(userId, existing.id, { ...pageInput, expectedVersion: existing.versionNo ?? undefined, changeNote: '同步工作区页面更新', }, { snapshot, }); indexedPages.set(relativePath, { id: existing.id, updatedAt: Date.now(), versionNo: existing.versionNo != null ? existing.versionNo + 1 : null, }); return 'updated'; } const page = await pageService.createFromChat(userId, pageInput, { ...(assetId ? { assetId } : {}), snapshot, createdAt: sourceMtimeMs != null && Number.isFinite(Number(sourceMtimeMs)) ? Math.floor(Number(sourceMtimeMs)) : undefined, }); indexedPages.set(relativePath, { id: page.id, updatedAt: Date.now() }); if (assetId) indexedPages.set(`asset:${assetId}`, { id: page.id, updatedAt: Date.now() }); return 'created'; } export async function syncGeneratedPagesFromPublicAssets({ pool, pageService, assetService, userId, publishDir = null, syncWorkspaceAssets = null, onlyRelativePaths = null, } = {}) { if (!pool || !pageService || !userId) { return { created: 0, skipped: 0, updated: 0 }; } if (typeof syncWorkspaceAssets === 'function') { await syncWorkspaceAssets(userId, { categoryCode: 'public', onlyRelativePaths, }).catch(() => {}); } const indexedPages = await loadIndexedWorkspacePages(pool, userId); const allowList = onlyRelativePaths == null ? null : new Set(onlyRelativePaths.map((item) => normalizePublicHtmlPath(item)).filter(Boolean)); let created = 0; let updated = 0; let skipped = 0; if (publishDir) { const htmlFiles = await listPublishPublicHtmlFiles(publishDir); for (const file of htmlFiles) { if (allowList && !allowList.has(file.relativePath)) continue; try { const content = await fs.readFile(file.absolutePath, 'utf8'); if (!content.trim()) { skipped += 1; continue; } if (htmlUsesPageDataApi(content)) { skipped += 1; continue; } const result = await upsertWorkspaceHtmlPage({ pool, pageService, userId, indexedPages, relativePath: file.relativePath, content, sourceMtimeMs: file.mtimeMs, }); if (result === 'created') created += 1; else if (result === 'updated') updated += 1; else skipped += 1; } catch (error) { console.warn( `[MindSpace] page sync failed for ${file.relativePath}:`, error?.message ?? error, ); skipped += 1; } } } if (!assetService) { return { created, updated, skipped }; } const [rows] = await pool.query( `SELECT a.id, a.display_name, a.original_filename, a.updated_at FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id WHERE a.user_id = ? AND c.category_code = 'public' AND a.mime_type = 'text/html' AND a.status = 'ready' ORDER BY a.updated_at DESC LIMIT 200`, [userId], ); for (const asset of rows) { const relativePath = normalizePublicHtmlPath( String(asset.original_filename ?? '').includes('/') ? asset.original_filename : `public/${asset.original_filename}`, ); if (allowList && !allowList.has(relativePath)) continue; if (indexedPages.has(relativePath)) { skipped += 1; continue; } try { const { path: assetPath } = await assetService.readAsset(userId, asset.id); const content = await fs.readFile(assetPath, 'utf8'); const result = await upsertWorkspaceHtmlPage({ pool, pageService, userId, indexedPages, relativePath, content, assetId: asset.id, sourceMtimeMs: Number(asset.updated_at ?? 0), }); if (result === 'created') created += 1; else if (result === 'updated') updated += 1; else skipped += 1; } catch (error) { console.warn( `[MindSpace] asset page sync failed for ${asset.original_filename}:`, error?.message ?? error, ); skipped += 1; } } return { created, updated, skipped }; }