diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index fe9fa01..8da1b12 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -7,7 +7,9 @@ import { pageInternals } from './mindspace-pages.mjs'; import { loadMindSpaceConfig } from './mindspace-config.mjs'; import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs'; import { + isRelativeDownloadReference, loadWorkspaceDownloadLinkIndex, + resolveWorkspaceRelativeFilePath, rewriteRelativeDownloadLinks, inferWorkspaceHtmlRelativePath, } from './mindspace-html-download-links.mjs'; @@ -16,6 +18,7 @@ import { createImgproxySigner } from './imgproxy-signer.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; const ACCESS_MODES = new Set([ 'public', 'password', @@ -165,6 +168,71 @@ function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; } +function companionKindForWorkspacePath(workspacePath) { + const normalized = String(workspacePath ?? '').toLowerCase(); + if (normalized.endsWith('.long.png')) return 'long_image'; + if (normalized.endsWith('.docx') || normalized.endsWith('.doc')) return 'docx'; + if (normalized.endsWith('.pdf')) return 'pdf'; + return 'generated_file'; +} + +function companionMimeForWorkspacePath(workspacePath) { + const normalized = String(workspacePath ?? '').toLowerCase(); + if (normalized.endsWith('.long.png') || normalized.endsWith('.png')) return 'image/png'; + if (normalized.endsWith('.docx')) { + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + } + if (normalized.endsWith('.doc')) return 'application/msword'; + if (normalized.endsWith('.pdf')) return 'application/pdf'; + if (normalized.endsWith('.csv')) return 'text/csv'; + if (normalized.endsWith('.md')) return 'text/markdown'; + if (normalized.endsWith('.txt')) return 'text/plain'; + return 'application/octet-stream'; +} + +async function statWorkspaceFile(publishDir, workspacePath) { + if (!publishDir || !workspacePath) return null; + const normalized = String(workspacePath).replace(/^\/+/, '').replace(/\\/g, '/'); + const root = path.resolve(publishDir); + const absolutePath = path.resolve(root, ...normalized.split('/')); + if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) return null; + try { + const stat = await fs.stat(absolutePath); + if (!stat.isFile()) return null; + return { workspacePath: normalized, sizeBytes: stat.size }; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + return null; + } +} + +async function collectPublicationCompanionArtifacts({ + publishDir, + html = '', + htmlRelativePath = null, +}) { + if (!publishDir || !htmlRelativePath) return []; + const candidates = new Set(); + const normalizedHtmlPath = String(htmlRelativePath).replace(/^\/+/, '').replace(/\\/g, '/'); + if (normalizedHtmlPath.toLowerCase().endsWith('.html')) { + candidates.add(normalizedHtmlPath.replace(/\.html$/i, '.docx')); + candidates.add(normalizedHtmlPath.replace(/\.html$/i, '.long.png')); + } + let match; + while ((match = URL_ATTR_PATTERN.exec(String(html ?? ''))) !== null) { + const ref = match[2]; + if (isRelativeDownloadReference(ref)) { + candidates.add(resolveWorkspaceRelativeFilePath(normalizedHtmlPath, ref)); + } + } + const artifacts = []; + for (const candidate of candidates) { + const stat = await statWorkspaceFile(publishDir, candidate); + if (stat) artifacts.push(stat); + } + return artifacts; +} + const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) { @@ -755,6 +823,8 @@ export function createPublicationService(pool, options = {}) { bundleAssetId, storageKey, htmlBytes, + html: page.content, + publishDir: h5Root ? resolvePublishDir(h5Root, { id: userId }) : null, now, }); return publication; @@ -1067,6 +1137,8 @@ async function registerPublicationArtifactForConversation({ bundleAssetId, storageKey, htmlBytes, + html, + publishDir, now, }) { const sessionId = String(page?.source_session_id ?? '').trim(); @@ -1095,6 +1167,38 @@ async function registerPublicationArtifactForConversation({ sortOrder: Number.isFinite(Number(publication.publishedAt)) ? Number(publication.publishedAt) : 0, now, }); + const htmlRelativePath = page.source_relative_path ?? null; + const companionArtifacts = await collectPublicationCompanionArtifacts({ + publishDir, + html, + htmlRelativePath, + }); + await Promise.all( + companionArtifacts.map((companion, index) => + registry.recordArtifact({ + id: `ca_${publication.id}_${crypto + .createHash('sha256') + .update(companion.workspacePath) + .digest('hex') + .slice(0, 12)}`, + packageId: packageRecord.id, + artifactKind: companionKindForWorkspacePath(companion.workspacePath), + role: 'assistant', + pageId: page.page_id, + publicationId: publication.id, + messageId: page.source_message_id ?? null, + displayName: path.posix.basename(companion.workspacePath), + mimeType: companionMimeForWorkspacePath(companion.workspacePath), + sizeBytes: companion.sizeBytes, + canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), userId, companion.workspacePath), + sortOrder: + (Number.isFinite(Number(publication.publishedAt)) ? Number(publication.publishedAt) : 0) + + index + + 1, + now, + }), + ), + ); await registry.writeManifestForSession({ userId, sessionId }); return artifact; } catch (error) { @@ -1121,6 +1225,7 @@ export const publicationInternals = { workspacePublicAssetRelativeUrl, rewriteWorkspacePublicAssetReferences, localizePrivateImageReferences, + collectPublicationCompanionArtifacts, prepareHtmlPublishContent, registerPublicationArtifactForConversation, scanContent, // re-exported from mindspace-content-scan.mjs diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs index 222184d..b6c0a99 100644 --- a/mindspace-publications.test.mjs +++ b/mindspace-publications.test.mjs @@ -275,6 +275,79 @@ test('registerPublicationArtifactForConversation records public html artifact', ]); }); +test('collectPublicationCompanionArtifacts finds real download and long-image files', async () => { + const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-')); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx'); + await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png'); + await fs.writeFile(path.join(publishDir, 'public', 'appendix.pdf'), 'pdf'); + + const artifacts = await publicationInternals.collectPublicationCompanionArtifacts({ + publishDir, + htmlRelativePath: 'public/report.html', + html: 'PDF', + }); + + assert.deepEqual( + artifacts.map((item) => item.workspacePath).sort(), + ['public/appendix.pdf', 'public/report.docx', 'public/report.long.png'], + ); +}); + +test('registerPublicationArtifactForConversation records public companions', async () => { + const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-')); + await fs.mkdir(path.join(publishDir, 'public'), { recursive: true }); + await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx'); + await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png'); + const calls = []; + const registry = { + async ensurePackage(input) { + calls.push(['ensurePackage', input]); + return { id: 'cp_session-1' }; + }, + async recordArtifact(input) { + calls.push(['recordArtifact', input]); + return input; + }, + async writeManifestForSession(input) { + calls.push(['writeManifestForSession', input]); + return { storageKey: 'users/user-1/conversations/session-1/manifest.json' }; + }, + }; + + await publicationInternals.registerPublicationArtifactForConversation({ + registry, + userId: 'user-1', + page: { + page_id: 'page-1', + title: '发布页', + source_session_id: 'session-1', + source_message_id: 'message-1', + source_relative_path: 'public/report.html', + }, + publication: { + id: 'pub-1', + publicUrl: 'https://m.tkmind.cn/u/john/pages/published', + publishedAt: 3000, + }, + bundleAssetId: 'asset-pub', + storageKey: 'users/user-1/publications/pub-1/index.html', + htmlBytes: 256, + html: '