diff --git a/chat-skills.mjs b/chat-skills.mjs index ed74b9f..1538392 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -31,6 +31,7 @@ const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [ const PAGE_DATA_INTENT_PATTERNS = [ /(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u, /(?:报名表|报名登记|在线报名|收集报名)/u, + /(?:作业登记|作业打卡|作业记录|每日作业|打卡登记|台账|记录表)/u, /(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u, /(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u, /(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u, diff --git a/mindspace-page-data-finish-guard.integration.test.mjs b/mindspace-page-data-finish-guard.integration.test.mjs index eae2592..598f40c 100644 --- a/mindspace-page-data-finish-guard.integration.test.mjs +++ b/mindspace-page-data-finish-guard.integration.test.mjs @@ -252,7 +252,7 @@ test('integration: finish guard auto-bind clears unbound state for valid survey storageRoot: workspaceRoot, }); assert.equal(autoBind.bound.length, 0); - assert.equal(autoBind.errors[0]?.code, 'database_unconfigured'); + assert.equal(autoBind.errors[0]?.code, 'missing_context'); writePageAccessPolicy(workspaceRoot, { pageId: 'page-diet-survey', @@ -326,11 +326,11 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm } }); -test('integration: finish guard never scans historical Page Data files without a current HTML write', async () => { +test('integration: finish guard repairs historical unbound Page Data html without a current write', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-')); try { await setupSurveyWorkspace(workspaceRoot); - fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8'); + fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), VALID_SURVEY_HTML, 'utf8'); const result = await maybeRepairPageDataAfterFinish({ sessionId: 'session-with-history', userId: 'user-page-data', @@ -339,12 +339,13 @@ test('integration: finish guard never scans historical Page Data files without a pool: null, h5Root: workspaceRoot, storageRoot: workspaceRoot, - userText: SURVEY_USER_TEXT, + userText: '确认发布', tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } }, }); - assert.equal(result.repaired, false); + assert.equal(result.structuralPageData, true); + assert.equal(result.relevantFiles.length, 1); + assert.equal(result.needsRepair, true); assert.equal(result.triggered, undefined); - assert.equal(result.relevantFiles.length, 0); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index d338b12..83292ec 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -7,8 +7,8 @@ import { htmlUsesPageDataApi, inferPageDataBindAccessMode, } from './page-data-html-detect.mjs'; -import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs'; import { createPageService } from './mindspace-pages.mjs'; +import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs'; import { assessPageDataHtmlBinding, assessWorkspacePageDataReadiness, @@ -240,17 +240,41 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } = return [...targets]; } +function isStructuralPageDataHtmlFile(file) { + return Boolean(file?.evaluation?.usage?.size > 0 || file?.evaluation?.usesPageDataApi); +} + +function resolvePageDataGuardAgentText({ agentText = '', messages = [] } = {}) { + const direct = String(agentText ?? '').trim(); + if (isPageDataIntent(direct)) return direct; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role !== 'user') continue; + const text = Array.isArray(message?.content) + ? message.content + .filter((item) => item?.type === 'text') + .map((item) => String(item.text ?? '').trim()) + .filter(Boolean) + .join('\n') + : String(message?.content ?? '').trim(); + if (isPageDataIntent(text)) return text; + } + return direct; +} + export function evaluatePageDataFinishGuard({ publishDir, agentText = '', messages = [], requestStartedAt = 0, } = {}) { - const pageDataIntent = isPageDataIntent(agentText); + const resolvedAgentText = resolvePageDataGuardAgentText({ agentText, messages }); + const pageDataIntent = isPageDataIntent(resolvedAgentText); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); - const relevantFiles = pageDataFiles.filter((file) => - pageDataIntent || recentWrites.includes(file.relativePath), + const structuralFiles = pageDataFiles.filter(isStructuralPageDataHtmlFile); + const relevantFiles = pageDataFiles.filter( + (file) => isStructuralPageDataHtmlFile(file) || recentWrites.includes(file.relativePath), ); const htmlIssues = relevantFiles.flatMap((file) => @@ -271,13 +295,17 @@ export function evaluatePageDataFinishGuard({ ); const needsRepair = - pageDataIntent && + structuralFiles.length > 0 && (htmlIssues.length > 0 || unboundFiles.length > 0 || - (relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages))); + (pageDataIntent && + relevantFiles.length === 0 && + extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && + usedPageDataCollectSkill(messages))); return { pageDataIntent, + structuralPageData: structuralFiles.length > 0, relevantFiles, htmlIssues, unboundFiles, @@ -310,10 +338,11 @@ export async function evaluatePageDataFinishGuardAsync({ findPageByRelativePath, }); const needsRepair = - base.pageDataIntent && + base.structuralPageData && (base.htmlIssues.length > 0 || unboundFiles.length > 0 || - (base.relevantFiles.length === 0 && + (base.pageDataIntent && + base.relevantFiles.length === 0 && extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }).length === 0 && usedPageDataCollectSkill(messages))); @@ -403,16 +432,15 @@ export function resolvePageDataCollectOutcome({ requestStartedAt = 0, }) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); - if (!isPageDataIntent(agentText)) { - return { action: 'skip' }; - } - const evaluation = evaluatePageDataFinishGuard({ publishDir, agentText, messages: reply?.messages ?? [], requestStartedAt, }); + if (!evaluation.structuralPageData && !evaluation.pageDataIntent) { + return { action: 'skip' }; + } if (evaluation.htmlIssues.length > 0) { return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; @@ -438,10 +466,6 @@ export async function resolvePageDataCollectOutcomeAsync({ fetchImpl = fetch, } = {}) { const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim(); - if (!isPageDataIntent(agentText)) { - return { action: 'skip' }; - } - const evaluation = await evaluatePageDataFinishGuardAsync({ publishDir, agentText, @@ -451,6 +475,9 @@ export async function resolvePageDataCollectOutcomeAsync({ userId, findPageByRelativePath, }); + if (!evaluation.structuralPageData && !evaluation.pageDataIntent) { + return { action: 'skip' }; + } if (evaluation.htmlIssues.length > 0) { return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation }; @@ -471,7 +498,18 @@ export async function resolvePageDataCollectOutcomeAsync({ fetchImpl, }); if (failures.length === 0) { - return { action: 'send', reason: 'verified_by_live_api', evaluation }; + const reassessment = await evaluatePageDataFinishGuardAsync({ + publishDir, + agentText, + messages: reply?.messages ?? [], + requestStartedAt, + pool, + userId, + findPageByRelativePath, + }); + if (reassessment.unboundFiles.length === 0 && reassessment.htmlIssues.length === 0) { + return { action: 'send', reason: 'verified_by_live_api', evaluation: reassessment }; + } } } } @@ -503,66 +541,15 @@ export async function maybeAutoBindPageDataHtmlPages({ onlyRelativePaths = null, findPageByRelativePath = null, } = {}) { - if (!pool) { - return { bound: [], skipped: [], errors: [{ code: 'database_unconfigured' }] }; - } - - const pageLookup = - typeof findPageByRelativePath === 'function' - ? findPageByRelativePath - : createPageService(pool, { h5Root, storageRoot }).findPageByRelativePath; - - const bound = []; - const skipped = []; - const errors = []; - const allowList = onlyRelativePaths ? new Set(onlyRelativePaths) : null; - - for (const file of collectPageDataPublicHtmlFiles(publishDir)) { - if (allowList && !allowList.has(file.relativePath)) continue; - if (file.evaluation.issues.length > 0) { - skipped.push({ relativePath: file.relativePath, reason: 'invalid_html', issues: file.evaluation.issues }); - continue; - } - if (file.evaluation.usage?.size === 0) { - skipped.push({ relativePath: file.relativePath, reason: 'no_dataset_usage' }); - continue; - } - const assessment = await assessPageDataHtmlBinding({ - pool, - userId, - publishDir, - relativePath: file.relativePath, - html: file.content, - findPageByRelativePath: pageLookup, - }); - if (assessment.bound) { - skipped.push({ relativePath: file.relativePath, reason: 'already_bound' }); - continue; - } - - try { - const accessMode = inferPageDataBindAccessMode(file.relativePath, file.content); - const result = await bindWorkspaceHtmlForPageData({ - pool, - h5Root, - storageRoot, - userId, - workspaceRoot: publishDir, - relativePath: file.relativePath, - accessMode, - password: accessMode === 'password' ? '88888888' : null, - }); - bound.push({ relativePath: file.relativePath, pageId: result.pageId, workspaceUrl: result.workspaceUrl }); - } catch (err) { - errors.push({ - relativePath: file.relativePath, - message: err instanceof Error ? err.message : String(err), - code: err?.code ?? 'bind_failed', - }); - } - } - - return { bound, skipped, errors }; + return ensurePageDataHtmlPagesBound({ + pool, + h5Root, + storageRoot, + userId, + workspaceRoot: publishDir, + findPageByRelativePath, + onlyRelativePaths, + }); } export async function ensurePageDataDeliveryReady({ @@ -609,14 +596,14 @@ export async function maybeRepairPageDataAfterFinish({ const pageService = pool ? createPageService(pool, { h5Root, storageRoot }) : null; const evaluation = await evaluatePageDataFinishGuardAsync({ publishDir, - agentText: recentUserText, + agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), messages, pool, userId, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); - if (!evaluation.pageDataIntent && evaluation.relevantFiles.length === 0) { + if (!evaluation.structuralPageData && evaluation.relevantFiles.length === 0) { resetPageDataFinishGuardAttempts(sessionId); return { repaired: false, skipped: 'not_page_data', ...evaluation }; } @@ -627,13 +614,15 @@ export async function maybeRepairPageDataAfterFinish({ publishDir, h5Root, storageRoot, - onlyRelativePaths: evaluation.relevantFiles.map((file) => file.relativePath), + onlyRelativePaths: evaluation.relevantFiles.length + ? evaluation.relevantFiles.map((file) => file.relativePath) + : null, findPageByRelativePath: pageService?.findPageByRelativePath?.bind(pageService) ?? null, }); const afterBind = await evaluatePageDataFinishGuardAsync({ publishDir, - agentText: recentUserText, + agentText: resolvePageDataGuardAgentText({ agentText: recentUserText, messages }), messages, pool, userId, diff --git a/mindspace-page-data-finish-guard.test.mjs b/mindspace-page-data-finish-guard.test.mjs index 1df8d1b..f7a28e9 100644 --- a/mindspace-page-data-finish-guard.test.mjs +++ b/mindspace-page-data-finish-guard.test.mjs @@ -108,7 +108,7 @@ test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => { storageRoot: publishDir, }); assert.equal(result.bound.length, 0); - assert.equal(result.errors[0]?.code, 'database_unconfigured'); + assert.equal(result.errors[0]?.code, 'missing_context'); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } diff --git a/mindspace-workspace-page-deliver-page-data.test.mjs b/mindspace-workspace-page-deliver-page-data.test.mjs new file mode 100644 index 0000000..5cc7f5e --- /dev/null +++ b/mindspace-workspace-page-deliver-page-data.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs'; + +const PAGE_DATA_HTML = `
+ + +`; + +test('ensureWorkspaceHtmlPublications skips page-data html and delegates to pageDataEnsure', async () => { + const h5Root = fs.mkdtempSync(path.join(os.tmpdir(), 'deliver-h5-')); + const userId = 'user-1'; + const publishDir = path.join(h5Root, 'MindSpace', userId); + fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); + fs.writeFileSync(path.join(publishDir, 'public', 'survey.html'), PAGE_DATA_HTML, 'utf8'); + + const published = []; + const bound = []; + const service = createWorkspacePageDeliverService({ + pool: { + async query() { + return [[{ + page_id: 'page-1', + title: '问卷', + current_version_id: 'ver-1', + workspace_relative_path: 'public/survey.html', + source_snapshot_json: JSON.stringify({ + auto_synced: true, + relative_path: 'public/survey.html', + content_mode: 'static_html', + }), + }]]; + }, + }, + pageService: { + async getPage() { + return { id: 'page-1', title: '问卷', currentVersionId: 'ver-1' }; + }, + findPageByRelativePath: async () => null, + }, + publicationService: { + async getCurrent() { + return null; + }, + async publish(userIdArg, pageId, input) { + published.push({ userId: userIdArg, pageId, input }); + return { id: 'pub-1' }; + }, + }, + pageSyncService: { + async syncUserGeneratedPages() { + return { created: 0, updated: 0, skipped: 0 }; + }, + }, + pageDataEnsure: { + async ensurePageDataHtmlPagesBound() { + bound.push(true); + return { bound: [{ relativePath: 'public/survey.html' }], skipped: [], errors: [] }; + }, + }, + h5Root, + storageRoot: null, + }); + + const result = await service.syncAndDeliver(userId); + assert.equal(bound.length, 1); + assert.equal(result.publish.published, 0); + assert.equal(result.publish.skipped, 1); + assert.equal(published.length, 0); + fs.rmSync(h5Root, { recursive: true, force: true }); +}); diff --git a/mindspace-workspace-page-deliver.mjs b/mindspace-workspace-page-deliver.mjs index 5b47a15..b849ba2 100644 --- a/mindspace-workspace-page-deliver.mjs +++ b/mindspace-workspace-page-deliver.mjs @@ -1,5 +1,9 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { slugFromPageTitle } from './mindspace-chat-plaza.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; +import { htmlUsesPageDataApi } from './page-data-html-detect.mjs'; +import { resolvePublishDir } from './user-publish.mjs'; function parseJsonColumn(value, fallback = {}) { if (value == null || value === '') return { ...fallback }; @@ -16,11 +20,31 @@ function isPublicWorkspaceHtmlPath(relativePath) { return Boolean(normalized?.startsWith('public/') && normalized.toLowerCase().endsWith('.html')); } +function readWorkspaceHtmlContent(publishDir, relativePath) { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!normalized || !publishDir) return ''; + const absolutePath = path.join(publishDir, ...normalized.split('/')); + try { + return fs.readFileSync(absolutePath, 'utf8'); + } catch { + return ''; + } +} + +function isPageDataWorkspaceHtml(publishDir, relativePath) { + const content = readWorkspaceHtmlContent(publishDir, relativePath); + return htmlUsesPageDataApi(content); +} + export function createWorkspacePageDeliverService({ pool, pageService, publicationService, pageSyncService, + pageDataEnsure = null, + h5Root = null, + storageRoot = null, + findPageByRelativePath = null, logger = console, } = {}) { async function listUnpublishedAutoSyncedWorkspacePages(userId) { @@ -66,6 +90,30 @@ export function createWorkspacePageDeliverService({ }); } + function resolveWorkspaceRoot(userId) { + if (!h5Root || !userId) return null; + return resolvePublishDir(h5Root, { id: userId }); + } + + async function ensurePageDataBindings(userId) { + if (!pageDataEnsure?.ensurePageDataHtmlPagesBound || !pool || !userId) { + return { bound: [], skipped: [], errors: [] }; + } + const workspaceRoot = resolveWorkspaceRoot(userId); + if (!workspaceRoot) { + return { bound: [], skipped: [], errors: [{ code: 'missing_workspace_root' }] }; + } + return pageDataEnsure.ensurePageDataHtmlPagesBound({ + pool, + h5Root, + storageRoot, + userId, + workspaceRoot, + findPageByRelativePath, + logger, + }); + } + async function refreshOnlineWorkspacePublications(userId) { if (!publicationService?.refreshOnlinePublicationHtml || !userId) { return { refreshed: 0, skipped: 0, errors: [] }; @@ -101,11 +149,19 @@ export function createWorkspacePageDeliverService({ return { published: 0, skipped: 0, errors: [] }; } const candidates = await listUnpublishedAutoSyncedWorkspacePages(userId); + const publishDir = resolveWorkspaceRoot(userId); let published = 0; let skipped = 0; const errors = []; for (const candidate of candidates) { try { + const snapshot = parseJsonColumn(candidate.source_snapshot_json); + const relativePath = + candidate.workspace_relative_path ?? snapshot.relative_path ?? null; + if (isPageDataWorkspaceHtml(publishDir, relativePath)) { + skipped += 1; + continue; + } const page = await pageService.getPage(userId, candidate.page_id); const current = await publicationService.getCurrent?.(userId, candidate.page_id); if (current?.status === 'online') { @@ -138,13 +194,20 @@ export function createWorkspacePageDeliverService({ if (pageSyncService?.syncUserGeneratedPages) { syncResult = await pageSyncService.syncUserGeneratedPages(userId); } + const pageDataBindResult = await ensurePageDataBindings(userId); const publishResult = await ensureWorkspaceHtmlPublications(userId); const refreshResult = await refreshOnlineWorkspacePublications(userId); - return { sync: syncResult, publish: publishResult, refresh: refreshResult }; + return { + sync: syncResult, + pageDataBind: pageDataBindResult, + publish: publishResult, + refresh: refreshResult, + }; } return { syncAndDeliver, + ensurePageDataBindings, ensureWorkspaceHtmlPublications, refreshOnlineWorkspacePublications, }; diff --git a/page-data-workspace-ensure.mjs b/page-data-workspace-ensure.mjs new file mode 100644 index 0000000..55e36c1 --- /dev/null +++ b/page-data-workspace-ensure.mjs @@ -0,0 +1,193 @@ +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 = 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 }; +} diff --git a/page-data-workspace-ensure.test.mjs b/page-data-workspace-ensure.test.mjs new file mode 100644 index 0000000..8b58c76 --- /dev/null +++ b/page-data-workspace-ensure.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + ensureRegisteredDatasetFromHtml, + inferInsertColumnsFromHtml, +} from './page-data-workspace-ensure.mjs'; +import { evaluatePageDataFinishGuard } from './mindspace-page-data-finish-guard.mjs'; + +const HOMEWORK_HTML = `