Record published companion artifacts
This commit is contained in:
@@ -7,7 +7,9 @@ import { pageInternals } from './mindspace-pages.mjs';
|
|||||||
import { loadMindSpaceConfig } from './mindspace-config.mjs';
|
import { loadMindSpaceConfig } from './mindspace-config.mjs';
|
||||||
import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
|
import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
|
||||||
import {
|
import {
|
||||||
|
isRelativeDownloadReference,
|
||||||
loadWorkspaceDownloadLinkIndex,
|
loadWorkspaceDownloadLinkIndex,
|
||||||
|
resolveWorkspaceRelativeFilePath,
|
||||||
rewriteRelativeDownloadLinks,
|
rewriteRelativeDownloadLinks,
|
||||||
inferWorkspaceHtmlRelativePath,
|
inferWorkspaceHtmlRelativePath,
|
||||||
} from './mindspace-html-download-links.mjs';
|
} from './mindspace-html-download-links.mjs';
|
||||||
@@ -16,6 +18,7 @@ import { createImgproxySigner } from './imgproxy-signer.mjs';
|
|||||||
const SCANNER_VERSION = 'mindspace-content-v1';
|
const SCANNER_VERSION = 'mindspace-content-v1';
|
||||||
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
|
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
|
||||||
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
|
/(?: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([
|
const ACCESS_MODES = new Set([
|
||||||
'public',
|
'public',
|
||||||
'password',
|
'password',
|
||||||
@@ -165,6 +168,71 @@ function buildPublicationThumbnailFallback(ownerSlug, urlSlug) {
|
|||||||
return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`;
|
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;
|
const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i;
|
||||||
|
|
||||||
function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) {
|
function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) {
|
||||||
@@ -755,6 +823,8 @@ export function createPublicationService(pool, options = {}) {
|
|||||||
bundleAssetId,
|
bundleAssetId,
|
||||||
storageKey,
|
storageKey,
|
||||||
htmlBytes,
|
htmlBytes,
|
||||||
|
html: page.content,
|
||||||
|
publishDir: h5Root ? resolvePublishDir(h5Root, { id: userId }) : null,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
return publication;
|
return publication;
|
||||||
@@ -1067,6 +1137,8 @@ async function registerPublicationArtifactForConversation({
|
|||||||
bundleAssetId,
|
bundleAssetId,
|
||||||
storageKey,
|
storageKey,
|
||||||
htmlBytes,
|
htmlBytes,
|
||||||
|
html,
|
||||||
|
publishDir,
|
||||||
now,
|
now,
|
||||||
}) {
|
}) {
|
||||||
const sessionId = String(page?.source_session_id ?? '').trim();
|
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,
|
sortOrder: Number.isFinite(Number(publication.publishedAt)) ? Number(publication.publishedAt) : 0,
|
||||||
now,
|
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 });
|
await registry.writeManifestForSession({ userId, sessionId });
|
||||||
return artifact;
|
return artifact;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1121,6 +1225,7 @@ export const publicationInternals = {
|
|||||||
workspacePublicAssetRelativeUrl,
|
workspacePublicAssetRelativeUrl,
|
||||||
rewriteWorkspacePublicAssetReferences,
|
rewriteWorkspacePublicAssetReferences,
|
||||||
localizePrivateImageReferences,
|
localizePrivateImageReferences,
|
||||||
|
collectPublicationCompanionArtifacts,
|
||||||
prepareHtmlPublishContent,
|
prepareHtmlPublishContent,
|
||||||
registerPublicationArtifactForConversation,
|
registerPublicationArtifactForConversation,
|
||||||
scanContent, // re-exported from mindspace-content-scan.mjs
|
scanContent, // re-exported from mindspace-content-scan.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: '<a href="appendix.pdf" download>PDF</a>',
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<!doctype html><title>发布页</title>',
|
||||||
|
publishDir,
|
||||||
|
now: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifactKinds = calls
|
||||||
|
.filter(([name]) => name === 'recordArtifact')
|
||||||
|
.map(([, input]) => input.artifactKind)
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']);
|
||||||
|
const docx = calls.find(([, input]) => input?.artifactKind === 'docx')?.[1];
|
||||||
|
assert.equal(docx.displayName, 'report.docx');
|
||||||
|
assert.match(docx.canonicalUrl, /\/MindSpace\/user-1\/public\/report\.docx$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerPublicationArtifactForConversation skips pages without source session', async () => {
|
test('registerPublicationArtifactForConversation skips pages without source session', async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await publicationInternals.registerPublicationArtifactForConversation({
|
await publicationInternals.registerPublicationArtifactForConversation({
|
||||||
|
|||||||
Reference in New Issue
Block a user