Files
memind/mindspace-page-sync.mjs
T
john 06eb129a28 fix(mindspace): 修复删除时无法解析 MySQL JSON 导致页面复活
source_snapshot_json 在 mysql2 中已是对象,JSON.parse 失败会使 relative_path 丢失、工作区 HTML 未清理;同步后页面重新出现。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

295 lines
8.5 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
const PUBLIC_HTML_SKIP_DIRS = new Set([
'.tmp-images',
'assets',
'images',
'shared',
'node_modules',
]);
function normalizePublicHtmlPath(relativePath) {
const parts = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..');
if (parts.length === 0) return '';
if (parts[0].toLowerCase() === 'public') return parts.join('/');
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
return parts.join('/');
}
function titleFromRelativePath(relativePath) {
const basename = path.basename(String(relativePath ?? ''), '.html');
return basename.replace(/[-_]+/g, ' ').trim() || '生成的页面';
}
function extractHtmlTitle(content) {
const match = String(content).match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.trim() ?? '';
}
function extractHtmlSummary(content) {
const match = String(content).match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
return match?.[1]?.trim().slice(0, 1000) ?? '';
}
async function findPageByWorkspaceRelativePath(pool, userId, relativePath) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, relativePath],
);
const row = rows[0];
if (!row) return null;
return { id: row.id, updatedAt: Number(row.updated_at ?? 0) };
}
function parseJsonColumn(value, fallback = {}) {
if (value == null || value === '') return { ...fallback };
if (typeof value === 'object' && !Buffer.isBuffer(value)) return value;
try {
return JSON.parse(String(value));
} catch {
return { ...fallback };
}
}
async function loadIndexedWorkspacePages(pool, userId) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ? AND p.status <> 'deleted'`,
[userId],
);
const byPath = new Map();
for (const row of rows) {
const snapshot = parseJsonColumn(row.source_snapshot_json);
const relativePath = normalizePublicHtmlPath(snapshot.relative_path);
if (relativePath) {
byPath.set(relativePath, {
id: row.id,
updatedAt: Number(row.updated_at ?? 0),
});
}
if (row.source_asset_id && relativePath) {
byPath.set(`asset:${row.source_asset_id}`, { id: row.id, updatedAt: Number(row.updated_at ?? 0) });
}
}
return byPath;
}
async function listPublishPublicHtmlFiles(publishDir) {
const publicDir = path.join(publishDir, 'public');
const files = [];
async function walk(absDir, relWithinPublic) {
let entries;
try {
entries = await fs.readdir(absDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith('.') || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue;
const absPath = path.join(absDir, entry.name);
const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await walk(absPath, relPath);
continue;
}
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
const stat = await fs.stat(absPath);
files.push({
relativePath: normalizePublicHtmlPath(`public/${relPath}`),
absolutePath: absPath,
mtimeMs: stat.mtimeMs,
});
}
}
await walk(publicDir, '');
return files;
}
async function upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
relativePath,
content,
assetId = null,
sourceMtimeMs = null,
}) {
const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath);
const summary = extractHtmlSummary(content);
let existing =
indexedPages.get(relativePath) ??
(assetId ? indexedPages.get(`asset:${assetId}`) : null);
if (!existing && pool) {
existing = await findPageByWorkspaceRelativePath(pool, userId, relativePath);
if (existing) {
indexedPages.set(relativePath, existing);
if (assetId) indexedPages.set(`asset:${assetId}`, existing);
}
}
const pageInput = {
title,
summary,
content,
contentFormat: 'html',
pageType: 'html',
templateId: 'editorial',
categoryCode: 'draft',
};
const snapshot = {
...(assetId ? { source_asset_id: assetId } : {}),
source_category: 'public',
content_mode: 'static_html',
relative_path: relativePath,
auto_synced: true,
};
if (existing) {
if (sourceMtimeMs != null && existing.updatedAt >= sourceMtimeMs) {
return 'skipped';
}
await pageService.updatePage(userId, existing.id, {
...pageInput,
changeNote: '同步工作区页面更新',
});
indexedPages.set(relativePath, { id: existing.id, updatedAt: Date.now() });
return 'updated';
}
const page = await pageService.createFromChat(userId, pageInput, {
...(assetId ? { assetId } : {}),
snapshot,
createdAt:
sourceMtimeMs != null && Number.isFinite(Number(sourceMtimeMs))
? Math.floor(Number(sourceMtimeMs))
: undefined,
});
indexedPages.set(relativePath, { id: page.id, updatedAt: Date.now() });
if (assetId) indexedPages.set(`asset:${assetId}`, { id: page.id, updatedAt: Date.now() });
return 'created';
}
export async function syncGeneratedPagesFromPublicAssets({
pool,
pageService,
assetService,
userId,
publishDir = null,
syncWorkspaceAssets = null,
} = {}) {
if (!pool || !pageService || !userId) {
return { created: 0, skipped: 0, updated: 0 };
}
if (typeof syncWorkspaceAssets === 'function') {
await syncWorkspaceAssets(userId, { categoryCode: 'public' }).catch(() => {});
}
const indexedPages = await loadIndexedWorkspacePages(pool, userId);
let created = 0;
let updated = 0;
let skipped = 0;
if (publishDir) {
const htmlFiles = await listPublishPublicHtmlFiles(publishDir);
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file.absolutePath, 'utf8');
if (!content.trim()) {
skipped += 1;
continue;
}
const result = await upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
relativePath: file.relativePath,
content,
sourceMtimeMs: file.mtimeMs,
});
if (result === 'created') created += 1;
else if (result === 'updated') updated += 1;
else skipped += 1;
} catch (error) {
console.warn(
`[MindSpace] page sync failed for ${file.relativePath}:`,
error?.message ?? error,
);
skipped += 1;
}
}
}
if (!assetService) {
return { created, updated, skipped };
}
const [rows] = await pool.query(
`SELECT a.id, a.display_name, a.original_filename, a.updated_at
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
WHERE a.user_id = ?
AND c.category_code = 'public'
AND a.mime_type = 'text/html'
AND a.status = 'ready'
ORDER BY a.updated_at DESC
LIMIT 200`,
[userId],
);
for (const asset of rows) {
const relativePath = normalizePublicHtmlPath(
String(asset.original_filename ?? '').includes('/')
? asset.original_filename
: `public/${asset.original_filename}`,
);
if (indexedPages.has(relativePath)) {
skipped += 1;
continue;
}
try {
const { path: assetPath } = await assetService.readAsset(userId, asset.id);
const content = await fs.readFile(assetPath, 'utf8');
const result = await upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
relativePath,
content,
assetId: asset.id,
sourceMtimeMs: Number(asset.updated_at ?? 0),
});
if (result === 'created') created += 1;
else if (result === 'updated') updated += 1;
else skipped += 1;
} catch (error) {
console.warn(
`[MindSpace] asset page sync failed for ${asset.original_filename}:`,
error?.message ?? error,
);
skipped += 1;
}
}
return { created, updated, skipped };
}