Browse and recover by folder (1.4.0) #1

Merged
lucasrguerra merged 5 commits from feat/navegacao-pastas into main 2026-11-03 17:10:46 +00:00
3 changed files with 228 additions and 0 deletions
Showing only changes of commit b274f0b7ef - Show all commits

No files matched your search

+173
View File
@@ -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<Value> = 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<Value> = 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. /// Facet counts for sidebar navigation.
pub fn facets(found: &[Found]) -> Value { pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new(); 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<u64> {
} }
Some(h) Some(h)
} }
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Found> {
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<String> {
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<Found> = (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");
}
}
+18
View File
@@ -359,6 +359,13 @@ impl Engine {
Ok(json!({ "total": total, "items": page })) 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<Value> {
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). /// Sidebar navigation facet counts (categories, dates, folders, cameras).
pub fn facets(&self, id: &str) -> Result<Value> { pub fn facets(&self, id: &str) -> Result<Value> {
let j = self.job(id)?; let j = self.job(id)?;
@@ -1232,6 +1239,17 @@ impl Engine {
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))? .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" => { "results" => {
let q: Query = p let q: Query = p
.get("query") .get("query")
+37
View File
@@ -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)
}
}
}