spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/preview/office.mjs8 * Purpose : LibreOffice headless → PDF conversion, queued + cached by sha9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { execFile } from 'node:child_process';14import { existsSync } from 'node:fs';15import { mkdtemp, readdir, rename, rm, symlink } from 'node:fs/promises';16import os from 'node:os';17import path from 'node:path';18import { promisify } from 'node:util';19import { config } from '../config.mjs';20import { blobPath } from '../storage/blobs.mjs';21import { enqueue, jobStatus } from './queue.mjs';22import { hasBin } from './thumbs.mjs';2324const execFileP = promisify(execFile);25const OFFICE_TIMEOUT = 5 * 60_000;2627export const OFFICE_EXTS = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf']);2829const officePdf = (sha) => path.join(config.cacheDir, 'office', `${sha}.pdf`);3031/** Locate the LibreOffice binary (macOS app bundle or PATH). */32export async function sofficeBin() {33 const mac = '/Applications/LibreOffice.app/Contents/MacOS/soffice';34 if (existsSync(mac)) return mac;35 if (await hasBin('soffice')) return 'soffice';36 if (await hasBin('libreoffice')) return 'libreoffice';37 return null;38}3940/** Conversion status: ready / processing / queued / unavailable / none. */41export function officeState(sha) {42 if (existsSync(officePdf(sha))) return { state: 'ready', path: officePdf(sha) };43 const job = jobStatus(`office:${sha}`);44 if (job?.status === 'running') return { state: 'processing' };45 if (job?.status === 'queued') return { state: 'queued' };46 if (job?.status === 'error') return { state: 'unavailable' };47 return { state: 'none' };48}4950/**51 * Convert an office document blob to PDF (queued, cached).52 * @param {string} ext original extension so soffice picks the right filter53 * @returns {Promise<string|null>} pdf path, or null when LibreOffice is absent54 */55export async function convertToPdf(sha, ext) {56 const out = officePdf(sha);57 if (existsSync(out)) return out;58 const bin = await sofficeBin();59 if (!bin) return null;6061 return enqueue(`office:${sha}`, async () => {62 if (existsSync(out)) return out;63 const workDir = await mkdtemp(path.join(os.tmpdir(), 'spbdrive-office-'));64 try {65 // soffice needs a real extension to choose an import filter.66 const input = path.join(workDir, `doc.${ext}`);67 await symlink(blobPath(sha), input);68 await execFileP(69 bin,70 ['--headless', '--norestore', `-env:UserInstallation=file://${workDir}/lo-profile`,71 '--convert-to', 'pdf', '--outdir', workDir, input],72 { timeout: OFFICE_TIMEOUT },73 );74 const produced = (await readdir(workDir)).find((f) => f.endsWith('.pdf'));75 if (!produced) throw new Error('LibreOffice produced no PDF');76 await rename(path.join(workDir, produced), out);77 return out;78 } finally {79 await rm(workDir, { recursive: true, force: true });80 }81 });82}83