/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/preview/office.mjs * Purpose : LibreOffice headless → PDF conversion, queued + cached by sha * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdtemp, readdir, rename, rm, symlink } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import { config } from '../config.mjs'; import { blobPath } from '../storage/blobs.mjs'; import { enqueue, jobStatus } from './queue.mjs'; import { hasBin } from './thumbs.mjs'; const execFileP = promisify(execFile); const OFFICE_TIMEOUT = 5 * 60_000; export const OFFICE_EXTS = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf']); const officePdf = (sha) => path.join(config.cacheDir, 'office', `${sha}.pdf`); /** Locate the LibreOffice binary (macOS app bundle or PATH). */ export async function sofficeBin() { const mac = '/Applications/LibreOffice.app/Contents/MacOS/soffice'; if (existsSync(mac)) return mac; if (await hasBin('soffice')) return 'soffice'; if (await hasBin('libreoffice')) return 'libreoffice'; return null; } /** Conversion status: ready / processing / queued / unavailable / none. */ export function officeState(sha) { if (existsSync(officePdf(sha))) return { state: 'ready', path: officePdf(sha) }; const job = jobStatus(`office:${sha}`); if (job?.status === 'running') return { state: 'processing' }; if (job?.status === 'queued') return { state: 'queued' }; if (job?.status === 'error') return { state: 'unavailable' }; return { state: 'none' }; } /** * Convert an office document blob to PDF (queued, cached). * @param {string} ext original extension so soffice picks the right filter * @returns {Promise} pdf path, or null when LibreOffice is absent */ export async function convertToPdf(sha, ext) { const out = officePdf(sha); if (existsSync(out)) return out; const bin = await sofficeBin(); if (!bin) return null; return enqueue(`office:${sha}`, async () => { if (existsSync(out)) return out; const workDir = await mkdtemp(path.join(os.tmpdir(), 'spbdrive-office-')); try { // soffice needs a real extension to choose an import filter. const input = path.join(workDir, `doc.${ext}`); await symlink(blobPath(sha), input); await execFileP( bin, ['--headless', '--norestore', `-env:UserInstallation=file://${workDir}/lo-profile`, '--convert-to', 'pdf', '--outdir', workDir, input], { timeout: OFFICE_TIMEOUT }, ); const produced = (await readdir(workDir)).find((f) => f.endsWith('.pdf')); if (!produced) throw new Error('LibreOffice produced no PDF'); await rename(path.join(workDir, produced), out); return out; } finally { await rm(workDir, { recursive: true, force: true }); } }); }