feat(explorer): list one folder level with recursive counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7683967650
commit
b274f0b7ef
3 files changed
+228
No files matched your search
@@ -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.
|
||||
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<u64> {
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<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).
|
||||
pub fn facets(&self, id: &str) -> Result<Value> {
|
||||
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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user