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.
|
/// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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