import fs from 'node:fs'; import path from 'node:path'; import { assessPageDataHtmlBinding } from './page-data-delivery-assess.mjs'; import { detectPageDataDatasetUsageFromHtml, htmlUsesPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { assertSafeSqlIdentifier } from './user-data-space-service.mjs'; const PAGE_DATA_ADMIN_PASSWORD = '88888888'; function readPublicHtmlFiles(workspaceRoot) { const publicDir = path.join(path.resolve(String(workspaceRoot ?? '')), 'public'); if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return []; return fs .readdirSync(publicDir) .filter((name) => name.toLowerCase().endsWith('.html')) .map((name) => { const relativePath = `public/${name}`; const absolutePath = path.join(publicDir, name); const content = fs.readFileSync(absolutePath, 'utf8'); return { relativePath, absolutePath, content }; }); } export function inferInsertColumnsFromHtml(html, datasetName) { const text = String(html ?? ''); const safeName = String(datasetName ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const patterns = [ new RegExp(`\\.insertRow\\(\\s*['"]${safeName}['"]\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*[,)]`, 'm'), new RegExp( `\\.insertRow\\(\\s*([A-Za-z_$][\\w$]*)\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*[,)]`, 'm', ), ]; const constants = new Map(); for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g)) { constants.set(match[1], match[2]); } for (const pattern of patterns) { const match = pattern.exec(text); if (!match) continue; const resolvedDataset = match.length === 3 ? constants.get(match[1]) ?? match[1] : datasetName; if (String(resolvedDataset).trim() !== String(datasetName).trim()) continue; const body = match[match.length - 1] ?? ''; const columns = []; for (const fieldMatch of body.matchAll(/([A-Za-z_][\w$]*)\s*:/g)) { const column = assertSafeSqlIdentifier(fieldMatch[1], 'insert 字段'); if (!columns.includes(column)) columns.push(column); } if (columns.length) return columns; } return []; } export async function ensureRegisteredDatasetFromHtml({ workspaceRoot, userId = null, query = null, html, datasetName, }) { const dataSpace = createUserDataSpaceService({ workspaceRoot, userId, query }); const existing = await dataSpace.getDataset(datasetName); if (existing) return existing; const insertColumns = inferInsertColumnsFromHtml(html, datasetName); if (!insertColumns.length) { throw Object.assign( new Error(`无法从 HTML 推断 dataset「${datasetName}」的 insert 字段,请先 register_dataset`), { code: 'insert_columns_unknown', datasetName }, ); } const tableName = assertSafeSqlIdentifier(datasetName, 'dataset 表名'); const columnSql = insertColumns .map((column) => `${column} TEXT NOT NULL DEFAULT ''`) .join(',\n '); await dataSpace.executeSql( `CREATE TABLE IF NOT EXISTS ${tableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnSql}, created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')) );`, ); const readColumns = ['id', ...insertColumns, 'created_at']; return dataSpace.upsertDataset({ name: datasetName, table: tableName, description: `Auto-registered from workspace HTML (${datasetName})`, actions: ['read', 'insert'], columns: { read: readColumns, insert: insertColumns, }, }); } export function listPageDataHtmlFiles(workspaceRoot) { return readPublicHtmlFiles(workspaceRoot).filter((file) => htmlUsesPageDataApi(file.content)); } export async function ensurePageDataHtmlPagesBound({ pool, h5Root, storageRoot, userId, workspaceRoot, findPageByRelativePath = null, onlyRelativePaths = null, logger = console, } = {}) { if (!pool || !userId || !workspaceRoot) { return { bound: [], skipped: [], errors: [{ code: 'missing_context', message: '缺少 pool/userId/workspaceRoot' }], }; } const allowList = onlyRelativePaths ? new Set(onlyRelativePaths) : null; const bound = []; const skipped = []; const errors = []; for (const file of listPageDataHtmlFiles(workspaceRoot)) { if (allowList && !allowList.has(file.relativePath)) continue; const usage = detectPageDataDatasetUsageFromHtml(file.content); if (!usage.size) { skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' }); continue; } try { // Keep every page's assessment and repair isolated. A malformed legacy // page must be reported in `errors`, not reject the entire MindSpace page // listing that invokes this best-effort maintenance pass. const assessment = await assessPageDataHtmlBinding({ pool, userId, publishDir: workspaceRoot, relativePath: file.relativePath, html: file.content, findPageByRelativePath, }); if (assessment.bound) { skipped.push({ relativePath: file.relativePath, reason: 'already_bound' }); continue; } for (const datasetName of usage.keys()) { await ensureRegisteredDatasetFromHtml({ workspaceRoot, userId, query: pool.query.bind(pool), html: file.content, datasetName, }); } const accessMode = inferPageDataBindAccessMode(file.relativePath, file.content); const result = await bindWorkspaceHtmlForPageData({ pool, h5Root, storageRoot, userId, workspaceRoot, relativePath: file.relativePath, accessMode, password: accessMode === 'password' ? PAGE_DATA_ADMIN_PASSWORD : null, }); bound.push({ relativePath: file.relativePath, pageId: result.pageId, workspaceUrl: result.workspaceUrl, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); errors.push({ relativePath: file.relativePath, message, code: err?.code ?? 'bind_failed', }); logger.warn?.( `[PageData] ensure bind failed for ${file.relativePath}: ${message}`, ); } } return { bound, skipped, errors }; }