mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +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,256 @@
|
||||
/**
|
||||
* @fileoverview Shared disk cache for expensive Office document previews.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
||||
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$source = ${toPowerShellSingleQuotedString(sourcePath)}
|
||||
$output = ${toPowerShellSingleQuotedString(outputPath)}
|
||||
$word = $null
|
||||
$doc = $null
|
||||
try {
|
||||
$word = New-Object -ComObject Word.Application
|
||||
$word.Visible = $false
|
||||
$word.DisplayAlerts = 0
|
||||
$doc = $word.Documents.Open($source)
|
||||
$doc.ExportAsFixedFormat($output, 17)
|
||||
} finally {
|
||||
if ($null -ne $doc) {
|
||||
$doc.Close($false) | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
||||
}
|
||||
if ($null -ne $word) {
|
||||
$word.Quit() | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
||||
}
|
||||
[System.GC]::Collect()
|
||||
[System.GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
type OfficePreviewConverter = 'msword' | 'libreoffice';
|
||||
|
||||
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function clearDocumentPreviewCache(): void {
|
||||
inFlightOfficeConversions.clear();
|
||||
}
|
||||
|
||||
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
if (ext !== 'docx' && ext !== 'pptx') return null;
|
||||
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const converter of getOfficePreviewConverters(filePath, ext)) {
|
||||
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
|
||||
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
const inFlightKey = `${converter}:${cacheKey}`;
|
||||
const inFlight = inFlightOfficeConversions.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
const converted = await inFlight;
|
||||
if (converted) return converted;
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversion =
|
||||
converter === 'msword'
|
||||
? convertWordDocumentToCachedPdf(filePath, cachePath)
|
||||
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
|
||||
inFlightOfficeConversions.set(inFlightKey, conversion);
|
||||
try {
|
||||
const converted = await conversion;
|
||||
if (converted) return converted;
|
||||
} finally {
|
||||
inFlightOfficeConversions.delete(inFlightKey);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
|
||||
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
|
||||
return ['msword', 'libreoffice'];
|
||||
}
|
||||
return ['libreoffice'];
|
||||
}
|
||||
|
||||
function createDocumentPreviewCacheKey(
|
||||
filePath: string,
|
||||
extension: string,
|
||||
size: number,
|
||||
mtimeMs: number,
|
||||
converter: OfficePreviewConverter
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
|
||||
if (converter === 'msword') {
|
||||
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
|
||||
if (windowsCacheDir) {
|
||||
return join(windowsCacheDir, `${cacheKey}.pdf`);
|
||||
}
|
||||
}
|
||||
|
||||
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return typeof stat.isFile !== 'function' || stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
const outputPath = wslMountPathToWindowsPath(cachePath);
|
||||
if (!outputPath) return null;
|
||||
|
||||
let sourceCopyPath: string | undefined;
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(cachePath), { recursive: true });
|
||||
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
|
||||
await fs.copyFile(filePath, sourceCopyPath);
|
||||
|
||||
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
|
||||
if (!sourcePath) return null;
|
||||
|
||||
await execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-EncodedCommand',
|
||||
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (sourceCopyPath) {
|
||||
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
let workDir: string | undefined;
|
||||
try {
|
||||
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
||||
workDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
||||
const profileDir = join(workDir, 'profile');
|
||||
await fs.mkdir(profileDir, { recursive: true });
|
||||
|
||||
await execFileAsync(
|
||||
'soffice',
|
||||
[
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
workDir,
|
||||
filePath,
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
);
|
||||
|
||||
const converted = (await fs.readdir(workDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
||||
if (!converted) return null;
|
||||
|
||||
await fs.rename(join(workDir, converted), cachePath);
|
||||
return cachePath;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (workDir) {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wslMountPathToWindowsPath(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function getWindowsUserTempCacheDir(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
|
||||
if (!match) return null;
|
||||
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
|
||||
}
|
||||
|
||||
function toPowerShellSingleQuotedString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function encodePowerShellCommand(script: string): string {
|
||||
return Buffer.from(script, 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
|
||||
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
|
||||
}
|
||||
|
||||
function getCacheErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
Reference in New Issue
Block a user