Files
memind/page-data-workspace-ensure.mjs
T

194 lines
6.1 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;
}
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;
}
try {
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 };
}