1#![allow(
8 clippy::cast_possible_truncation,
9 clippy::cast_possible_wrap,
10 clippy::cast_precision_loss,
11 clippy::cast_sign_loss
12)]
13use std::fmt::Write as _;
14use std::path::Path;
15
16use crate::db::FileRow;
17
18use anyhow::{Context, Result};
19use sha2::{Digest, Sha256};
20
21use super::App;
22
23#[derive(Clone)]
25pub enum OcrUpdate {
26 Stage(String),
29 Progress(usize, usize, usize),
31 Done(std::result::Result<(String, Vec<(usize, String)>), String>),
34}
35
36#[derive(Clone)]
38pub enum OcrBackend {
39 Router(crate::provider::openrouter::OpenRouter, String),
41 Ollama(reqwest::Client, String),
44}
45
46impl OcrBackend {
47 async fn transcribe(&self, png: &[u8]) -> anyhow::Result<String> {
48 self.transcribe_image(png, "image/png").await
49 }
50
51 async fn describe(&self, bytes: &[u8], mime: &str) -> anyhow::Result<String> {
54 match self {
55 Self::Router(provider, model) => {
56 use base64::Engine;
57 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
58 let url = format!("data:{mime};base64,{b64}");
59 provider.describe_image(model, &url).await
60 }
61 Self::Ollama(client, model) => {
62 use base64::Engine;
63 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
64 let resp = client
65 .post("http://127.0.0.1:11434/api/generate")
66 .timeout(std::time::Duration::from_mins(10))
67 .json(&serde_json::json!({
68 "model": model,
69 "prompt": "Describe this image so another AI model can reason about \
70 it without seeing it. Cover: what it is (screenshot, chart, \
71 photo, diagram…), overall layout and structure, the key \
72 entities and how they relate, ALL visible text verbatim, \
73 and any notable visual details. Be thorough but do not \
74 speculate beyond what is visible.",
75 "images": [b64],
76 "stream": false,
77 "options": { "num_ctx": 8192 },
78 }))
79 .send()
80 .await
81 .map_err(|e| {
82 if e.is_timeout() {
83 anyhow::anyhow!("timeout after 600s")
84 } else if e.is_connect() {
85 anyhow::anyhow!("cannot reach ollama — is it running?")
86 } else {
87 e.into()
88 }
89 })?;
90 if resp.status().as_u16() == 404 {
91 anyhow::bail!("model '{model}' not pulled");
92 }
93 let v = resp.error_for_status()?.json::<serde_json::Value>().await?;
94 Ok(v.get("response")
95 .and_then(|r| r.as_str())
96 .unwrap_or("")
97 .to_string())
98 }
99 }
100 }
101
102 async fn transcribe_image(&self, bytes: &[u8], mime: &str) -> anyhow::Result<String> {
105 match self {
106 Self::Router(provider, model) => {
107 use base64::Engine;
108 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
109 let url = format!("data:{mime};base64,{b64}");
110 provider.ocr_page(model, &url).await
111 }
112 Self::Ollama(client, model) => {
113 use base64::Engine;
114 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
115 let resp = client
116 .post("http://127.0.0.1:11434/api/generate")
117 .timeout(std::time::Duration::from_mins(10))
118 .json(&ollama_ocr_body(model, &b64))
119 .send()
120 .await
121 .map_err(|e| {
122 if e.is_timeout() {
123 anyhow::anyhow!("timeout after 600s")
124 } else if e.is_connect() {
125 anyhow::anyhow!(
126 "cannot reach ollama at 127.0.0.1:11434 — is it running? (systemctl start ollama)"
127 )
128 } else {
129 e.into()
130 }
131 })?;
132 if resp.status().as_u16() == 404 {
133 anyhow::bail!(
134 "model '{model}' not pulled — cycle OCR engine to 'local' in /config"
135 );
136 }
137 let v = resp.error_for_status()?.json::<serde_json::Value>().await?;
138 Ok(v.get("response")
139 .and_then(|r| r.as_str())
140 .unwrap_or("")
141 .to_string())
142 }
143 }
144 }
145}
146
147fn is_uuid_like(stem: &str) -> bool {
151 let hex = |s: &str| s.chars().all(|c| c.is_ascii_hexdigit());
152 let parts: Vec<&str> = stem.split('-').collect();
153 parts.len() == 5
154 && parts[0].len() == 8
155 && hex(parts[0])
156 && parts[1].len() == 4
157 && hex(parts[1])
158 && parts[2].len() == 4
159 && hex(parts[2])
160 && parts[3].len() == 4
161 && hex(parts[3])
162 && parts[4].len() == 12
163 && hex(parts[4])
164}
165
166fn clip_err(e: &str) -> String {
167 let mut s: String = e.chars().take(90).collect();
168 if s.len() < e.len() {
169 s.push('…');
170 }
171 s
172}
173
174fn ollama_ocr_body(model: &str, png_b64: &str) -> serde_json::Value {
177 serde_json::json!({
178 "model": model,
179 "prompt": crate::provider::openrouter::OCR_PROMPT,
180 "images": [png_b64],
181 "stream": false,
182 "options": { "num_ctx": 8192 },
185 })
186}
187
188async fn ocr_pdf_vlm(
195 backend: &OcrBackend,
196 path: &Path,
197 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
198 space_id: &str,
199 name: &str,
200 files_dir: &Path,
201) -> std::result::Result<(String, Vec<(usize, String)>), String> {
202 let stem = std::path::Path::new(name)
203 .file_stem()
204 .and_then(|s| s.to_str())
205 .unwrap_or(name);
206 let page_dir = files_dir.join(stem);
207 if let Err(e) = std::fs::create_dir_all(&page_dir) {
208 return Err(format!("error: ocr: {e}"));
209 }
210 ocr_pdf_vlm_in(backend, path, &page_dir, tx, space_id, name).await
211}
212
213async fn ocr_pdf_vlm_in(
214 backend: &OcrBackend,
215 path: &Path,
216 page_dir: &Path,
217 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
218 space_id: &str,
219 name: &str,
220) -> std::result::Result<(String, Vec<(usize, String)>), String> {
221 let _ = tx.send((
222 space_id.to_string(),
223 name.to_string(),
224 OcrUpdate::Stage("rendering pages (300 dpi)…".to_string()),
225 ));
226 let (pdf, dir) = (path.to_path_buf(), page_dir.to_path_buf());
227 let pages = tokio::task::spawn_blocking(move || {
228 crate::extract::render_pdf_pages("pdftoppm", &pdf, &dir, 300, false)
229 })
230 .await
231 .map_err(|e| format!("error: ocr: {e}"))?
232 .map_err(|e| match e {
233 crate::extract::OcrError::MissingTools => {
234 "scanned pdf — install poppler (pdftoppm) for ocr".to_string()
235 }
236 crate::extract::OcrError::Failed(m) => format!("error: ocr: {m}"),
237 })?;
238
239 let total = pages.len();
240 let _ = tx.send((
243 space_id.to_string(),
244 name.to_string(),
245 OcrUpdate::Progress(0, total, 0),
246 ));
247 let mut results: Vec<std::result::Result<String, String>> =
248 vec![Err("not transcribed".to_string()); total];
249 let mut set = tokio::task::JoinSet::new();
250 let spawn_page =
251 |set: &mut tokio::task::JoinSet<(usize, std::result::Result<String, String>)>, i: usize| {
252 let (backend, png) = (backend.clone(), pages[i].clone());
253 set.spawn(async move {
254 let Ok(bytes) = std::fs::read(&png) else {
255 return (i, Err("page image unreadable".to_string()));
256 };
257 let mut last = String::new();
258 for _ in 0..2 {
259 match backend.transcribe(&bytes).await {
260 Ok(text) => return (i, Ok(text)),
261 Err(e) => last = e.to_string(),
262 }
263 }
264 (i, Err(last))
265 });
266 };
267
268 let window = (16_usize).min(total);
272 let mut next = 0;
273 while next < window {
274 spawn_page(&mut set, next);
275 next += 1;
276 }
277 let mut done = 0;
278 let mut failed = 0;
279 while let Some(joined) = set.join_next().await {
280 let (i, r) = joined.unwrap_or_else(|_| (usize::MAX, Err("page task panicked".to_string())));
281 if r.is_err() {
282 failed += 1;
283 }
284 if let Some(slot) = results.get_mut(i) {
285 *slot = r;
286 }
287 done += 1;
288 let _ = tx.send((
289 space_id.to_string(),
290 name.to_string(),
291 OcrUpdate::Progress(done, total, failed),
292 ));
293 if next < total {
294 spawn_page(&mut set, next);
295 next += 1;
296 }
297 }
298 let _ = std::fs::create_dir_all(page_dir);
300 for (i, p) in pages.iter().enumerate() {
301 let stable = page_dir.join(format!("page-{}.png", i + 1));
302 let _ = std::fs::rename(p, &stable);
303 }
304 let errors: Vec<(usize, String)> = results
305 .iter()
306 .enumerate()
307 .filter_map(|(i, r)| r.as_ref().err().map(|e| (i, e.clone())))
308 .collect();
309 Ok((crate::extract::join_pages(&results), errors))
310}
311
312async fn ocr_image_vlm(
316 backend: &OcrBackend,
317 path: &Path,
318 tx: &tokio::sync::mpsc::UnboundedSender<(String, String, OcrUpdate)>,
319 space_id: &str,
320 name: &str,
321) -> std::result::Result<(String, Vec<(usize, String)>), String> {
322 let _ = tx.send((
323 space_id.to_string(),
324 name.to_string(),
325 OcrUpdate::Stage("transcribing image…".to_string()),
326 ));
327 let Ok(bytes) = std::fs::read(path) else {
328 return Err(format!("cannot read {name}"));
329 };
330 let ext = path
331 .extension()
332 .and_then(|e| e.to_str())
333 .unwrap_or("")
334 .to_lowercase();
335 let mime = match ext.as_str() {
336 "jpg" | "jpeg" => "image/jpeg",
337 "gif" => "image/gif",
338 "webp" => "image/webp",
339 "bmp" => "image/bmp",
340 _ => "image/png",
341 };
342 let _ = tx.send((
343 space_id.to_string(),
344 name.to_string(),
345 OcrUpdate::Progress(0, 1, 0),
346 ));
347 match backend.describe(&bytes, mime).await {
348 Ok(text) => {
349 let _ = tx.send((
350 space_id.to_string(),
351 name.to_string(),
352 OcrUpdate::Progress(1, 1, 0),
353 ));
354 Ok((text, Vec::new()))
355 }
356 Err(e) => {
357 let err = e.to_string();
358 Err(format!("error: ocr: {err}"))
359 }
360 }
361}
362
363impl App {
364 #[allow(clippy::too_many_lines)]
371 pub fn rescan_files(&mut self) {
372 let dir = self.space.files_dir(&self.active_space.name);
373 let known = self
374 .db
375 .list_files(&self.active_space.id)
376 .unwrap_or_default();
377 let mut seen: Vec<String> = Vec::new();
378 let mut ocr_jobs: Vec<(String, String, std::path::PathBuf)> = Vec::new();
379
380 let entries = std::fs::read_dir(&dir)
381 .map(|rd| rd.flatten().collect::<Vec<_>>())
382 .unwrap_or_default();
383 for entry in entries {
384 let path = entry.path();
385 if !path.is_file() {
386 continue;
387 }
388 let name = entry.file_name().to_string_lossy().to_string();
389 seen.push(name.clone());
390 let mtime = entry
391 .metadata()
392 .ok()
393 .and_then(|m| m.modified().ok())
394 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
395 .map_or(0, |d| d.as_secs() as i64);
396 let disk_size = entry.metadata().map_or(0, |m| m.len() as i64);
397 let existing = known.iter().find(|f| f.name == name);
398 if let Some(f) = existing
401 && f.size == disk_size
402 && f.mtime == mtime
403 && mtime != 0
404 {
405 if f.status.starts_with("ocr") {
409 if self.ocr_rx.is_none() {
410 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
411 }
412 continue;
413 }
414 if self.db.file_has_chunks(&f.id).unwrap_or(false) {
415 continue;
416 }
417 }
418 let Ok(bytes) = std::fs::read(&path) else {
419 continue;
420 };
421 let hash = Sha256::digest(&bytes)
422 .iter()
423 .fold(String::new(), |mut h, b| {
424 let _ = write!(h, "{b:02x}");
425 h
426 });
427 if let Some(f) = existing.filter(|f| f.hash == hash)
428 && self.db.file_indexed(&f.id).unwrap_or(false)
429 && self.db.file_has_chunks(&f.id).unwrap_or(false)
430 {
431 let _ = self.db.set_file_mtime(&f.id, mtime);
434 if f.status.starts_with("ocr") && self.ocr_rx.is_none() {
435 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
436 }
437 continue;
438 }
439 let size = bytes.len() as i64;
443 let (status, chunks) = match crate::extract::extract_text(&path) {
444 Ok(text) if text.trim().is_empty() => {
445 let ext = std::path::Path::new(&name)
446 .extension()
447 .and_then(|e| e.to_str())
448 .unwrap_or("")
449 .to_lowercase();
450 if ext == "pdf" || crate::extract::is_image_ext(&ext) {
451 ocr_jobs.push((self.active_space.id.clone(), name.clone(), path.clone()));
452 ("ocr…".to_string(), Vec::new())
453 } else {
454 (
455 "no text (scanned?)".to_string(),
456 crate::extract::metadata_chunks(&name),
457 )
458 }
459 }
460 Ok(text) => {
461 let chunks = crate::extract::chunk_lines(&text);
462 if chunks.is_empty() {
463 (
464 "no text (scanned?)".to_string(),
465 crate::extract::metadata_chunks(&name),
466 )
467 } else {
468 ("ok".to_string(), chunks)
469 }
470 }
471 Err(e) => (
472 format!("error: {e}"),
473 crate::extract::metadata_chunks(&name),
474 ),
475 };
476 if let Ok(id) = self
477 .db
478 .upsert_file(&self.active_space.id, &name, &hash, size, &status)
479 {
480 let _ = self.db.set_file_chunks(&id, &chunks);
481 let _ = self.db.set_file_mtime(&id, mtime);
482 }
483 }
484 for gone in known.iter().filter(|f| !seen.contains(&f.name)) {
485 let _ = self.db.delete_file(&gone.id);
486 }
487 self.start_ocr(ocr_jobs);
488 self.start_embedding();
491 self.files_cache = self
492 .db
493 .list_files(&self.active_space.id)
494 .unwrap_or_default();
495 }
496
497 pub fn start_ocr(&mut self, jobs: Vec<(String, String, std::path::PathBuf)>) {
501 if jobs.is_empty() || self.ocr_rx.is_some() {
502 return;
503 }
504 let backend = self.ocr_backend();
505 let files_dir = self.space.files_dir(&self.active_space.name);
506 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
507 self.ocr_rx = Some(rx);
508 if let Some(backend) = backend {
509 tokio::spawn(async move {
510 for (space_id, name, path) in jobs {
511 let is_image = path
512 .extension()
513 .and_then(|e| e.to_str())
514 .is_some_and(crate::extract::is_image_ext);
515 let result = if is_image {
516 ocr_image_vlm(&backend, &path, &tx, &space_id, &name).await
517 } else {
518 ocr_pdf_vlm(&backend, &path, &tx, &space_id, &name, &files_dir).await
519 };
520 if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
521 return;
522 }
523 }
524 });
525 return;
526 }
527 tokio::task::spawn_blocking(move || {
528 for (space_id, name, path) in jobs {
529 let is_image = path
530 .extension()
531 .and_then(|e| e.to_str())
532 .is_some_and(crate::extract::is_image_ext);
533 if is_image {
534 let _ = tx.send((
537 space_id,
538 name,
539 OcrUpdate::Done(Err("no vlm backend for image ocr".to_string())),
540 ));
541 continue;
542 }
543 let progress_tx = tx.clone();
544 let (sid, fname) = (space_id.clone(), name.clone());
545 let progress = move |done: usize, total: usize| {
546 let _ = progress_tx.send((
547 sid.clone(),
548 fname.clone(),
549 OcrUpdate::Progress(done, total, 0),
550 ));
551 };
552 let result = match crate::extract::ocr_pdf(&path, &progress) {
553 Ok(text) => Ok((text, Vec::new())),
554 Err(crate::extract::OcrError::MissingTools) => {
555 Err("scanned pdf — install tesseract + poppler for ocr".to_string())
556 }
557 Err(crate::extract::OcrError::Failed(e)) => Err(format!("error: ocr: {e}")),
558 };
559 if tx.send((space_id, name, OcrUpdate::Done(result))).is_err() {
560 return;
561 }
562 }
563 });
564 }
565
566 pub fn ocr_backend(&self) -> Option<OcrBackend> {
569 if self.ocr_engine == "local" {
570 let model = self.local_ocr_model.trim();
571 let model = if model.is_empty() { "glm-ocr" } else { model };
572 return Some(OcrBackend::Ollama(
573 reqwest::Client::new(),
574 model.to_string(),
575 ));
576 }
577 if self.vlm_ocr_enabled() {
578 let model = self.ocr_model.trim().to_string();
579 return self
580 .resolve_model_backend(&model)
581 .map(|(p, raw_model)| OcrBackend::Router(p, raw_model));
582 }
583 None
584 }
585
586 pub fn ocr_local_install(&mut self, arg: &str) {
591 if self.ocr_pull_rx.is_some() {
592 self.push_status("an OCR model pull is already running".to_string());
593 return;
594 }
595 let model = if arg.is_empty() {
596 "glm-ocr".to_string()
597 } else {
598 arg.to_string()
599 };
600 self.local_ocr_model.clone_from(&model);
601 let _ = self.db.set_setting("local_ocr_model", &model);
602 #[cfg(test)]
606 {
607 self.ocr_engine = "local".to_string();
608 let _ = self.db.set_setting("ocr_engine", "local");
609 self.push_status(format!("(test) local OCR: {model}"));
610 }
611 #[cfg(not(test))]
612 {
613 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
614 self.ocr_pull_rx = Some(rx);
615 self.push_status(format!(
616 "pulling {model} via ollama… (keeps running in background)"
617 ));
618 tokio::spawn(async move {
619 let result = match tokio::process::Command::new("ollama")
620 .args(["pull", &model])
621 .output()
622 .await
623 {
624 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
625 Err("ollama not installed — get it from https://ollama.com (pacman -S ollama), then retry".to_string())
626 }
627 Err(e) => Err(format!("ollama pull failed: {e}")),
628 Ok(out) if !out.status.success() => {
629 let err = String::from_utf8_lossy(&out.stderr);
630 let hint = if err.contains("could not connect") || err.contains("connection refused") {
631 " — is the ollama server running? (systemctl start ollama, or `ollama serve`)"
632 } else {
633 ""
634 };
635 Err(format!("ollama pull failed: {}{hint}", err.trim()))
636 }
637 Ok(_) => Ok(model),
638 };
639 let _ = tx.send(result);
640 });
641 }
642 }
643
644 pub fn on_ocr_pull(&mut self, r: Option<Result<String, String>>) {
646 let Some(result) = r else {
647 self.ocr_pull_rx = None;
648 return;
649 };
650 self.ocr_pull_rx = None;
651 match result {
652 Ok(model) => {
653 self.ocr_engine = "local".to_string();
654 let _ = self.db.set_setting("ocr_engine", "local");
655 self.push_status(format!(
656 "local OCR ready: {model} via ollama — Ctrl+O a file in /files to re-run it"
657 ));
658 }
659 Err(e) => self.push_status(e),
660 }
661 }
662
663 pub fn reextract_file(&mut self, name: &str) {
667 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
668 return;
669 };
670 let _ = self.db.set_file_chunks(&f.id, &[]);
671 let _ = self
674 .db
675 .upsert_file(&self.active_space.id, &f.name, "", 0, "re-extracting");
676 self.push_status(format!("re-extracting: {}", f.name));
677 self.rescan_files();
678 }
679
680 pub fn reocr_file(&mut self, name: &str) {
685 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
686 return;
687 };
688 let ext = std::path::Path::new(&f.name)
689 .extension()
690 .and_then(|e| e.to_str())
691 .unwrap_or("")
692 .to_lowercase();
693 if ext != "pdf" && !crate::extract::is_image_ext(&ext) {
694 self.push_status(format!("only PDFs and images support OCR: {}", f.name));
695 return;
696 }
697 let path = self.space.files_dir(&self.active_space.name).join(&f.name);
698 self.ocr_rx = None;
700 let _ = self.db.set_file_status(&f.id, "ocr…");
701 self.start_ocr(vec![(self.active_space.id.clone(), f.name.clone(), path)]);
702 self.files_cache = self
703 .db
704 .list_files(&self.active_space.id)
705 .unwrap_or_default();
706 self.push_status(format!("ocr queued: {}", f.name));
707 }
708
709 pub fn start_embedding(&mut self) {
715 if self.embed_rx.is_some() {
716 return;
717 }
718 let model = self.embedding_model.trim().to_string();
719 if model.is_empty() {
720 return;
721 }
722 let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
723 return;
724 };
725 let space_id = self.active_space.id.clone();
726 let Ok(missing) = self.db.files_missing_embeddings(&space_id) else {
727 return;
728 };
729 let Some(file) = self.db.list_files(&space_id).ok().and_then(|files| {
730 files.into_iter().find(|file| {
731 missing.iter().any(|id| id == &file.id) && !file.status.starts_with("ocr")
732 })
733 }) else {
734 return;
735 };
736 let file_id = file.id.clone();
741 let Ok(mut chunks) = self.db.file_chunk_texts(&file_id) else {
742 return;
743 };
744 if chunks.is_empty() {
745 if self
746 .db
747 .set_file_chunks(&file_id, &crate::extract::metadata_chunks(&file.name))
748 .is_err()
749 {
750 return;
751 }
752 chunks = match self.db.file_chunk_texts(&file_id) {
753 Ok(chunks) => chunks,
754 Err(_) => return,
755 };
756 }
757 let Ok(handle) = tokio::runtime::Handle::try_current() else {
760 return;
761 };
762 let show_embedding_status = matches!(file.status.as_str(), "ok" | "embedding…");
766 if show_embedding_status {
767 let _ = self.db.set_file_status(&file_id, "embedding…");
768 }
769 if space_id == self.active_space.id {
770 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
771 }
772 let file_name = file.name;
773 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
774 self.embed_rx = Some(rx);
775 handle.spawn(async move {
776 let mut out: Vec<(i64, Vec<f32>)> = Vec::with_capacity(chunks.len());
777 let mut err = None;
778 for batch in chunks.chunks(64) {
779 let inputs: Vec<String> = batch
780 .iter()
781 .map(|(_, text)| format!("file: {file_name}\n{text}"))
782 .collect();
783 match provider.embed(&raw_model, inputs).await {
784 Ok(vecs) if vecs.len() == batch.len() => out.extend(
785 batch
786 .iter()
787 .zip(vecs)
788 .map(|((seq, _), vector)| (*seq, vector)),
789 ),
790 Ok(vecs) => {
791 err = Some(format!(
792 "embedding returned {} vectors for {} chunks",
793 vecs.len(),
794 batch.len()
795 ));
796 break;
797 }
798 Err(e) => {
799 err = Some(e.to_string());
800 break;
801 }
802 }
803 }
804 let result = match err {
805 Some(e) => Err(e),
806 None => Ok(out),
807 };
808 let _ = tx.send((space_id, file_id, result));
809 });
810 }
811
812 pub fn on_embed_done(&mut self, r: Option<crate::app::EmbedMsg>) {
817 let Some((space_id, file_id, result)) = r else {
818 self.embed_rx = None;
819 return;
820 };
821 self.embed_rx = None;
822 let restore_status = self
823 .db
824 .list_files(&space_id)
825 .ok()
826 .and_then(|files| files.into_iter().find(|file| file.id == file_id))
827 .is_some_and(|file| file.status == "embedding…");
828 match result {
829 Ok(vecs) => {
830 let _ = self.db.set_chunk_embeddings(&file_id, &vecs);
831 if restore_status {
832 let _ = self.db.set_file_status(&file_id, "ok");
833 }
834 self.start_embedding();
835 }
836 Err(e) => {
837 if restore_status {
838 let _ = self.db.set_file_status(&file_id, "ok");
839 }
840 self.push_status(format!("embedding failed: {e}"));
841 }
842 }
843 if space_id == self.active_space.id {
844 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
845 }
846 }
847
848 #[allow(clippy::too_many_lines)]
851 pub fn on_ocr_done(&mut self, r: Option<(String, String, OcrUpdate)>) {
852 let Some((space_id, name, update)) = r else {
853 self.ocr_rx = None;
854 self.rescan_files();
858 return;
859 };
860 let Ok(files) = self.db.list_files(&space_id) else {
861 return;
862 };
863 let Some(f) = files.iter().find(|f| f.name == name) else {
864 return; };
866 if !f.status.starts_with("ocr") {
867 return; }
869 let completed = matches!(update, OcrUpdate::Done(_));
870 match update {
871 OcrUpdate::Stage(s) => {
872 let _ = self.db.set_file_status(&f.id, &format!("ocr: {s}"));
874 if space_id == self.active_space.id {
875 self.push_status(format!("ocr {name}: {s}"));
876 }
877 }
878 OcrUpdate::Progress(done, total, failed) => {
879 let tail = if failed > 0 {
880 format!(" ({failed} failed)")
881 } else {
882 String::new()
883 };
884 let _ = self
885 .db
886 .set_file_status(&f.id, &format!("ocr {done}/{total}{tail}"));
887 if space_id == self.active_space.id {
888 self.push_status(format!("ocr {name}: {done}/{total} pages{tail}"));
889 }
890 }
891 OcrUpdate::Done(Ok((text, errors))) if text.trim().is_empty() => {
892 let _ = self
895 .db
896 .set_file_chunks(&f.id, &crate::extract::metadata_chunks(&name));
897 let status = match errors.first() {
899 Some((i, e)) => {
900 format!("all pages failed (p{}: {})", i + 1, clip_err(e))
901 }
902 None => "no text (ocr found nothing)".to_string(),
903 };
904 let _ = self.db.set_file_status(&f.id, &status);
905 if space_id == self.active_space.id {
906 self.push_status(format!("ocr {name}: {status}"));
907 }
908 }
909 OcrUpdate::Done(Ok((text, errors))) => {
910 let chunks = crate::extract::chunk_lines(&text);
911 let chunks = if chunks.is_empty() {
912 crate::extract::metadata_chunks(&name)
913 } else {
914 chunks
915 };
916 let _ = self.db.set_file_chunks(&f.id, &chunks);
917 let status = match errors.first() {
918 None => "ok".to_string(),
919 Some((i, e)) => format!(
920 "ok — {} page{} failed (p{}: {})",
921 errors.len(),
922 if errors.len() == 1 { "" } else { "s" },
923 i + 1,
924 clip_err(e),
925 ),
926 };
927 let _ = self.db.set_file_status(&f.id, &status);
928
929 if let Some(new_name) = Self::descriptive_paste_name(f, &text) {
931 let dir = self.space.files_dir(&self.active_space.name);
932 let old_path = dir.join(&f.name);
933 let new_path = dir.join(&new_name);
934 if old_path.exists() && std::fs::rename(&old_path, &new_path).is_ok() {
935 let _ = self.db.rename_file(&f.id, &new_name);
936 let _ = self
937 .db
938 .replace_file_ref_in_messages(&space_id, &f.name, &new_name);
939 if space_id == self.active_space.id {
940 self.push_status(format!("ocr done: {new_name}"));
941 }
942 } else if space_id == self.active_space.id {
944 self.push_status(format!("ocr done: {name}"));
945 }
946 } else if space_id == self.active_space.id {
947 self.push_status(format!("ocr done: {name}"));
948 }
949 }
950 OcrUpdate::Done(Err(msg)) => {
951 if self
955 .db
956 .file_chunk_texts(&f.id)
957 .unwrap_or_default()
958 .is_empty()
959 {
960 let _ = self
961 .db
962 .set_file_chunks(&f.id, &crate::extract::metadata_chunks(&name));
963 }
964 let _ = self.db.set_file_status(&f.id, &msg);
965 if space_id == self.active_space.id {
966 self.push_status(format!("ocr {name}: {msg}"));
967 }
968 }
969 }
970 if completed && space_id == self.active_space.id {
971 self.start_embedding();
972 }
973 if space_id == self.active_space.id {
974 self.files_cache = self.db.list_files(&space_id).unwrap_or_default();
975 }
976 }
977
978 pub fn import_file(&mut self, path: &Path) -> Result<String> {
982 let name = path
983 .file_name()
984 .map(|n| n.to_string_lossy().to_string())
985 .filter(|n| !n.is_empty())
986 .context("path has no file name")?;
987 let dir = self.space.files_dir(&self.active_space.name);
988 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
989 std::fs::copy(path, dir.join(&name))
990 .with_context(|| format!("copying {} into the space", path.display()))?;
991 self.rescan_files();
992 Ok(name)
993 }
994
995 pub fn delete_file(&mut self, name: &str) -> Result<()> {
998 let Some(f) = self.files_cache.iter().find(|f| f.name == name).cloned() else {
999 return Ok(());
1000 };
1001 let disk = self.space.files_dir(&self.active_space.name).join(&f.name);
1002 if disk.exists() {
1003 std::fs::remove_file(&disk).with_context(|| format!("removing {}", disk.display()))?;
1004 }
1005 self.db.delete_file(&f.id)?;
1006 self.push_status(format!("removed {}", f.name));
1007 self.rescan_files();
1008 Ok(())
1009 }
1010
1011 pub fn rename_file(&mut self, name: &str, new: &str) -> Result<()> {
1016 if new.is_empty() || new == name {
1017 return Ok(());
1018 }
1019 if new.contains(['/', '\\']) || new == "." || new == ".." {
1020 anyhow::bail!("invalid name: {new}");
1021 }
1022 let dir = self.space.files_dir(&self.active_space.name);
1023 if dir.join(new).exists() {
1024 anyhow::bail!("{new} already exists");
1025 }
1026 std::fs::rename(dir.join(name), dir.join(new))
1027 .with_context(|| format!("renaming {name} to {new}"))?;
1028 self.rescan_files();
1029 self.push_status(format!("renamed {name} to {new}"));
1030 Ok(())
1031 }
1032
1033 fn descriptive_paste_name(f: &FileRow, ocr_text: &str) -> Option<String> {
1036 let stem = std::path::Path::new(&f.name).file_stem()?.to_str()?;
1037 let ext = std::path::Path::new(&f.name).extension()?.to_str()?;
1038 if !is_uuid_like(stem) {
1040 return None;
1041 }
1042 let slug = Self::slug_from_ocr(ocr_text)?;
1043 Some(format!("{stem}-{slug}.{ext}"))
1044 }
1045
1046 fn slug_from_ocr(text: &str) -> Option<String> {
1049 let words: Vec<&str> = text
1050 .split_whitespace()
1051 .filter(|w| {
1052 let w = w.trim_matches(|c: char| !c.is_alphanumeric());
1053 w.len() > 2 && w.chars().any(char::is_alphanumeric)
1054 })
1055 .collect();
1056 if words.is_empty() {
1057 return None;
1058 }
1059 let slug: String = words
1060 .iter()
1061 .take(5)
1062 .map(|w| {
1063 w.trim_matches(|c: char| !c.is_alphanumeric())
1064 .to_lowercase()
1065 })
1066 .collect::<Vec<_>>()
1067 .join("_");
1068 if slug.is_empty() { None } else { Some(slug) }
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use crate::db::Db;
1076 use crate::space::Space;
1077
1078 fn test_app() -> App {
1079 let db = Db::open_in_memory().unwrap();
1080 let root = std::env::temp_dir().join(format!("nexus-files-test-{}", uuid::Uuid::new_v4()));
1081 std::fs::create_dir_all(root.join("spaces")).unwrap();
1082 let space = Space { root };
1083 App::new(db, Some("k"), space)
1084 }
1085
1086 #[tokio::test]
1087 async fn embedder_queue_backfills_chains_and_stops_on_error() {
1088 let mut a = test_app();
1089 let space = a.active_space.id.clone();
1090 let id = a.db.upsert_file(&space, "b.txt", "h", 1, "ok").unwrap();
1091 a.db.set_file_chunks(&id, &[("l".into(), "text".into())])
1092 .unwrap();
1093
1094 let saved = a.backends.clone();
1096 a.backends = crate::app::Backends::default();
1097 a.start_embedding();
1098 assert!(a.embed_rx.is_none());
1099 a.backends = saved;
1100
1101 let m = std::mem::take(&mut a.embedding_model);
1103 a.start_embedding();
1104 assert!(a.embed_rx.is_none());
1105 a.embedding_model = m;
1106
1107 a.start_embedding();
1109 assert!(a.embed_rx.is_some());
1110 let files = a.db.list_files(&space).unwrap();
1111 assert!(
1112 files[0].status.starts_with("embedding"),
1113 "{}",
1114 files[0].status
1115 );
1116
1117 a.on_embed_done(Some((
1119 space.clone(),
1120 id.clone(),
1121 Ok(vec![(0, vec![1.0f32, 0.0])]),
1122 )));
1123 assert!(a.db.files_missing_embeddings(&space).unwrap().is_empty());
1124 assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1125
1126 a.db.set_file_chunks(&id, &[("l".into(), "new".into())])
1128 .unwrap();
1129 a.on_embed_done(Some((space.clone(), id.clone(), Err("offline".into()))));
1130 assert!(a.embed_rx.is_none());
1131 let (_, status) = a.drain_ui_events();
1132 assert!(status.contains("embedding failed"));
1133 assert_eq!(a.db.list_files(&space).unwrap()[0].status, "ok");
1134 }
1135
1136 #[test]
1137 fn import_copies_extracts_and_indexes() {
1138 let mut a = test_app();
1139 let src = std::env::temp_dir().join(format!("nexus-src-{}.md", uuid::Uuid::new_v4()));
1140 std::fs::write(&src, "# quarterly report\nrevenue up").unwrap();
1141
1142 let name = a.import_file(&src).unwrap();
1143 assert_eq!(name, src.file_name().unwrap().to_string_lossy());
1144 assert_eq!(a.files_cache.len(), 1);
1145 assert_eq!(a.files_cache[0].status, "ok");
1146 assert!(a.space.files_dir(&a.active_space.name).join(&name).exists());
1148 let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1150 .unwrap();
1151 assert_eq!(hits.len(), 1);
1152 }
1153
1154 #[test]
1155 fn generic_code_and_binary_files_get_index_chunks() {
1156 let mut a = test_app();
1157 let dir = a.space.files_dir(&a.active_space.name);
1158 std::fs::create_dir_all(&dir).unwrap();
1159 std::fs::write(dir.join("page.html"), "<main>searchable markup</main>").unwrap();
1160 std::fs::write(dir.join("job.py"), "def searchable_code():\n return 42").unwrap();
1161 std::fs::write(dir.join("asset.bin"), [0u8, 1, 2, 3]).unwrap();
1164
1165 a.rescan_files();
1166 for name in ["page.html", "job.py", "asset.bin"] {
1167 let file = a.files_cache.iter().find(|file| file.name == name).unwrap();
1168 assert!(a.db.file_has_chunks(&file.id).unwrap(), "{name}");
1169 }
1170 let hits =
1171 crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "searchable", 8)
1172 .unwrap();
1173 assert_eq!(hits.len(), 2, "code files should be FTS indexed");
1174 let binary_hits =
1175 crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "asset.bin", 8)
1176 .unwrap();
1177 assert_eq!(
1178 binary_hits.len(),
1179 1,
1180 "binary upload should be name-searchable"
1181 );
1182 }
1183
1184 #[test]
1185 fn cold_cache_after_restore_reindexes_from_disk() {
1186 let root = std::env::temp_dir().join(format!("nexus-cold-{}", uuid::Uuid::new_v4()));
1190 std::fs::create_dir_all(root.join("spaces")).unwrap();
1191 let space = Space { root };
1192 let db = Db::open(&space.db_path()).unwrap();
1193 let mut a = App::new(db, Some("k"), space);
1194
1195 let src = std::env::temp_dir().join(format!("nexus-cold-src-{}.md", uuid::Uuid::new_v4()));
1196 std::fs::write(&src, "# report\nresilience is a property").unwrap();
1197 let name = a.import_file(&src).unwrap();
1198 let id = a.files_cache[0].id.clone();
1199 assert!(a.db.file_indexed(&id).unwrap());
1200 assert!(
1201 crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1202 .unwrap()
1203 .is_some()
1204 );
1205 let root = a.space.root.clone();
1208 drop(a);
1209
1210 let cache_path = root.join("cache.db");
1212 assert!(cache_path.exists());
1213 std::fs::remove_file(&cache_path).unwrap();
1214
1215 let mut a = App::new(
1216 Db::open(&root.join("nexus.db")).unwrap(),
1217 Some("k"),
1218 Space { root },
1219 );
1220 assert!(!a.db.file_indexed(&id).unwrap());
1221
1222 a.rescan_files();
1225 assert!(a.db.file_indexed(&id).unwrap());
1226 assert_eq!(
1227 crate::db::file_text(a.db.conn_for_test(), &a.active_space.id, &name)
1228 .unwrap()
1229 .as_deref(),
1230 Some("# report\nresilience is a property")
1231 );
1232 assert_eq!(a.db.list_files(&a.active_space.id).unwrap()[0].status, "ok");
1233 }
1234
1235 #[test]
1236 fn ollama_ocr_body_uses_native_generate_shape() {
1237 let body = ollama_ocr_body("glm-ocr", "QUFB");
1238 assert_eq!(body["model"], "glm-ocr");
1239 assert_eq!(body["stream"], false);
1240 assert_eq!(body["images"][0], "QUFB"); assert!(body["prompt"].as_str().unwrap().contains("furigana"));
1242 assert!(
1243 body.get("messages").is_none(),
1244 "must not be OpenAI chat shape"
1245 );
1246 }
1247
1248 #[test]
1249 fn ocr_backend_routes_by_engine() {
1250 let mut a = test_app();
1251 assert!(matches!(a.ocr_backend(), Some(OcrBackend::Router(..))));
1253 a.ocr_engine = "local".to_string();
1255 a.local_ocr_model = String::new(); match a.ocr_backend() {
1257 Some(OcrBackend::Ollama(_, model)) => assert_eq!(model, "glm-ocr"),
1258 other => panic!("expected ollama backend, got {}", other.is_some()),
1259 }
1260 a.ocr_engine = "tesseract".to_string();
1262 assert!(a.ocr_backend().is_none());
1263 a.ocr_engine = "auto".to_string();
1265 a.backends = crate::app::Backends::default();
1266 assert!(a.ocr_backend().is_none());
1267 }
1268
1269 #[tokio::test]
1270 async fn ocr_pull_success_switches_engine_to_local() {
1271 let mut a = test_app();
1272 a.on_ocr_pull(Some(Ok("glm-ocr".to_string())));
1273 assert_eq!(a.ocr_engine, "local");
1274 let (_, status) = a.drain_ui_events();
1275 assert!(status.contains("local OCR ready"));
1276 a.on_ocr_pull(Some(Err("ollama not installed — get it".to_string())));
1277 assert_eq!(a.ocr_engine, "local"); let (_, status) = a.drain_ui_events();
1279 assert!(status.contains("ollama not installed"));
1280 }
1281
1282 #[test]
1283 fn ocr_statuses_surface_stages_failures_and_reasons() {
1284 let mut a = test_app();
1285 let space = a.active_space.id.clone();
1286 let id =
1287 a.db.upsert_file(&space, "scan.pdf", "h", 1, "ocr…")
1288 .unwrap();
1289
1290 a.on_ocr_done(Some((
1292 space.clone(),
1293 "scan.pdf".into(),
1294 OcrUpdate::Stage("rendering pages (300 dpi)…".into()),
1295 )));
1296 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1297 assert_eq!(status, "ocr: rendering pages (300 dpi)…");
1298
1299 a.on_ocr_done(Some((
1301 space.clone(),
1302 "scan.pdf".into(),
1303 OcrUpdate::Progress(5, 10, 2),
1304 )));
1305 assert_eq!(
1306 a.db.list_files(&space).unwrap()[0].status,
1307 "ocr 5/10 (2 failed)"
1308 );
1309 assert!(a.last_status().contains("5/10 pages (2 failed)"));
1310
1311 a.on_ocr_done(Some((
1313 space.clone(),
1314 "scan.pdf".into(),
1315 OcrUpdate::Done(Ok((
1316 "[page 1]\ntext".to_string(),
1317 vec![
1318 (2, "timeout after 600s".to_string()),
1319 (4, "boom".to_string()),
1320 ],
1321 ))),
1322 )));
1323 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1324 assert_eq!(status, "ok — 2 pages failed (p3: timeout after 600s)");
1325
1326 let _ = a.db.set_file_status(&id, "ocr…");
1328 a.on_ocr_done(Some((
1329 space.clone(),
1330 "scan.pdf".into(),
1331 OcrUpdate::Done(Ok((
1332 String::new(),
1333 vec![(0, "cannot reach ollama at 127.0.0.1:11434 — is it running? (systemctl start ollama)".to_string())],
1334 ))),
1335 )));
1336 let status = a.db.list_files(&space).unwrap()[0].status.clone();
1337 assert!(
1338 status.starts_with("all pages failed (p1: cannot reach ollama"),
1339 "{status}"
1340 );
1341 assert!(!status.starts_with("ocr"), "{status}");
1343 }
1344
1345 #[test]
1346 fn reextract_clears_stale_chunks_and_reindexes_from_disk() {
1347 let mut a = test_app();
1348 let dir = a.space.files_dir(&a.active_space.name);
1349 std::fs::create_dir_all(&dir).unwrap();
1350 std::fs::write(dir.join("doc.txt"), "real content on disk").unwrap();
1351 a.rescan_files();
1352 let id = a.files_cache[0].id.clone();
1353
1354 a.db.set_file_chunks(&id, &[("p1".into(), "garbage".into())])
1356 .unwrap();
1357
1358 a.reextract_file("doc.txt");
1359 assert!(
1360 a.last_status().contains("re-extracting"),
1361 "{}",
1362 a.last_status()
1363 );
1364 let texts = a.db.file_chunk_texts(&id).unwrap();
1365 assert_eq!(texts.len(), 1);
1366 assert!(texts[0].1.contains("real content"), "{texts:?}");
1367 assert_eq!(a.files_cache[0].status, "ok");
1368 }
1369
1370 #[test]
1371 fn rescan_picks_up_dropped_and_deleted_files() {
1372 let mut a = test_app();
1373 let dir = a.space.files_dir(&a.active_space.name);
1374 std::fs::create_dir_all(&dir).unwrap();
1375 std::fs::write(dir.join("dropped.txt"), "hello dropped").unwrap();
1376
1377 a.rescan_files();
1378 assert_eq!(a.files_cache.len(), 1);
1379 assert_eq!(a.files_cache[0].name, "dropped.txt");
1380
1381 std::fs::write(dir.join("dropped.txt"), "hello again").unwrap();
1383 a.rescan_files();
1384 assert_eq!(a.files_cache.len(), 1);
1385 std::fs::remove_file(dir.join("dropped.txt")).unwrap();
1386 a.rescan_files();
1387 assert!(a.files_cache.is_empty());
1388 }
1389
1390 #[test]
1391 fn empty_extraction_gets_no_text_status() {
1392 let mut a = test_app();
1393 let dir = a.space.files_dir(&a.active_space.name);
1394 std::fs::create_dir_all(&dir).unwrap();
1395 std::fs::write(dir.join("empty.txt"), " ").unwrap();
1396 a.rescan_files();
1397 assert_eq!(a.files_cache[0].status, "no text (scanned?)");
1398 }
1399
1400 #[test]
1401 fn rescan_skips_stat_unchanged_files_without_rehashing() {
1402 let mut a = test_app();
1403 let dir = a.space.files_dir(&a.active_space.name);
1404 std::fs::create_dir_all(&dir).unwrap();
1405 std::fs::write(dir.join("book.txt"), "big content").unwrap();
1406 a.rescan_files();
1407 let f = a.files_cache[0].clone();
1408 assert!(f.mtime > 0, "mtime recorded on index");
1409
1410 a.db.upsert_file(&a.active_space.id, "book.txt", "sentinel", f.size, "ok")
1413 .unwrap();
1414 a.rescan_files();
1415 assert_eq!(a.files_cache[0].hash, "sentinel");
1416
1417 std::fs::write(dir.join("book.txt"), "big content grew").unwrap();
1419 a.rescan_files();
1420 assert_ne!(a.files_cache[0].hash, "sentinel");
1421 assert_eq!(a.files_cache[0].status, "ok");
1422 }
1423
1424 #[test]
1425 fn rename_moves_disk_file_and_reindexes() {
1426 let mut a = test_app();
1427 let dir = a.space.files_dir(&a.active_space.name);
1428 std::fs::create_dir_all(&dir).unwrap();
1429 std::fs::write(dir.join("old.txt"), "searchable content").unwrap();
1430 std::fs::write(dir.join("taken.txt"), "x").unwrap();
1431 a.rescan_files();
1432
1433 assert!(a.rename_file("old.txt", "taken.txt").is_err());
1435 assert!(dir.join("old.txt").exists());
1436
1437 assert!(a.rename_file("old.txt", "../evil.txt").is_err());
1439
1440 a.rename_file("old.txt", "new.txt").unwrap();
1442 assert!(!dir.join("old.txt").exists());
1443 assert!(dir.join("new.txt").exists());
1444 assert!(a.files_cache.iter().any(|f| f.name == "new.txt"));
1445 }
1446
1447 #[test]
1448 fn delete_removes_disk_file_and_row() {
1449 let mut a = test_app();
1450 let dir = a.space.files_dir(&a.active_space.name);
1451 std::fs::create_dir_all(&dir).unwrap();
1452 std::fs::write(dir.join("gone.txt"), "bye").unwrap();
1453 a.rescan_files();
1454 a.delete_file("gone.txt").unwrap();
1455 assert!(a.files_cache.is_empty());
1456 assert!(!dir.join("gone.txt").exists());
1457 }
1458
1459 #[test]
1460 fn import_file_copies_and_indexes_typed_path() {
1461 let mut a = test_app();
1462 let src = std::env::temp_dir().join(format!("nexus-add-{}.txt", uuid::Uuid::new_v4()));
1463 std::fs::write(&src, "typed in").unwrap();
1464 let name = a.import_file(&src).unwrap();
1465 assert_eq!(a.files_cache.len(), 1);
1466 assert_eq!(a.files_cache[0].name, name);
1467
1468 assert!(
1470 a.import_file(std::path::Path::new("/definitely/not/a/file"))
1471 .is_err()
1472 );
1473 assert_eq!(a.files_cache.len(), 1);
1474 }
1475
1476 #[tokio::test]
1477 async fn rescan_marks_empty_pdf_ocr_and_spawns_batch() {
1478 let mut a = test_app();
1479 let dir = a.space.files_dir(&a.active_space.name);
1480 std::fs::create_dir_all(&dir).unwrap();
1481 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1482
1483 a.rescan_files();
1484 assert_eq!(a.files_cache[0].status, "ocr…");
1485 assert!(a.ocr_rx.is_some(), "an ocr batch should be in flight");
1486
1487 a.rescan_files();
1489 assert_eq!(a.files_cache[0].status, "ocr…");
1490 }
1491
1492 #[tokio::test]
1493 async fn rescan_requeues_stale_ocr_status_when_idle() {
1494 let mut a = test_app();
1495 let dir = a.space.files_dir(&a.active_space.name);
1496 std::fs::create_dir_all(&dir).unwrap();
1497 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1498 a.rescan_files();
1499
1500 a.ocr_rx = None;
1502 a.rescan_files();
1503 assert!(a.ocr_rx.is_some(), "stale ocr… should re-queue");
1504 }
1505
1506 #[test]
1507 fn on_ocr_done_ok_indexes_and_marks_ok() {
1508 let mut a = test_app();
1509 let id =
1510 a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1511 .unwrap();
1512 let _ = id;
1513 a.on_ocr_done(Some((
1514 a.active_space.id.clone(),
1515 "scan.pdf".to_string(),
1516 OcrUpdate::Done(Ok((
1517 "[page 1]\nquarterly revenue table".to_string(),
1518 Vec::new(),
1519 ))),
1520 )));
1521 assert_eq!(a.files_cache[0].status, "ok");
1522 let hits = crate::db::search_chunks(a.db.conn_for_test(), &a.active_space.id, "revenue", 8)
1523 .unwrap();
1524 assert_eq!(hits.len(), 1);
1525 }
1526
1527 #[test]
1528 fn on_ocr_progress_updates_status_and_status_line() {
1529 let mut a = test_app();
1530 a.db.upsert_file(&a.active_space.id, "scan.pdf", "h", 9, "ocr…")
1531 .unwrap();
1532 a.on_ocr_done(Some((
1533 a.active_space.id.clone(),
1534 "scan.pdf".to_string(),
1535 OcrUpdate::Progress(3, 10, 0),
1536 )));
1537 assert_eq!(a.files_cache[0].status, "ocr 3/10");
1538 assert!(a.last_status().contains("3/10"), "{}", a.last_status());
1539
1540 a.on_ocr_done(Some((
1542 a.active_space.id.clone(),
1543 "scan.pdf".to_string(),
1544 OcrUpdate::Done(Ok(("[page 1]\nfound".to_string(), Vec::new()))),
1545 )));
1546 assert_eq!(a.files_cache[0].status, "ok");
1547 }
1548
1549 #[test]
1550 fn on_ocr_done_empty_and_err_statuses() {
1551 let mut a = test_app();
1552 a.db.upsert_file(&a.active_space.id, "blank.pdf", "h1", 9, "ocr…")
1553 .unwrap();
1554 a.db.upsert_file(&a.active_space.id, "bad.pdf", "h2", 9, "ocr…")
1555 .unwrap();
1556
1557 a.on_ocr_done(Some((
1558 a.active_space.id.clone(),
1559 "blank.pdf".to_string(),
1560 OcrUpdate::Done(Ok((String::new(), Vec::new()))),
1561 )));
1562 a.on_ocr_done(Some((
1563 a.active_space.id.clone(),
1564 "bad.pdf".to_string(),
1565 OcrUpdate::Done(Err(
1566 "scanned pdf — install tesseract + poppler for ocr".to_string()
1567 )),
1568 )));
1569
1570 let by_name = |a: &App, n: &str| {
1571 a.files_cache
1572 .iter()
1573 .find(|f| f.name == n)
1574 .unwrap()
1575 .status
1576 .clone()
1577 };
1578 assert_eq!(by_name(&a, "blank.pdf"), "no text (ocr found nothing)");
1579 assert_eq!(
1580 by_name(&a, "bad.pdf"),
1581 "scanned pdf — install tesseract + poppler for ocr"
1582 );
1583 }
1584
1585 #[test]
1586 fn on_ocr_done_for_inactive_space_writes_db_but_not_cache() {
1587 let mut a = test_app();
1588 let other = a.db.create_space("other").unwrap();
1589 a.db.upsert_file(&other.id, "scan.pdf", "h", 9, "ocr…")
1590 .unwrap();
1591
1592 a.on_ocr_done(Some((
1593 other.id.clone(),
1594 "scan.pdf".to_string(),
1595 OcrUpdate::Done(Ok(("found text".to_string(), Vec::new()))),
1596 )));
1597
1598 assert!(
1599 a.files_cache.is_empty(),
1600 "active-space cache must not show other space's file"
1601 );
1602 let rows = a.db.list_files(&other.id).unwrap();
1603 assert_eq!(rows[0].status, "ok");
1604
1605 a.on_ocr_done(Some((
1607 other.id.clone(),
1608 "gone.pdf".to_string(),
1609 OcrUpdate::Done(Ok(("x".to_string(), Vec::new()))),
1610 )));
1611 }
1612
1613 #[tokio::test]
1614 async fn on_ocr_done_none_clears_channel_and_requeues_stragglers() {
1615 let mut a = test_app();
1616 let dir = a.space.files_dir(&a.active_space.name);
1617 std::fs::create_dir_all(&dir).unwrap();
1618 std::fs::write(dir.join("scan.pdf"), crate::extract::minimal_pdf(None)).unwrap();
1620 a.rescan_files();
1621 assert!(a.ocr_rx.is_some());
1622 a.on_ocr_done(None);
1624 assert!(a.ocr_rx.is_some(), "straggler should re-queue on batch end");
1625 }
1626}