/**
* 从 MindSpace 公开 HTML 中解析 Page Data API 引用的 dataset 与读写意图。
*/
const PAGE_DATA_CLIENT_SCRIPT_PATTERN = /\/assets\/page-data-client\.js/i;
const FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN = /\/api\/page-data\b/i;
export function htmlUsesForbiddenLegacyPageDataApi(html) {
return FORBIDDEN_LEGACY_PAGE_DATA_API_PATTERN.test(String(html ?? ''));
}
export function htmlUsesPageDataApi(html) {
const content = String(html ?? '');
const usage = detectPageDataDatasetUsageFromHtml(content);
return (
usage.size > 0 ||
PAGE_DATA_CLIENT_SCRIPT_PATTERN.test(content) ||
/\bMindSpacePageData\b/.test(content) ||
htmlUsesForbiddenLegacyPageDataApi(content)
);
}
export function detectPageDataDatasetUsageFromHtml(html) {
const text = String(html ?? '');
const datasets = new Map();
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]);
}
function remember(name, patch) {
const key = String(name ?? '').trim();
if (!key) return;
datasets.set(key, { ...(datasets.get(key) ?? {}), ...patch });
}
for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) {
remember(match[1], { insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
remember(match[1], { read: true });
}
for (const match of text.matchAll(/\.deleteRow\(\s*['"]([^'"]+)['"]/g)) {
// The browser client's deleteRow endpoint intentionally falls back to a
// soft delete unless a policy explicitly grants hard_delete. A page using
// this API must therefore only require the safe, default capability.
remember(match[1], { softDelete: true });
}
for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { read: true });
}
for (const match of text.matchAll(/\.deleteRow\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { softDelete: true });
}
return datasets;
}
export function inferPageDataBindAccessMode(relativePath, html) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const hasRead = [...usage.values()].some((item) => item.read);
const hasInsert = [...usage.values()].some((item) => item.insert);
// A page that exchanges a password for a Page Data token is intentionally
// protected even when it both reads and writes its dataset (for example, a
// personal tracker). Do not silently re-bind it as public merely because it
// is not named "-admin.html".
const usesServerAuthentication = /\.\s*authenticate\s*\(/.test(String(html ?? ''));
if (
/-admin\.html$/i.test(String(relativePath ?? '')) ||
(hasRead && !hasInsert) ||
usesServerAuthentication
) {
return 'password';
}
return 'public';
}
export function assertPolicyMatchesHtmlDatasets(html, policyDatasets) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const htmlNames = [...usage.keys()].sort();
const policyNames = Object.keys(policyDatasets ?? {}).sort();
if (!htmlNames.length) return usage;
if (htmlNames.join(',') !== policyNames.join(',')) {
throw Object.assign(
new Error(
`Page Data 策略 dataset(${policyNames.join(', ') || '无'})与 HTML 中 insertRow/listRows 引用的 dataset(${htmlNames.join(', ')})不一致。请使用与页面脚本相同的 dataset 名称。`,
),
{
code: 'dataset_policy_html_mismatch',
htmlDatasets: htmlNames,
policyDatasets: policyNames,
},
);
}
return usage;
}
export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets, usage }) {
const detected = usage ?? detectPageDataDatasetUsageFromHtml(html);
if (!detected.size) return null;
const registryMap = new Map(registryDatasets.map((dataset) => [dataset.name, dataset]));
const datasets = {};
for (const [name, perms] of detected) {
const registered = registryMap.get(name);
if (!registered) {
throw Object.assign(
new Error(`HTML 引用的 dataset「${name}」尚未注册,请先执行 private_data_register_dataset`),
{ code: 'dataset_not_registered', datasetName: name },
);
}
const entry = {
insert: false,
read: false,
update: false,
softDelete: false,
hardDelete: false,
columns: {},
};
if (perms.insert && !perms.read) {
entry.insert = true;
entry.columns.insert = registered.columns?.insert ?? [];
} else if (perms.read && !perms.insert) {
entry.read = true;
entry.columns.read = registered.columns?.read ?? [];
} else {
if (perms.insert) {
entry.insert = true;
entry.columns.insert = registered.columns?.insert ?? [];
}
if (perms.read) {
entry.read = true;
entry.columns.read = registered.columns?.read ?? [];
}
}
if (perms.softDelete) {
entry.softDelete = true;
entry.columns.soft_delete = registered.columns?.soft_delete ?? ['id'];
}
datasets[name] = entry;
}
return datasets;
}