197 lines
6.3 KiB
JavaScript
197 lines
6.3 KiB
JavaScript
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 };
|
|
}
|