use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::process::Command; const CACHE_DIR: &str = "data/cache"; // Phase 1: built-in macOS tools (no installation) const TEXTUTIL_FORMATS: &[&str] = &["docx", "doc", "rtf"]; const APPLE_FORMATS: &[&str] = &["pages", "key", "numbers"]; // Phase 2: soffice/qlmanage fallback const SOFFICE_FORMATS: &[&str] = &["pptx", "ppt", "xlsx", "xls", "odt", "epub"]; pub fn is_document_ext(ext: &str) -> bool { TEXTUTIL_FORMATS.contains(&ext) || APPLE_FORMATS.contains(&ext) || SOFFICE_FORMATS.contains(&ext) } pub fn is_textutil_ext(ext: &str) -> bool { TEXTUTIL_FORMATS.contains(&ext) } pub fn is_apple_format_ext(ext: &str) -> bool { APPLE_FORMATS.contains(&ext) } pub fn get_cached_preview(file_uuid: &str, ext: &str) -> Option<(PathBuf, &'static str)> { if TEXTUTIL_FORMATS.contains(&ext) { get_cached_txt(file_uuid).map(|p| (p, "text/plain; charset=utf-8")) } else if APPLE_FORMATS.contains(&ext) { get_cached_jpg(file_uuid).map(|p| (p, "image/jpeg")) } else { get_cached_pdf(file_uuid).map(|p| (p, "application/pdf")) } } pub fn get_cached_txt(file_uuid: &str) -> Option { let p = Path::new(CACHE_DIR).join(format!("{}.txt", file_uuid)); p.exists().then_some(p) } pub fn get_cached_jpg(file_uuid: &str) -> Option { let p = Path::new(CACHE_DIR).join(format!("{}.jpg", file_uuid)); p.exists().then_some(p) } pub fn get_cached_pdf(file_uuid: &str) -> Option { let p = Path::new(CACHE_DIR).join(format!("{}.pdf", file_uuid)); p.exists().then_some(p) } pub fn get_cached_png(file_uuid: &str) -> Option { let p = Path::new(CACHE_DIR).join(format!("{}.png", file_uuid)); p.exists().then_some(p) } pub fn convert_document(input_path: &Path, file_uuid: &str) -> Result<(PathBuf, &'static str)> { let ext = input_path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); // Phase 1: built-in tools (fast, no installation) if TEXTUTIL_FORMATS.contains(&ext.as_str()) { let p = textutil_to_txt(input_path, file_uuid)?; return Ok((p, "text/plain; charset=utf-8")); } if APPLE_FORMATS.contains(&ext.as_str()) { match unzip_preview_jpg(input_path, file_uuid) { Ok(p) => return Ok((p, "image/jpeg")), Err(e) => eprintln!("[markbase] unzip preview failed for {}: {}", file_uuid, e), } // Fall back to qlmanage PNG let p = qlmanage_to_png(input_path, file_uuid, 2048)?; return Ok((p, "image/png")); } // Phase 2: soffice for Office formats if SOFFICE_FORMATS.contains(&ext.as_str()) { match soffice_to_pdf(input_path, file_uuid) { Ok(p) => return Ok((p, "application/pdf")), Err(e) => { eprintln!("[markbase] soffice failed for {}: {}", file_uuid, e); } } } // Final fallback: qlmanage PNG let p = qlmanage_to_png(input_path, file_uuid, 2048)?; Ok((p, "image/png")) } // ─── Phase 1: textutil (macOS built-in, .docx/.doc/.rtf → .txt) ─── fn textutil_to_txt(input_path: &Path, file_uuid: &str) -> Result { let cache_dir = Path::new(CACHE_DIR); std::fs::create_dir_all(cache_dir)?; let output = cache_dir.join(format!("{}.txt", file_uuid)); if output.exists() { return Ok(output); } let out = Command::new("textutil") .args(["-convert", "txt", "-output"]) .arg(&output) .arg(input_path) .output() .context("Failed to run textutil")?; if !out.status.success() { anyhow::bail!("textutil: {}", String::from_utf8_lossy(&out.stderr).trim()); } if output.exists() { Ok(output) } else { anyhow::bail!("textutil did not produce {}", output.display()) } } // ─── Phase 1: unzip preview.jpg from iWork packages ─── fn unzip_preview_jpg(input_path: &Path, file_uuid: &str) -> Result { let cache_dir = Path::new(CACHE_DIR); std::fs::create_dir_all(cache_dir)?; let output = cache_dir.join(format!("{}.jpg", file_uuid)); if output.exists() { return Ok(output); } let tmp = cache_dir.join(format!("_tmp_{}", file_uuid)); let _ = std::fs::remove_dir_all(&tmp); let out = Command::new("unzip") .args(["-o", "-d"]) .arg(&tmp) .arg(input_path) .output() .context("Failed to unzip iWork package")?; if !out.status.success() { anyhow::bail!("unzip: {}", String::from_utf8_lossy(&out.stderr).trim()); } // Look for preview.jpg, preview.pdf, or quicklook/thumbnail.jpg for name in &[ "preview.jpg", "preview.png", "preview.pdf", "preview-web.jpg", ] { let src = tmp.join(name); if src.exists() { std::fs::copy(&src, &output)?; let _ = std::fs::remove_dir_all(&tmp); return Ok(output); } } let _ = std::fs::remove_dir_all(&tmp); anyhow::bail!("no preview found in iWork package") } // ─── Phase 2: soffice (LibreOffice, multi-page PDF) ─── fn soffice_to_pdf(input_path: &Path, file_uuid: &str) -> Result { let cache_dir = Path::new(CACHE_DIR); std::fs::create_dir_all(cache_dir)?; let output = cache_dir.join(format!("{}.pdf", file_uuid)); if output.exists() { return Ok(output); } let out = Command::new("soffice") .args(["--headless", "--convert-to", "pdf", "--outdir"]) .arg(cache_dir) .arg(input_path) .output() .context("Failed to run soffice")?; if !out.status.success() { anyhow::bail!("soffice: {}", String::from_utf8_lossy(&out.stderr).trim()); } let basename = input_path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown"); let generated = cache_dir.join(format!("{}.pdf", basename)); if generated.exists() && generated != output { std::fs::rename(&generated, &output)?; } if output.exists() { Ok(output) } else { anyhow::bail!("soffice did not produce {}", output.display()) } } // ─── Phase 2: qlmanage (Apple QuickLook, PNG thumbnail) ─── fn qlmanage_to_png(input_path: &Path, file_uuid: &str, size: u32) -> Result { let cache_dir = Path::new(CACHE_DIR); std::fs::create_dir_all(cache_dir)?; let output = cache_dir.join(format!("{}.png", file_uuid)); if output.exists() { return Ok(output); } let out = Command::new("qlmanage") .args(["-t", "-s", &size.to_string(), "-o"]) .arg(cache_dir) .arg(input_path) .output() .context("Failed to run qlmanage")?; if !out.status.success() { anyhow::bail!("qlmanage: {}", String::from_utf8_lossy(&out.stderr).trim()); } let filename = input_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("unknown"); let generated = cache_dir.join(format!("{}.png", filename)); if generated.exists() && generated != output { std::fs::rename(&generated, &output)?; } if output.exists() { Ok(output) } else { anyhow::bail!("qlmanage did not produce {}", output.display()) } }