From e2ad3bf62b1d93e61d7bb1d5cc3c0eba18308e59 Mon Sep 17 00:00:00 2001
From: john
Date: Sun, 5 Jul 2026 23:14:41 +0800
Subject: [PATCH] =?UTF-8?q?feat(mindspace):=20=E5=85=AC=E5=BC=80=E9=A1=B5?=
=?UTF-8?q?=E5=88=86=E4=BA=AB=E7=BB=84=E4=BB=B6=E3=80=81workspace=20?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=B8=8E=20Plaza=20=E5=8F=91=E5=B8=83?=
=?UTF-8?q?=E5=A2=9E=E5=BC=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 public share widget 与 workspace relative path 解析
- 增强 publications/chat-plaza 发布链路与缩略图 demo
- 补充 schema、cover 检查脚本与回归测试
Co-authored-by: Cursor
---
db.mjs | 47 ++++
mindspace-assets.mjs | 72 +++++-
mindspace-assets.test.mjs | 27 ++
mindspace-chat-plaza.mjs | 230 +++++++++++++++++-
mindspace-chat-plaza.test.mjs | 67 +++++
mindspace-page-sync.mjs | 20 +-
mindspace-pages.mjs | 53 +++-
mindspace-public-share-widget.mjs | 205 ++++++++++++++++
mindspace-public-share-widget.test.mjs | 20 ++
mindspace-publications.mjs | 138 ++++++++++-
mindspace-publications.test.mjs | 221 ++++++++++++++++-
mindspace-server-adapter-contract.mjs | 2 +
.../mindspace-rpc-server.test.mjs | 2 +
mindspace-workspace-path.mjs | 29 +++
mindspace-workspace-path.test.mjs | 38 +++
mindspace-workspace-sync.mjs | 17 +-
package.json | 2 +
plaza-embed.mjs | 49 ++--
plaza-embed.test.mjs | 11 +-
plaza-public.mjs | 43 ++--
.../vietnam-guide-bad.html | 36 +++
.../vietnam-guide-good.html | 47 ++++
public/thumbnail-demo/index.html | 76 +++---
.../thumbnail-demo/vietnam-guide-bad-feed.svg | 65 +++++
.../vietnam-guide-bad-generated.svg | 65 +++++
.../vietnam-guide-good-feed.svg | 58 +++++
.../vietnam-guide-good-generated.svg | 58 +++++
schema.sql | 4 +
scripts/check-mindspace-cover.mjs | 151 ++++++++++++
scripts/mindspace-publications-e2e.mjs | 38 +++
scripts/patch-critical-covers.mjs | 121 +++++++++
scripts/thumbnail-preview-demo.mjs | 157 ++++++------
server.mjs | 177 ++++++++++----
skills/static-page-publish/SKILL.md | 21 +-
src/components/ChatPlazaPublishModal.tsx | 27 +-
src/index.css | 27 +-
src/utils/mindspaceCards.ts | 4 +-
user-publish.mjs | 10 +
wechat/prompts/page-generate.mjs | 5 +-
39 files changed, 2184 insertions(+), 256 deletions(-)
create mode 100644 mindspace-public-share-widget.mjs
create mode 100644 mindspace-public-share-widget.test.mjs
create mode 100644 mindspace-workspace-path.mjs
create mode 100644 mindspace-workspace-path.test.mjs
create mode 100644 public/thumbnail-demo-samples/vietnam-guide-bad.html
create mode 100644 public/thumbnail-demo-samples/vietnam-guide-good.html
create mode 100644 public/thumbnail-demo/vietnam-guide-bad-feed.svg
create mode 100644 public/thumbnail-demo/vietnam-guide-bad-generated.svg
create mode 100644 public/thumbnail-demo/vietnam-guide-good-feed.svg
create mode 100644 public/thumbnail-demo/vietnam-guide-good-generated.svg
create mode 100644 scripts/check-mindspace-cover.mjs
create mode 100644 scripts/patch-critical-covers.mjs
diff --git a/db.mjs b/db.mjs
index 2f41476..189e67f 100644
--- a/db.mjs
+++ b/db.mjs
@@ -771,6 +771,53 @@ export async function migrateSchema(pool) {
)
`);
+ if (!(await columnExists(pool, 'h5_page_records', 'workspace_relative_path'))) {
+ await pool.query(
+ `ALTER TABLE h5_page_records
+ ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER draft_content_ref`,
+ );
+ }
+ if (!(await indexExists(pool, 'h5_page_records', 'idx_h5_pages_workspace_path'))) {
+ await pool.query(
+ `ALTER TABLE h5_page_records
+ ADD KEY idx_h5_pages_workspace_path (user_id, workspace_relative_path, updated_at)`,
+ );
+ }
+ if (!(await columnExists(pool, 'h5_assets', 'workspace_relative_path'))) {
+ await pool.query(
+ `ALTER TABLE h5_assets
+ ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER display_name`,
+ );
+ }
+ if (!(await indexExists(pool, 'h5_assets', 'idx_h5_assets_workspace_path'))) {
+ await pool.query(
+ `ALTER TABLE h5_assets
+ ADD KEY idx_h5_assets_workspace_path (user_id, workspace_relative_path, updated_at)`,
+ );
+ }
+ await pool.query(
+ `UPDATE h5_page_records p
+ JOIN h5_page_versions pv ON pv.id = p.current_version_id
+ SET p.workspace_relative_path = JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path'))
+ WHERE p.workspace_relative_path IS NULL
+ AND JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path') IS NOT NULL
+ AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) <> ''`,
+ );
+ await pool.query(
+ `UPDATE h5_assets a
+ JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
+ SET a.workspace_relative_path = CASE
+ WHEN a.original_filename LIKE 'public/%' OR a.original_filename LIKE 'oa/%'
+ THEN a.original_filename
+ WHEN c.category_code IN ('public', 'oa')
+ THEN CONCAT(c.category_code, '/', a.original_filename)
+ ELSE NULL
+ END
+ WHERE a.workspace_relative_path IS NULL
+ AND a.status <> 'deleted'
+ AND c.category_code IN ('public', 'oa')`,
+ );
+
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_user_feedback (
id CHAR(36) PRIMARY KEY,
diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs
index e6779a2..e7635e4 100644
--- a/mindspace-assets.mjs
+++ b/mindspace-assets.mjs
@@ -25,6 +25,10 @@ import {
DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
normalizeImageForStorage,
} from './user-image-normalize.mjs';
+import {
+ normalizeWorkspaceRelativePath,
+ resolveAssetWorkspaceRelativePath,
+} from './mindspace-workspace-path.mjs';
const ALLOWED_EXTENSIONS = new Map([
['.txt', 'text/plain'],
@@ -184,9 +188,15 @@ function isPublicImageAsset(row) {
return row?.category_code === 'public';
}
+function isCanonicalPublicImageStorageKey(storageKey) {
+ const normalized = String(storageKey ?? '').replace(/\\/g, '/');
+ if (isWorkspaceStorageKey(normalized)) return false;
+ return isPublicImageStorageKey(normalized);
+}
+
function publicUrlForAsset(row) {
if (!isPublicImageAsset(row)) return null;
- if (row?.storage_key && isPublicImageStorageKey(row.storage_key)) {
+ if (row?.storage_key && isCanonicalPublicImageStorageKey(row.storage_key)) {
return buildUserImagePublicUrl({
userId: row.user_id,
storageKey: row.storage_key,
@@ -194,9 +204,16 @@ function publicUrlForAsset(row) {
originalFilename: row.original_filename,
});
}
+ const workspacePath = normalizeWorkspaceRelativePath(row?.workspace_relative_path);
+ if (workspacePath && isWorkspaceStorageKey(row?.storage_key)) {
+ return createMindSpacePublicUrl({
+ publicBaseUrl: resolvePublicBaseUrl(),
+ ownerKey: row.user_id,
+ relativePath: workspacePath,
+ });
+ }
if (row?.category_code === 'public' && row?.id) {
- const legacy = publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename);
- return legacy;
+ return publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename);
}
return null;
}
@@ -651,12 +668,17 @@ export function createAssetService(pool, options = {}) {
const now = Date.now();
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(upload.category_code);
+ const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
+ categoryCode: upload.category_code,
+ originalFilename: storedFilename,
+ explicitPath: workspaceRelativePath,
+ });
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
- display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
+ display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
[
assetId,
userId,
@@ -666,6 +688,7 @@ export function createAssetService(pool, options = {}) {
storedMimeType,
storedFilename,
upload.filename,
+ indexedWorkspacePath,
versionId,
storedSizeBytes,
storedChecksum,
@@ -1191,12 +1214,17 @@ export function createAssetService(pool, options = {}) {
}
const now = Date.now();
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(category.category_code);
+ const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
+ categoryCode: category.category_code,
+ originalFilename: storedFilename,
+ explicitPath: workspaceRelativePath,
+ });
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
- display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
+ display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
userId,
@@ -1206,6 +1234,7 @@ export function createAssetService(pool, options = {}) {
storedMimeType,
storedFilename,
displayName || path.basename(storedFilename),
+ indexedWorkspacePath,
versionId,
storedSizeBytes,
storedChecksum,
@@ -1370,6 +1399,33 @@ export function createAssetService(pool, options = {}) {
return expired;
};
+ const findAssetByWorkspaceRelativePath = async (userId, relativePath) => {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ if (!normalized) return null;
+ const [rows] = await pool.query(
+ `SELECT a.*, c.category_code, av.storage_key
+ FROM h5_assets a
+ JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
+ JOIN h5_asset_versions av ON av.id = a.current_version_id
+ WHERE a.user_id = ?
+ AND a.status <> 'deleted'
+ AND (
+ a.workspace_relative_path = ?
+ OR (
+ (a.workspace_relative_path IS NULL OR a.workspace_relative_path = '')
+ AND (
+ a.original_filename = ?
+ OR CONCAT(c.category_code, '/', a.original_filename) = ?
+ )
+ )
+ )
+ ORDER BY a.updated_at DESC, a.id DESC
+ LIMIT 1`,
+ [userId, normalized, normalized, normalized],
+ );
+ return rows[0] ? assetResponse(rows[0]) : null;
+ };
+
return {
storageRoot,
createUpload,
@@ -1379,6 +1435,7 @@ export function createAssetService(pool, options = {}) {
cancelUpload,
createChatAsset,
listAssets,
+ findAssetByWorkspaceRelativePath,
deleteAsset,
readAsset,
readPublicAsset,
@@ -1440,5 +1497,6 @@ export const assetInternals = {
normalizeFilename,
expectedMimeType,
assetTypeForMime,
+ publicUrlForAsset,
registerUploadArtifactForConversation,
};
diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs
index 02200b0..e9e533d 100644
--- a/mindspace-assets.test.mjs
+++ b/mindspace-assets.test.mjs
@@ -689,3 +689,30 @@ test('createUpload rejects another users category id', async () => {
(error) => error.code === 'category_not_found',
);
});
+
+test('publicUrlForAsset uses MindSpace public URL for workspace-synced images', () => {
+ const url = assetInternals.publicUrlForAsset({
+ id: 'asset-1',
+ user_id: 'user-1',
+ category_code: 'public',
+ mime_type: 'image/png',
+ original_filename: 'mapo-tofu-recipe.thumbnail.png',
+ workspace_relative_path: 'public/mapo-tofu-recipe.thumbnail.png',
+ storage_key: 'workspace://user-1/public/mapo-tofu-recipe.thumbnail.png',
+ });
+ assert.match(url, /\/MindSpace\/user-1\/public\/mapo-tofu-recipe\.thumbnail\.png$/);
+});
+
+test('publicUrlForAsset does not route workspace image paths through imgproxy', () => {
+ const url = assetInternals.publicUrlForAsset({
+ id: 'asset-2',
+ user_id: 'user-1',
+ category_code: 'public',
+ mime_type: 'image/jpeg',
+ original_filename: 'images/2026-07-04/photo.jpg',
+ workspace_relative_path: 'public/images/2026-07-04/photo.jpg',
+ storage_key: 'workspace://user-1/public/images/2026-07-04/photo.jpg',
+ });
+ assert.match(url, /\/MindSpace\/user-1\/public\/images\/2026-07-04\/photo\.jpg$/);
+ assert.doesNotMatch(url, /20081|workspace:\/\//);
+});
diff --git a/mindspace-chat-plaza.mjs b/mindspace-chat-plaza.mjs
index cdc9e11..591be8f 100644
--- a/mindspace-chat-plaza.mjs
+++ b/mindspace-chat-plaza.mjs
@@ -1,3 +1,7 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
+
export function slugFromPageTitle(title, pageId) {
const ascii = String(title ?? '')
.normalize('NFKC')
@@ -126,9 +130,21 @@ export async function ensureChatPageForPlaza({
});
}
-export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) {
+export async function ensurePagePublicationForPlaza({
+ userId,
+ page,
+ mindSpacePublications,
+ refreshIfOnline = true,
+}) {
const existing = await mindSpacePublications.getCurrent(userId, page.id);
- if (existing?.id) return existing;
+ if (existing?.id) {
+ if (refreshIfOnline && page.pageType === 'html' && mindSpacePublications.refreshOnlinePublicationHtml) {
+ return (
+ (await mindSpacePublications.refreshOnlinePublicationHtml(userId, page.id)) ?? existing
+ );
+ }
+ return existing;
+ }
const preferredSlug = slugFromPageTitle(page.title, page.id);
return mindSpacePublications.publish(userId, page.id, {
@@ -139,6 +155,80 @@ export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePub
});
}
+function throwAlreadyPublished(postId) {
+ throw Object.assign(new Error('该内容已发布到广场'), {
+ code: 'ALREADY_PUBLISHED',
+ details: { post_id: postId },
+ });
+}
+
+async function assertWorkspacePathNotAlreadyOnPlaza({ userId, relativePath, mindSpacePages }) {
+ if (!mindSpacePages?.findPlazaContextByWorkspacePath) return null;
+ const context = await mindSpacePages.findPlazaContextByWorkspacePath(userId, relativePath);
+ if (
+ context?.postId &&
+ ['published', 'pending_review'].includes(String(context.postStatus ?? ''))
+ ) {
+ throwAlreadyPublished(context.postId);
+ }
+ return context;
+}
+
+async function ensureWorkspaceHtmlPageForPlaza({
+ userId,
+ relativePath,
+ content,
+ title,
+ mindSpacePages,
+}) {
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+ let page = await mindSpacePages.findPageByRelativePath(userId, normalized);
+ const pageInput = {
+ title: title ?? page?.title ?? 'MindSpace 页面',
+ content,
+ contentFormat: 'html',
+ pageType: 'html',
+ categoryCode: 'draft',
+ };
+ if (page) {
+ return mindSpacePages.updatePage(userId, page.id, {
+ ...pageInput,
+ expectedVersion: page.versionNo,
+ });
+ }
+ return mindSpacePages.createFromChat(userId, pageInput, {
+ snapshot: {
+ content_mode: 'static_html',
+ relative_path: normalized,
+ },
+ });
+}
+
+async function publishPageToPlaza({ userId, page, mindSpacePublications, plazaPosts }) {
+ const publication = await ensurePagePublicationForPlaza({
+ userId,
+ page,
+ mindSpacePublications,
+ refreshIfOnline: true,
+ });
+
+ const { post } = await ensurePlazaPostForPublication({
+ userId,
+ publication,
+ plazaPosts,
+ });
+
+ return {
+ pageId: page.id,
+ publicationId: publication.id,
+ publicUrl: publication.publicUrl ?? publication.public_url ?? null,
+ post: {
+ id: post.id,
+ status: post.status,
+ },
+ };
+}
+
export async function ensurePlazaPostForPublication({
userId,
publication,
@@ -173,6 +263,7 @@ export async function quickPlazaFromChat({
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
+ const { analysis } = bundle;
const page = await ensureChatPageForPlaza({
userId: user.id,
bundle,
@@ -182,25 +273,146 @@ export async function quickPlazaFromChat({
skipThumbnail: true,
});
- const publication = await ensurePagePublicationForPlaza({
+ if (analysis.contentMode === 'static_html' && analysis.relativePath) {
+ await assertWorkspacePathNotAlreadyOnPlaza({
+ userId: user.id,
+ relativePath: analysis.relativePath,
+ mindSpacePages,
+ });
+ }
+
+ return publishPageToPlaza({
userId: user.id,
page,
mindSpacePublications,
- });
-
- const { post } = await ensurePlazaPostForPublication({
- userId: user.id,
- publication,
plazaPosts,
});
+}
+
+function titleFromPublicHtml(content, relativePath) {
+ const match = String(content ?? '').match(/]*>([^<]+)<\/title>/i);
+ const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim();
+ if (fromTitle) return fromTitle;
+ const basename = path.basename(String(relativePath ?? ''), '.html');
+ return basename || 'MindSpace 页面';
+}
+
+function normalizePublicHtmlRelativePath(relativePath) {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) {
+ throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面'), { code: 'invalid_public_html_path' });
+ }
+ return normalized;
+}
+
+export async function getQuickPlazaFromPublicHtmlStatus({
+ user,
+ relativePath,
+ mindSpacePages,
+ mindSpacePublications,
+ plazaPosts,
+}) {
+ if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
+ throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
+ }
+
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+ const context = await mindSpacePages.findPlazaContextByWorkspacePath?.(user.id, normalized);
+ if (
+ context?.postId &&
+ ['published', 'pending_review'].includes(String(context.postStatus ?? ''))
+ ) {
+ return {
+ published: true,
+ pageId: context.pageId,
+ publicationId: context.publicationId,
+ post: {
+ id: context.postId,
+ status: context.postStatus,
+ },
+ };
+ }
+
+ const page = context?.pageId
+ ? { id: context.pageId }
+ : await mindSpacePages.findPageByRelativePath(user.id, normalized);
+ if (!page) {
+ return { published: false };
+ }
+
+ const publication =
+ context?.publicationId != null
+ ? { id: context.publicationId }
+ : await mindSpacePublications.getCurrent(user.id, page.id);
+ if (!publication?.id) {
+ return { published: false, pageId: page.id };
+ }
+
+ const post = await plazaPosts.findPostByPublicationId(publication.id, { userId: user.id });
+ if (!post || !['published', 'pending_review'].includes(post.status)) {
+ return {
+ published: false,
+ pageId: page.id,
+ publicationId: publication.id,
+ };
+ }
return {
+ published: true,
pageId: page.id,
publicationId: publication.id,
- publicUrl: publication.publicUrl ?? publication.public_url ?? null,
post: {
id: post.id,
status: post.status,
},
};
}
+
+export async function quickPlazaFromPublicHtml({
+ user,
+ relativePath,
+ mindSpacePages,
+ mindSpacePublications,
+ plazaPosts,
+ publishDir,
+}) {
+ if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
+ throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
+ }
+
+ const normalized = normalizePublicHtmlRelativePath(relativePath);
+
+ await assertWorkspacePathNotAlreadyOnPlaza({
+ userId: user.id,
+ relativePath: normalized,
+ mindSpacePages,
+ });
+
+ const resolvedRoot = path.resolve(publishDir);
+ const filePath = path.resolve(publishDir, normalized);
+ if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
+ throw Object.assign(new Error('路径越界'), { code: 'invalid_public_html_path' });
+ }
+
+ let content;
+ try {
+ content = await fs.readFile(filePath, 'utf8');
+ } catch {
+ throw Object.assign(new Error('页面文件不存在'), { code: 'static_page_not_found' });
+ }
+
+ const page = await ensureWorkspaceHtmlPageForPlaza({
+ userId: user.id,
+ relativePath: normalized,
+ content,
+ title: titleFromPublicHtml(content, normalized),
+ mindSpacePages,
+ });
+
+ return publishPageToPlaza({
+ userId: user.id,
+ page,
+ mindSpacePublications,
+ plazaPosts,
+ });
+}
diff --git a/mindspace-chat-plaza.test.mjs b/mindspace-chat-plaza.test.mjs
index 40bd4b8..9503e07 100644
--- a/mindspace-chat-plaza.test.mjs
+++ b/mindspace-chat-plaza.test.mjs
@@ -14,3 +14,70 @@ test('slugFromPageTitle falls back to page when title has no ascii', () => {
test('slugFromPageTitle keeps ascii slug and page suffix', () => {
assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234');
});
+
+test('quickPlazaFromPublicHtml rejects non-public html paths', async () => {
+ await assert.rejects(
+ () =>
+ import('./mindspace-chat-plaza.mjs').then(({ quickPlazaFromPublicHtml }) =>
+ quickPlazaFromPublicHtml({
+ user: { id: 'user-1' },
+ relativePath: 'pages/demo.html',
+ mindSpacePages: {},
+ mindSpacePublications: {},
+ plazaPosts: {},
+ publishDir: '/tmp/publish',
+ }),
+ ),
+ (error) => error?.code === 'invalid_public_html_path',
+ );
+});
+
+test('getQuickPlazaFromPublicHtmlStatus reports published plaza posts', async () => {
+ const { getQuickPlazaFromPublicHtmlStatus } = await import('./mindspace-chat-plaza.mjs');
+ const result = await getQuickPlazaFromPublicHtmlStatus({
+ user: { id: 'user-1' },
+ relativePath: 'public/demo.html',
+ mindSpacePages: {
+ async findPlazaContextByWorkspacePath(userId, relativePath) {
+ assert.equal(userId, 'user-1');
+ assert.equal(relativePath, 'public/demo.html');
+ return {
+ pageId: 'page-1',
+ publicationId: 'pub-1',
+ postId: 'post-1',
+ postStatus: 'published',
+ };
+ },
+ },
+ mindSpacePublications: {},
+ plazaPosts: {},
+ });
+
+ assert.equal(result.published, true);
+ assert.equal(result.post.id, 'post-1');
+});
+
+test('quickPlazaFromPublicHtml blocks duplicate plaza posts for the same workspace html', async () => {
+ const { quickPlazaFromPublicHtml } = await import('./mindspace-chat-plaza.mjs');
+ await assert.rejects(
+ () =>
+ quickPlazaFromPublicHtml({
+ user: { id: 'user-1' },
+ relativePath: 'public/demo.html',
+ mindSpacePages: {
+ async findPlazaContextByWorkspacePath() {
+ return {
+ pageId: 'page-chat',
+ publicationId: 'pub-chat',
+ postId: 'post-chat',
+ postStatus: 'published',
+ };
+ },
+ },
+ mindSpacePublications: {},
+ plazaPosts: {},
+ publishDir: '/tmp/publish',
+ }),
+ (error) => error?.code === 'ALREADY_PUBLISHED' && error?.details?.post_id === 'post-chat',
+ );
+});
diff --git a/mindspace-page-sync.mjs b/mindspace-page-sync.mjs
index d2a4006..452801d 100644
--- a/mindspace-page-sync.mjs
+++ b/mindspace-page-sync.mjs
@@ -33,13 +33,19 @@ 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
+ LEFT 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')) = ?
+ AND (
+ p.workspace_relative_path = ?
+ OR (
+ (p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
+ AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
+ )
+ )
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
- [userId, relativePath],
+ [userId, relativePath, relativePath],
);
const row = rows[0];
if (!row) return null;
@@ -58,16 +64,18 @@ function parseJsonColumn(value, 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
+ `SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path, pv.source_snapshot_json
FROM h5_page_records p
- JOIN h5_page_versions pv ON pv.id = p.current_version_id
+ LEFT 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);
+ const relativePath =
+ normalizePublicHtmlPath(row.workspace_relative_path) ??
+ normalizePublicHtmlPath(snapshot.relative_path);
if (relativePath) {
byPath.set(relativePath, {
id: row.id,
diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs
index 3c2fc53..c3d5815 100644
--- a/mindspace-pages.mjs
+++ b/mindspace-pages.mjs
@@ -446,6 +446,8 @@ export function createPageService(pool, options = {}) {
source.createdAt != null && Number.isFinite(Number(source.createdAt))
? Math.floor(Number(source.createdAt))
: Date.now();
+ const pageWorkspaceRelativePath =
+ normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null;
const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown';
const contentMimeType =
@@ -498,8 +500,8 @@ export function createPageService(pool, options = {}) {
`INSERT INTO h5_page_records
(id, user_id, space_id, category_id, source_session_id, source_message_id,
source_asset_id, title, summary, page_type, template_id, draft_content_ref,
- current_version_id, status, visibility, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
+ workspace_relative_path, current_version_id, status, visibility, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
[
pageId,
userId,
@@ -513,6 +515,7 @@ export function createPageService(pool, options = {}) {
normalized.pageType,
normalized.templateId,
stored.storageKey,
+ pageWorkspaceRelativePath,
pageVersionId,
now,
now,
@@ -539,7 +542,7 @@ export function createPageService(pool, options = {}) {
await conn.query(
`UPDATE h5_page_records
SET title = ?, summary = ?, page_type = ?, template_id = ?,
- draft_content_ref = ?, current_version_id = ?, updated_at = ?
+ draft_content_ref = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[
normalized.title,
@@ -547,6 +550,7 @@ export function createPageService(pool, options = {}) {
normalized.pageType,
normalized.templateId,
stored.storageKey,
+ pageWorkspaceRelativePath,
pageVersionId,
now,
pageId,
@@ -659,14 +663,52 @@ export function createPageService(pool, options = {}) {
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ?
AND p.status <> 'deleted'
- AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
+ AND (
+ p.workspace_relative_path = ?
+ OR (
+ (p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
+ AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
+ )
+ )
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
- [userId, normalized],
+ [userId, normalized, normalized],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
+ const findPlazaContextByWorkspacePath = async (userId, relativePath) => {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ if (!normalized) return null;
+ const [rows] = await pool.query(
+ `SELECT p.id AS page_id, pr.id AS publication_id, pp.id AS post_id, pp.status AS post_status
+ FROM h5_page_records p
+ JOIN h5_page_versions pv ON pv.id = p.current_version_id
+ LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
+ LEFT JOIN plaza_posts pp ON pp.publication_id = pr.id AND pp.user_id = p.user_id AND pp.status != 'hidden'
+ WHERE p.user_id = ?
+ AND p.status <> 'deleted'
+ AND (
+ p.workspace_relative_path = ?
+ OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
+ )
+ ORDER BY
+ CASE pp.status WHEN 'published' THEN 0 WHEN 'pending_review' THEN 1 ELSE 2 END,
+ p.updated_at DESC,
+ p.id DESC
+ LIMIT 1`,
+ [userId, normalized, normalized],
+ );
+ const row = rows[0];
+ if (!row) return null;
+ return {
+ pageId: row.page_id,
+ publicationId: row.publication_id ?? null,
+ postId: row.post_id ?? null,
+ postStatus: row.post_status ?? null,
+ };
+ };
+
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -1448,6 +1490,7 @@ export function createPageService(pool, options = {}) {
findPageBySourceAsset,
findPageBySourceMessage,
findPageByRelativePath,
+ findPlazaContextByWorkspacePath,
getPage,
getDeletePreview,
deletePage,
diff --git a/mindspace-public-share-widget.mjs b/mindspace-public-share-widget.mjs
new file mode 100644
index 0000000..f4163b3
--- /dev/null
+++ b/mindspace-public-share-widget.mjs
@@ -0,0 +1,205 @@
+import crypto from 'node:crypto';
+
+const PUBLIC_FILE_SHARE_SCRIPT = `(function(){
+var root=document.querySelector('[data-mindspace-public-share]');
+if(!root)return;
+var shareButton=root.querySelector('[data-action="share"]');
+var captureButton=root.querySelector('[data-action="capture"]');
+var plazaButton=root.querySelector('[data-action="plaza"]');
+var dialog=root.querySelector('[data-mindspace-public-share-dialog]');
+var plazaCancel=root.querySelector('[data-action="plaza-cancel"]');
+var plazaConfirm=root.querySelector('[data-action="plaza-confirm"]');
+var plazaAlreadyClose=root.querySelector('[data-action="plaza-already-close"]');
+var plazaSuccessClose=root.querySelector('[data-action="plaza-success-close"]');
+var plazaErrorClose=root.querySelector('[data-action="plaza-error-close"]');
+var plazaAlreadyLink=root.querySelector('[data-plaza-already-link]');
+var plazaSuccessLink=root.querySelector('[data-plaza-success-link]');
+var plazaViews={
+ confirm:root.querySelector('[data-plaza-view="confirm"]'),
+ loading:root.querySelector('[data-plaza-view="loading"]'),
+ already:root.querySelector('[data-plaza-view="already"]'),
+ success:root.querySelector('[data-plaza-view="success"]'),
+ error:root.querySelector('[data-plaza-view="error"]')
+};
+var status=root.querySelector('small');
+var timer=null;
+var publishing=false;
+var checking=false;
+function setStatus(text,err,persist){
+if(!status)return;
+status.textContent=text||'';
+status.classList.toggle('is-error',!!err);
+if(timer)clearTimeout(timer);
+if(text&&!persist)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},3200);
+}
+function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}
+function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}
+function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}
+async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}
+function workspaceRelativePath(){var parts=location.pathname.split('/').filter(Boolean);if(parts[0]==='MindSpace'&&parts.length>=3)return parts.slice(2).join('/');return'';}
+function showPlazaView(name){Object.keys(plazaViews).forEach(function(key){var view=plazaViews[key];if(view)view.hidden=key!==name;});}
+function openPlazaDialog(){if(!dialog)return;dialog.hidden=false;showPlazaView('loading');void refreshPlazaDialog();}
+function closePlazaDialog(){if(dialog)dialog.hidden=true;showPlazaView('confirm');}
+function apiErrorBody(payload){return payload&&(payload.error||payload)||{};}
+function resolvePlazaUrl(payload){var body=apiErrorBody(payload);if(body.plaza_url)return body.plaza_url;var details=body.details;if(details&&details.plaza_url)return details.plaza_url;var postId=details&&(details.post_id||details.postId);if(!postId)return'';return location.origin.replace(/:\\d+$/,'')+':3001/plaza/p/'+encodeURIComponent(postId);}
+function showAlreadyPublished(plazaUrl){showPlazaView('already');if(plazaAlreadyLink){if(plazaUrl){plazaAlreadyLink.href=plazaUrl;plazaAlreadyLink.hidden=false;}else{plazaAlreadyLink.hidden=true;}}}
+async function refreshPlazaDialog(){
+if(checking)return;
+var relativePath=workspaceRelativePath();
+if(!relativePath){showPlazaView('error');return;}
+checking=true;
+try{
+var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html/status?relative_path='+encodeURIComponent(relativePath),{credentials:'same-origin'});
+var payload=await res.json().catch(function(){return{};});
+if(!res.ok){
+if(res.status===401)throw new Error('请先登录 MindSpace');
+var errBody=apiErrorBody(payload);
+throw new Error(errBody.message||'无法检查发布状态');
+}
+var data=payload.data||{};
+if(data.published){showAlreadyPublished(data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}}));return;}
+showPlazaView('confirm');
+}catch(e){
+showPlazaView('error');
+setStatus(e&&e.message?e.message:'无法检查发布状态',true);
+}
+finally{checking=false;}
+}
+shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});
+captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});
+plazaButton&&plazaButton.addEventListener('click',openPlazaDialog);
+plazaCancel&&plazaCancel.addEventListener('click',closePlazaDialog);
+plazaAlreadyClose&&plazaAlreadyClose.addEventListener('click',closePlazaDialog);
+plazaSuccessClose&&plazaSuccessClose.addEventListener('click',closePlazaDialog);
+plazaErrorClose&&plazaErrorClose.addEventListener('click',closePlazaDialog);
+dialog&&dialog.addEventListener('click',function(event){if(event.target===dialog)closePlazaDialog();});
+plazaConfirm&&plazaConfirm.addEventListener('click',async function(){
+if(publishing)return;
+var relativePath=workspaceRelativePath();
+if(!relativePath){setStatus('无法识别页面路径',true);closePlazaDialog();return;}
+publishing=true;
+plazaConfirm.disabled=true;
+showPlazaView('loading');
+try{
+var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({relative_path:relativePath})});
+var payload=await res.json().catch(function(){return{};});
+if(!res.ok){
+if(res.status===401)throw new Error('请先登录 MindSpace');
+var errBody=apiErrorBody(payload);
+var plazaUrl=resolvePlazaUrl(payload);
+if(errBody.code==='ALREADY_PUBLISHED'){showAlreadyPublished(plazaUrl);setStatus('该内容已在广场发布',false,true);return;}
+throw new Error(errBody.message||'发布失败');
+}
+var data=payload.data||{};
+var successUrl=data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}});
+showPlazaView('success');
+if(plazaSuccessLink&&successUrl){plazaSuccessLink.href=successUrl;plazaSuccessLink.hidden=false;}
+else if(plazaSuccessLink){plazaSuccessLink.hidden=true;}
+setStatus('发布成功',false,true);
+if(successUrl)window.open(successUrl,'_blank','noopener,noreferrer');
+}catch(e){
+showPlazaView('error');
+setStatus(e&&e.message?e.message:'发布失败',true);
+}finally{publishing=false;plazaConfirm.disabled=false;}
+});
+})();`;
+
+const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
+ .createHash('sha256')
+ .update(PUBLIC_FILE_SHARE_SCRIPT)
+ .digest('base64');
+
+export function injectPublicFileShareButton(html) {
+ const source = String(html ?? '');
+ if (!source || source.includes('data-mindspace-public-share')) {
+ return { html: source, scriptHashes: [] };
+ }
+ const markup = `
+
+
+
+
+
+
发布到 Plaza
+
将把当前页面发布并推送到发现广场,确认继续?
+
+
+
+
+
+
+
+
已在广场发布
+
该内容已发布到 Plaza,无法重复发布。如需重新发布,请先在广场删除原帖。
+
查看已有帖子
+
+
+
+
+
+
+
操作失败
+
请稍后重试,或先登录 MindSpace。
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+ if (/<\/body>/i.test(source)) {
+ return {
+ html: source.replace(/<\/body>/i, `${markup}
Hi