use crate::config::Config; use std::path::Path; use std::process::Command; fn run_cmd(prog: &str, args: &[&str]) -> Option { let out = Command::new(prog).args(args).output().ok()?; if !out.status.success() && out.stdout.is_empty() { return None; } Some(String::from_utf8_lossy(&out.stdout).to_string()) } fn read_text_file(path: &Path, max: usize) -> Option { let bytes = std::fs::read(path).ok()?; if bytes.contains(&0) { return None; } let mut s = String::from_utf8_lossy(&bytes).to_string(); if s.chars().count() > max { s = s.chars().take(max).collect(); s.push_str("\n...[truncated]..."); } Some(s) } fn xml_text(xml: &str) -> String { // extract text nodes using quick-xml use quick_xml::events::Event; use quick_xml::Reader; let mut reader = Reader::from_str(xml); reader.config_mut().trim_text(true); let mut buf = Vec::new(); let mut out = String::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Text(t)) => { if let Ok(txt) = t.unescape() { let txt = txt.trim(); if !txt.is_empty() { out.push_str(txt); out.push(' '); } } } Ok(Event::Eof) => break, Err(_) => break, _ => {} } buf.clear(); } out } fn unzip_entry(path: &Path, entry: &str) -> Option { run_cmd("unzip", &["-p", path.to_str()?, entry]) } fn extract_office(path: &Path, ext: &str) -> Option { match ext { "docx" => { let xml = unzip_entry(path, "word/document.xml")?; Some(xml_text(&xml)) } "xlsx" => { // shared strings let mut out = String::new(); if let Some(ss) = unzip_entry(path, "xl/sharedStrings.xml") { out.push_str(&xml_text(&ss)); } // also try sheet1 if let Some(sh) = unzip_entry(path, "xl/worksheets/sheet1.xml") { out.push(' '); out.push_str(&xml_text(&sh)); } if out.trim().is_empty() { None } else { Some(out) } } "pptx" => { // extract all slides via listing let listing = run_cmd("unzip", &["-Z1", path.to_str()?])?; let mut out = String::new(); for line in listing.lines() { if line.starts_with("ppt/slides/slide") && line.ends_with(".xml") { if let Some(xml) = unzip_entry(path, line) { out.push_str(&xml_text(&xml)); out.push('\n'); } } } if out.trim().is_empty() { None } else { Some(out) } } "odt" | "ods" | "odp" => { let xml = unzip_entry(path, "content.xml")?; Some(xml_text(&xml)) } _ => None, } } fn extract_pdf(path: &Path) -> Option { run_cmd("pdftotext", &["-layout", path.to_str()?, "-"]) } fn extract_sqlite(path: &Path) -> Option { // tables let tables_out = run_cmd("sqlite3", &[path.to_str()?, ".tables"])?; let mut out = String::new(); out.push_str("TABLES: "); out.push_str(tables_out.trim()); out.push('\n'); for table in tables_out.split_whitespace() { // sanitize table name (alphanumeric/underscore) if !table.chars().all(|c| c.is_alphanumeric() || c == '_') { continue; } let q = format!("SELECT * FROM {} LIMIT 30;", table); if let Some(rows) = run_cmd("sqlite3", &[path.to_str()?, &q]) { out.push_str(&format!("\n-- {} --\n", table)); out.push_str(&rows); } } Some(out) } fn extract_archive(path: &Path) -> Option { // list contents only if let Some(s) = run_cmd("unzip", &["-l", path.to_str()?]) { return Some(s); } if let Some(s) = run_cmd("7z", &["l", path.to_str()?]) { return Some(s); } None } fn extract_image(path: &Path, ocr: bool) -> Option { if !ocr { return None; } run_cmd("tesseract", &[path.to_str()?, "stdout"]) } pub fn extract(path: &Path, cfg: &Config) -> Option { let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let res = match ext.as_str() { "pdf" => extract_pdf(path), "docx" | "xlsx" | "pptx" | "odt" | "ods" | "odp" => extract_office(path, &ext), "db" | "sqlite" | "sqlite3" => extract_sqlite(path), "zip" | "tar" | "gz" | "7z" | "rar" => extract_archive(path), "png" | "jpg" | "jpeg" | "tif" | "tiff" | "bmp" | "webp" => extract_image(path, cfg.ocr), _ => { // try sqlite if file smells like sqlite if let Ok(bytes) = std::fs::read(path) { if bytes.starts_with(b"SQLite format 3") { return extract_sqlite(path); } } read_text_file(path, 200_000) } }; // truncate res.map(|mut s| { if s.chars().count() > 200_000 { s = s.chars().take(200_000).collect(); s.push_str("\n...[truncated]..."); } s }) }