diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index bbc1f70..1bc85d5 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -8,7 +8,7 @@ import { ensureHtmlThumbnail, scheduleHtmlThumbnail, } from './mindspace-thumbnails.mjs'; -import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; +import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; @@ -828,10 +828,21 @@ export function createAssetService(pool, options = {}) { [assetId], ); const html = await fs.readFile(assetPath, 'utf8'); + const contentBaseDir = + asset.sourceType === 'workspace' && h5Root + ? path.dirname( + resolveZoneFilePath( + resolveUserWorkspaceRoot(h5Root, { id: userId }), + asset.categoryCode, + asset.filename, + ), + ) + : undefined; return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, { title: asset.displayName, subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML', contentStorageKey: versions[0]?.storage_key, + contentBaseDir, }); }; diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 723993e..e0821ba 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -10,6 +10,7 @@ import { pageThumbnailKey, writeThumbnail, bufferToImageDataUri, + resolveWorkspaceHtmlContentBaseDir, } from './mindspace-thumbnails.mjs'; import { resolvePublishDir } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; @@ -951,6 +952,10 @@ export function createPageService(pool, options = {}) { title, subtitle: summary || `草稿 v${asNumber(ctx.row.version_no)}`, contentStorageKey: ctx.row.storage_key, + contentBaseDir: resolveWorkspaceHtmlContentBaseDir( + ctx.workspacePublishDir, + ctx.workspaceHtmlRelativePath, + ), force: true, ...metaOverrides, }; diff --git a/mindspace-thumbnails.mjs b/mindspace-thumbnails.mjs index c0b83cd..ec2db5d 100644 --- a/mindspace-thumbnails.mjs +++ b/mindspace-thumbnails.mjs @@ -451,6 +451,30 @@ export function isModernFeedThumbnail(svg) { return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg); } +export function thumbnailHasEmbeddedPhoto(svg) { + return /href="data:image\//.test(String(svg ?? '')); +} + +export function resolveWorkspaceHtmlContentBaseDir(workspacePublishDir, workspaceHtmlRelativePath) { + if (!workspacePublishDir || !workspaceHtmlRelativePath) return undefined; + const dir = path.posix.dirname(String(workspaceHtmlRelativePath).replace(/^\/+/, '')); + return dir === '.' ? workspacePublishDir : path.join(workspacePublishDir, dir); +} + +async function shouldRegenerateThumbnailForCover(storageRoot, html, meta, existing) { + if (!existing || !isModernFeedThumbnail(existing) || meta.force) return Boolean(meta.force); + const signals = extractCoverSignals(html, meta); + if (!signals.image) return false; + if (thumbnailHasEmbeddedPhoto(existing)) return false; + const coverDataUri = await resolveCoverDataUri({ + storageRoot, + contentStorageKey: meta.contentStorageKey, + contentBaseDir: meta.contentBaseDir, + imageUrl: signals.image, + }); + return Boolean(coverDataUri); +} + export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { const signals = extractCoverSignals(html, meta); const coverDataUri = await resolveCoverDataUri({ @@ -467,7 +491,13 @@ export async function generateHtmlThumbnail(storageRoot, storageKey, html, meta export async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { const existing = await readThumbnailIfExists(storageRoot, storageKey); if (existing && isModernFeedThumbnail(existing) && !meta.force) { - return existing; + const needsCoverRefresh = await shouldRegenerateThumbnailForCover( + storageRoot, + html, + meta, + existing, + ); + if (!needsCoverRefresh) return existing; } return generateHtmlThumbnail(storageRoot, storageKey, html, meta); } @@ -480,17 +510,34 @@ export async function ensurePageThumbnail({ workspacePublishDir = null, workspaceHtmlRelativePath = null, }) { + const contentBaseDir = + meta.contentBaseDir ?? + resolveWorkspaceHtmlContentBaseDir(workspacePublishDir, workspaceHtmlRelativePath); + const enrichedMeta = contentBaseDir ? { ...meta, contentBaseDir } : meta; + if (workspacePublishDir && workspaceHtmlRelativePath) { const sidecar = await readThumbnailIfExists( workspacePublishDir, workspaceThumbnailRelativePath(workspaceHtmlRelativePath), ); if (sidecar && isModernFeedThumbnail(sidecar)) { - await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); - return sidecar; + if (thumbnailHasEmbeddedPhoto(sidecar)) { + await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); + return sidecar; + } + const needsCoverRefresh = await shouldRegenerateThumbnailForCover( + workspacePublishDir, + html, + enrichedMeta, + sidecar, + ); + if (!needsCoverRefresh) { + await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar); + return sidecar; + } } } - return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta); + return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, enrichedMeta); } export async function readThumbnailIfExists(storageRoot, storageKey) { diff --git a/mindspace-thumbnails.test.mjs b/mindspace-thumbnails.test.mjs index 3916fac..a1d8c6a 100644 --- a/mindspace-thumbnails.test.mjs +++ b/mindspace-thumbnails.test.mjs @@ -8,7 +8,9 @@ import { generateHtmlThumbnail, isModernFeedThumbnail, resolveCoverDataUri, + resolveWorkspaceHtmlContentBaseDir, shouldUseScenicBackground, + thumbnailHasEmbeddedPhoto, } from './mindspace-thumbnails.mjs'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -163,3 +165,38 @@ test('resolveCoverDataUri rejects paths outside content base', async () => { }); assert.equal(dataUri, null); }); + +test('resolveWorkspaceHtmlContentBaseDir resolves nested html paths', () => { + assert.equal( + resolveWorkspaceHtmlContentBaseDir('/workspace', 'oa/report.html'), + path.join('/workspace', 'oa'), + ); + assert.equal(resolveWorkspaceHtmlContentBaseDir('/workspace', 'page.html'), '/workspace'); +}); + +test('ensureHtmlThumbnail refreshes cached thumbnail when cover image becomes resolvable', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-refresh-')); + const htmlDir = path.join(root, 'oa'); + await fs.mkdir(htmlDir, { recursive: true }); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNk+M9Qz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', + 'base64', + ); + await fs.writeFile(path.join(htmlDir, 'hero.png'), png); + const html = `
+