use std::fs;
use std::path::Path;
use blake3;
use chrono::{DateTime, Local, NaiveDateTime};
use serde::{Deserialize, Serialize};
use sled::Db;
use walkdir::WalkDir;
use crate::colours;
use crate::config::Config;
use crate::db;
use crate::error::AppError;
use crate::ocr;
use crate::search;
#[derive(Serialize, Deserialize, Debug)]
pub struct ShotRecord {
pub path: String,
pub content: String,
pub created_at: String,
}
pub struct IngestReport {
pub found: usize,
pub new: usize,
pub skipped: usize,
pub errors: usize,
}
pub fn run(
config: &Config,
db: &Db,
index: &tantivy::Index,
force: bool,
) -> Result<IngestReport, AppError> {
let screenshots_dir = &config.paths.screenshots;
if !screenshots_dir.exists() {
return Err(AppError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Screenshots directory does not exist: {}",
screenshots_dir.display()
),
)));
}
colours::info(&format!(
"Scanning {} for PNG files…",
screenshots_dir.display()
));
let mut tantivy_writer = search::writer(index).map_err(|e| AppError::Search(e.to_string()))?;
let mut report = IngestReport {
found: 0,
new: 0,
skipped: 0,
errors: 0,
};
for entry in WalkDir::new(screenshots_dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !is_png(path) {
continue;
}
report.found += 1;
let hash = match hash_file(path) {
Ok(h) => h,
Err(e) => {
colours::warn(&format!(" ✗ Failed to hash {}: {}", path.display(), e));
report.errors += 1;
continue;
}
};
if !force {
match db::key_exists(db, &hash) {
Ok(true) => {
report.skipped += 1;
continue;
}
Ok(false) => { }
Err(e) => {
colours::warn(&format!(
" ✗ DB lookup failed for {}: {}",
path.display(),
e
));
report.errors += 1;
continue;
}
}
}
let path_str = path.to_string_lossy().to_string();
let content = match ocr::extract_text(&path_str, &config.ocr.language) {
Ok(text) => text,
Err(e) => {
colours::warn(&format!(" ✗ OCR failed for {}: {}", path.display(), e));
report.errors += 1;
continue;
}
};
let date_str = screenshot_date(path).unwrap_or_else(|| "unknown date".into());
let record = ShotRecord {
path: path_str.clone(),
content: content.clone(),
created_at: date_str.clone(),
};
let json = serde_json::to_vec(&record)
.map_err(|e| AppError::Database(format!("Failed to serialize record: {}", e)))?;
if let Err(e) = db.insert(hash.as_bytes(), json) {
colours::warn(&format!(
" ✗ DB insert failed for {}: {}",
path.display(),
e
));
report.errors += 1;
continue;
}
if let Err(e) =
search::index_document(&tantivy_writer, &hash, &path_str, &content, &date_str)
{
colours::warn(&format!(
" ✗ Search index failed for {}: {}",
path.display(),
e
));
}
let snippet = ocr::truncate(&content, 60);
colours::success(&format!(
" ✔ {} ({}) — \"{}\"",
path.display(),
date_str,
snippet,
));
report.new += 1;
}
tantivy_writer
.commit()
.map_err(|e| AppError::Search(e.to_string()))?;
Ok(report)
}
pub fn process_single_file(
path: &Path,
config: &Config,
db: &Db,
tantivy_writer: &tantivy::IndexWriter,
) -> Result<(), AppError> {
if !is_png(path) {
return Ok(());
}
let hash = hash_file(path)?;
if db::key_exists(db, &hash)? {
colours::info(&format!(" ⏭ Already indexed: {}", path.display()));
return Ok(());
}
let path_str = path.to_string_lossy().to_string();
let content = ocr::extract_text(&path_str, &config.ocr.language)
.map_err(|e| AppError::Ocr(format!("{}: {}", path.display(), e)))?;
let date_str = screenshot_date(path).unwrap_or_else(|| "unknown date".into());
let record = ShotRecord {
path: path_str.clone(),
content: content.clone(),
created_at: date_str.clone(),
};
let json = serde_json::to_vec(&record)
.map_err(|e| AppError::Database(format!("Failed to serialize: {}", e)))?;
db.insert(hash.as_bytes(), json)?;
search::index_document(tantivy_writer, &hash, &path_str, &content, &date_str)?;
let snippet = ocr::truncate(&content, 60);
colours::success(&format!(
" ✔ {} ({}) — \"{}\"",
path.display(),
date_str,
snippet,
));
Ok(())
}
pub fn is_png(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("png"))
.unwrap_or(false)
}
pub fn hash_file(path: &Path) -> Result<String, AppError> {
let bytes = fs::read(path)?;
let hash = blake3::hash(&bytes);
Ok(hash.to_hex().to_string())
}
pub fn screenshot_date(path: &Path) -> Option<String> {
if let Some(dt) = parse_macos_screenshot_name(path) {
return Some(dt.format("%Y-%m-%d %H:%M").to_string());
}
let meta = fs::metadata(path).ok()?;
let mtime = meta.modified().ok()?;
let dt: DateTime<Local> = mtime.into();
Some(dt.format("%Y-%m-%d %H:%M").to_string())
}
fn parse_macos_screenshot_name(path: &Path) -> Option<NaiveDateTime> {
let stem = path.file_stem()?.to_str()?;
let rest = stem.strip_prefix("Screenshot ")?;
let parts: Vec<&str> = rest.splitn(2, " at ").collect();
if parts.len() != 2 {
return None;
}
let combined = format!("{} {}", parts[0], parts[1].replace('.', ":"));
NaiveDateTime::parse_from_str(&combined, "%Y-%m-%d %H:%M:%S").ok()
}