mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
COD-38 document attachment previews + thumbnails (attachment cards)
Builds on the COD-37 registry: surfaces detected/registered attachments as dismissible cards with a first-page thumbnail and an inline preview — the consumer the registry PR deliberately deferred. Backend: - document-thumbnailer: first-page PNG thumbnails (PNG passthrough; PDF via pdftoppm; Office via the preview cache). - document-preview-cache: disk-cached DOCX/PPTX -> PDF conversion (LibreOffice / PowerShell COM), in-flight dedup, multi-converter fallback. - file-routes: serveConvertedPreview / serveThumbnail + four routes — GET .../attachments/:id/preview, .../thumbnail and the workspace-path file-preview / file-thumbnail. Reuses the registry's TOCTOU-safe resolveServableAttachmentPath, so previews stream the freshly-resolved path. - server: enrich detected attachment events with a thumbnail route. - image-watcher: .png now routes to attachment:detected — this PR adds the card consumer, so the screenshot popup is no longer its only handler. Frontend: - panels-ui: attachment cards (addAttachmentCard, lazy stack, Clear-all, per-session cleanup) plus a 3-arg openFilePreview that renders registered attachments inline (image/PDF) or via the server-converted PDF (docx/pptx). - app.js: wire attachment:detected -> _onAttachmentDetected and card state. - styles: attachment-card + stack styling. Verified: tsc / eslint / prettier / frontend-syntax clean; new thumbnailer + preview-cache unit tests pass; full test:ci green (2861 passed); card render + preview overlay + dismiss verified in-browser.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @fileoverview Best-effort first-page thumbnails for attachment cards.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, extname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return renderPdfFirstPage(filePath);
|
||||
}
|
||||
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
return renderOfficeFirstPage(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
try {
|
||||
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
|
||||
if (!previewPdfPath) return null;
|
||||
return await renderPdfFirstPage(previewPdfPath);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
let previewDir: string | undefined;
|
||||
try {
|
||||
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
|
||||
const prefix = join(previewDir, basename(filePath, extname(filePath)));
|
||||
await execFileAsync(
|
||||
'pdftoppm',
|
||||
['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix],
|
||||
{
|
||||
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
const content = await fs.readFile(`${prefix}.png`);
|
||||
return { content, contentType: 'image/png' };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (previewDir) {
|
||||
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getThumbnailErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
Reference in New Issue
Block a user