From b274f0b7ef047eefc0c6d8e51d57933c9f89f5ad Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Tue, 3 Nov 2026 13:26:12 -0300 Subject: [PATCH] feat(explorer): list one folder level with recursive counts Co-Authored-By: Claude Opus 5.5 --- crates/guara-core/src/explorer.rs | 173 ++++++++++++++++++++++++++++++ crates/guara-core/src/service.rs | 18 ++++ crates/guara-core/src/types.rs | 37 +++++++ 3 files changed, 228 insertions(+) diff --git a/crates/guara-core/src/explorer.rs b/crates/guara-core/src/explorer.rs index 201f64a..9f2f9fe 100644 --- a/crates/guara-core/src/explorer.rs +++ b/crates/guara-core/src/explorer.rs @@ -170,6 +170,76 @@ pub fn item_json(f: &Found) -> Value { }) } +/// Folder a file belongs to for browsing: `None` for signature-carved files, +/// `""` for the volume root. +fn browse_dir(f: &Found) -> Option<&str> { + if f.origin == Origin::Carved { + return None; + } + Some(f.dir.as_deref().unwrap_or("").trim_matches('/')) +} + +/// One level of the folder tree: the direct subfolders of `path` (with file and +/// byte totals over everything below them) and the files directly inside it, +/// paginated. `path` is `""` for the root or `"@carved"` for files found by +/// signature, which have no folder. Filters in `q` apply; its `folder` is ignored. +pub fn browse(found: &[Found], path: &str, q: &Query) -> Value { + let path = path.trim_matches('/'); + let q = Query { + folder: None, + ..q.clone() + }; + let selected = q.select(found); + let mut folders: BTreeMap<&str, (u64, u64)> = BTreeMap::new(); + let mut files: Vec<&Found> = Vec::new(); + let mut carved = (0u64, 0u64); + for f in selected { + let Some(dir) = browse_dir(f) else { + if path == "@carved" { + files.push(f); + } else if path.is_empty() { + carved.0 += 1; + carved.1 += f.size; + } + continue; + }; + if dir == path { + files.push(f); + continue; + } + let rest = if path.is_empty() { + dir + } else if let Some(rest) = dir.strip_prefix(path).and_then(|r| r.strip_prefix('/')) { + rest + } else { + continue; + }; + let child = rest.split('/').next().unwrap_or(rest); + let child_path = &dir[..dir.len() - rest.len() + child.len()]; + let entry = folders.entry(child_path).or_default(); + entry.0 += 1; + entry.1 += f.size; + } + let mut out: Vec = folders + .iter() + .map(|(p, (n, bytes))| { + json!({ "name": p.rsplit('/').next().unwrap_or(p), "path": p, "files": n, "bytes": bytes }) + }) + .collect(); + if carved.0 > 0 { + out.push(json!({ "name": "Por assinatura", "path": "@carved", "files": carved.0, "bytes": carved.1 })); + } + sort_items(&mut files, Some(q.sort.as_deref().unwrap_or("name"))); + let total = files.len(); + let page: Vec = files + .into_iter() + .skip(q.offset) + .take(q.limit.clamp(1, 5000)) + .map(item_json) + .collect(); + json!({ "path": path, "folders": out, "files": page, "total_files": total }) +} + /// Facet counts for sidebar navigation. pub fn facets(found: &[Found]) -> Value { let mut cats: HashMap<&'static str, usize> = HashMap::new(); @@ -375,3 +445,106 @@ pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option { } Some(h) } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + vec![ + Found::test_file(1, "DCIM/100CANON", "a.jpg", 100), + Found::test_file(2, "DCIM", "b.jpg", 10), + Found::test_file(3, "", "x.txt", 5), + Found::test_carved(4, "c.jpg", 7), + Found::test_file(5, "DCIM/100CANON", "notes.txt", 1), + ] + } + + fn folders(v: &Value) -> Vec<(String, u64, u64)> { + v["folders"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["path"].as_str().unwrap().to_string(), + f["files"].as_u64().unwrap(), + f["bytes"].as_u64().unwrap(), + ) + }) + .collect() + } + + fn names(v: &Value) -> Vec { + v["files"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn browse_lists_direct_children() { + let v = browse(&sample(), "", &Query::default()); + assert_eq!( + folders(&v), + vec![("DCIM".to_string(), 3, 111), ("@carved".to_string(), 1, 7)] + ); + assert_eq!(v["folders"][1]["name"], "Por assinatura"); + assert_eq!(names(&v), vec!["x.txt"]); + assert_eq!(v["total_files"], 1); + } + + #[test] + fn browse_inside_folder() { + let v = browse(&sample(), "DCIM", &Query::default()); + assert_eq!(folders(&v), vec![("DCIM/100CANON".to_string(), 2, 101)]); + assert_eq!(v["folders"][0]["name"], "100CANON"); + assert_eq!(names(&v), vec!["b.jpg"]); + let carved = browse(&sample(), "@carved", &Query::default()); + assert!(folders(&carved).is_empty()); + assert_eq!(names(&carved), vec!["c.jpg"]); + } + + #[test] + fn browse_applies_filters() { + let q = Query { + category: Some(Category::Image), + ..Query::default() + }; + let v = browse(&sample(), "", &q); + assert_eq!( + folders(&v), + vec![("DCIM".to_string(), 2, 110), ("@carved".to_string(), 1, 7)] + ); + assert!(names(&v).is_empty()); + } + + #[test] + fn prefix_is_not_parent() { + let found = vec![ + Found::test_file(1, "Foto", "a.jpg", 1), + Found::test_file(2, "Fotos", "b.jpg", 1), + ]; + let v = browse(&found, "Foto", &Query::default()); + assert_eq!(names(&v), vec!["a.jpg"]); + assert!(folders(&v).is_empty()); + } + + #[test] + fn browse_paginates_files() { + let found: Vec = (0..250) + .map(|i| Found::test_file(i, "big", &format!("f{i:03}.txt"), 1)) + .collect(); + let q = Query { + limit: 100, + offset: 200, + ..Query::default() + }; + let v = browse(&found, "big", &q); + assert_eq!(v["total_files"], 250); + assert_eq!(names(&v).len(), 50); + assert_eq!(names(&v)[0], "f200.txt"); + } +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 67c4863..70d422f 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -359,6 +359,13 @@ impl Engine { Ok(json!({ "total": total, "items": page })) } + /// One level of the folder tree (see [`crate::explorer::browse`]). + pub fn browse(&self, id: &str, path: &str, q: &Query) -> Result { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + Ok(crate::explorer::browse(&s.found, path, q)) + } + /// Sidebar navigation facet counts (categories, dates, folders, cameras). pub fn facets(&self, id: &str) -> Result { let j = self.job(id)?; @@ -1232,6 +1239,17 @@ impl Engine { .and_then(|v| v.as_u64()) .ok_or_else(|| Error::msg("id ausente"))? )?), + "browse" => { + let q: Query = p + .get("query") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let path = p.get("path").and_then(|v| v.as_str()).unwrap_or(""); + self.browse(&s("job")?, path, &q)? + } "results" => { let q: Query = p .get("query") diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index c0ce3ad..0fa5b49 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -510,3 +510,40 @@ impl Default for ScanOptions { } } } + +#[cfg(test)] +impl Found { + /// Minimal resident file for unit tests: `dir` `""` means the volume root. + pub(crate) fn test_file(id: u64, dir: &str, name: &str, size: u64) -> Self { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + Found { + id, + name: name.into(), + dir: Some(dir.into()), + category: crate::signatures::category_for_ext(&ext), + ext, + size, + origin: Origin::Fat, + confidence: Confidence::High, + data: DataLoc::Resident { bytes: Vec::new() }, + modified: None, + created: None, + accessed: None, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, + note: None, + } + } + + /// The same, found by signature (no folder). + pub(crate) fn test_carved(id: u64, name: &str, size: u64) -> Self { + Found { + dir: None, + origin: Origin::Carved, + ..Self::test_file(id, "", name, size) + } + } +}