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
5 changed files with 403 additions and 4 deletions
Showing only changes of commit ca677a8bc1 - Show all commits

No files matched your search

+1 -1
View File
@@ -812,7 +812,7 @@ fn recover_job(
filter.organize filter.organize
))?; ))?;
let rj = eng let rj = eng
.start_recover(scan_job, ids, Some(q), dest, layout) .start_recover(scan_job, ids, Some(q), None, dest, layout)
.map_err(s)?; .map_err(s)?;
let st = wait(eng, &rj, None)?; let st = wait(eng, &rj, None)?;
eprintln!(); eprintln!();
+88
View File
@@ -240,6 +240,44 @@ pub fn browse(found: &[Found], path: &str, q: &Query) -> Value {
json!({ "path": path, "folders": out, "files": page, "total_files": total }) json!({ "path": path, "folders": out, "files": page, "total_files": total })
} }
/// What the user marked in folder browsing: whole folders (recursive; `""` is
/// everything, `"@carved"` the files found by signature), single files, and
/// files unmarked inside a marked folder.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Selection {
#[serde(default)]
pub folders: Vec<String>,
#[serde(default)]
pub ids: Vec<u64>,
#[serde(default)]
pub exclude: Vec<u64>,
}
/// Whether `f` is inside the browsing folder `folder` (recursively).
pub fn in_folder(f: &Found, folder: &str) -> bool {
let folder = folder.trim_matches('/');
if folder.is_empty() {
return true;
}
match browse_dir(f) {
None => folder == "@carved",
Some(dir) => dir == folder || dir.strip_prefix(folder).is_some_and(|r| r.starts_with('/')),
}
}
/// Files a selection resolves to, in discovery order. Marked folders take
/// everything inside them, whatever filters the view had; an excluded id
/// always wins.
pub fn resolve_selection<'a>(found: &'a [Found], sel: &Selection) -> Vec<&'a Found> {
let ids: std::collections::HashSet<u64> = sel.ids.iter().copied().collect();
let exclude: std::collections::HashSet<u64> = sel.exclude.iter().copied().collect();
found
.iter()
.filter(|f| !exclude.contains(&f.id))
.filter(|f| ids.contains(&f.id) || sel.folders.iter().any(|d| in_folder(f, d)))
.collect()
}
/// 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();
@@ -532,6 +570,56 @@ mod tests {
assert!(folders(&v).is_empty()); assert!(folders(&v).is_empty());
} }
fn ids(v: Vec<&Found>) -> Vec<u64> {
v.into_iter().map(|f| f.id).collect()
}
#[test]
fn resolve_selection_folder_and_ids() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM/100CANON".into()],
ids: vec![3],
exclude: vec![],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 5]);
let all = Selection {
folders: vec!["".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &all)), vec![1, 2, 3, 4, 5]);
}
#[test]
fn exclude_wins_over_ids() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM".into()],
ids: vec![5],
exclude: vec![5, 2],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]);
}
#[test]
fn selection_carved_folder() {
let found = sample();
let sel = Selection {
folders: vec!["@carved".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![4]);
let prefix = vec![
Found::test_file(1, "Foto", "a.jpg", 1),
Found::test_file(2, "Fotos", "b.jpg", 1),
];
let sel = Selection {
folders: vec!["Foto".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&prefix, &sel)), vec![1]);
}
#[test] #[test]
fn browse_paginates_files() { fn browse_paginates_files() {
let found: Vec<Found> = (0..250) let found: Vec<Found> = (0..250)
+173 -1
View File
@@ -169,6 +169,25 @@ pub fn recover_resumable(
resume: bool, resume: bool,
cancelled: &(dyn Fn() -> bool + Sync), cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64), progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
recover_resumable_with(
src, files, dest, layout, None, source_key, resume, cancelled, progress,
)
}
/// [`recover_resumable`] for a folder-browsing selection: with `roots`, each
/// file lands relative to its marked folder and `layout` is ignored.
#[allow(clippy::too_many_arguments)]
pub fn recover_resumable_with(
src: &dyn Source,
files: &[&Found],
dest: &Path,
layout: RecoverLayout,
roots: Option<&SelectionRoots>,
source_key: &str,
resume: bool,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> { ) -> Result<RecoverReport> {
use std::io::Write; use std::io::Write;
crate::owner::create_dir_all_owned(dest)?; crate::owner::create_dir_all_owned(dest)?;
@@ -219,7 +238,7 @@ pub fn recover_resumable(
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
} }
} }
let rel = target_rel(f, layout); let rel = target_rel_in(f, layout, roots);
let mut path = dest.join(&rel); let mut path = dest.join(&rel);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
if let Err(e) = crate::owner::create_dir_all_owned(parent) { if let Err(e) = crate::owner::create_dir_all_owned(parent) {
@@ -292,6 +311,97 @@ pub fn recover_resumable(
Ok(report) Ok(report)
} }
/// Marked folders of a folder-browsing recovery and the name each one gets in
/// the destination: `a/b/Projeto` is recreated as `Projeto/...`.
#[derive(Debug, Clone, Default)]
pub struct SelectionRoots {
/// `(folder path, destination name)`; a nested marked folder is dropped
/// because the outermost one already covers it.
roots: Vec<(String, String)>,
}
impl SelectionRoots {
pub fn new(folders: &[String]) -> Self {
let mut paths: Vec<String> = folders
.iter()
.map(|f| f.trim_matches('/').to_string())
.collect();
paths.sort();
paths.dedup();
let covered = |p: &str, by: &str| {
p != by
&& p != "@carved"
&& (by.is_empty() || p.strip_prefix(by).is_some_and(|r| r.starts_with('/')))
};
let kept: Vec<String> = paths
.iter()
.filter(|p| !paths.iter().any(|q| covered(p, q)))
.cloned()
.collect();
let mut used: HashSet<String> = HashSet::new();
let roots = kept
.into_iter()
.map(|path| {
let base = match path.as_str() {
"" => String::new(),
"@carved" => "Por assinatura".to_string(),
p => sanitize_name(p.rsplit('/').next().unwrap_or(p)),
};
let mut name = base.clone();
let mut n = 2;
while !base.is_empty() && !used.insert(name.clone()) {
name = format!("{base} ({n})");
n += 1;
}
(path, name)
})
.collect();
Self { roots }
}
/// Destination folder for `f`, or `None` when no marked folder holds it
/// (a file marked on its own goes to the destination root).
fn rel_dir(&self, f: &Found) -> Option<PathBuf> {
let (path, name) = self
.roots
.iter()
.find(|(path, _)| crate::explorer::in_folder(f, path))?;
let mut out = PathBuf::new();
if !name.is_empty() {
out.push(name);
}
if f.origin == Origin::Carved {
if path.is_empty() {
out.push("Por assinatura");
}
return Some(out);
}
let dir = f.dir.as_deref().unwrap_or("").trim_matches('/');
let rest = if path.is_empty() {
dir
} else {
dir[path.len()..].trim_start_matches('/')
};
for part in rest.split('/').filter(|p| !p.is_empty()) {
out.push(sanitize_name(part));
}
Some(out)
}
}
/// Destination path of `f`: relative to its marked folder when recovering a
/// selection, otherwise by `layout`.
fn target_rel_in(f: &Found, layout: RecoverLayout, roots: Option<&SelectionRoots>) -> PathBuf {
match roots {
Some(roots) => {
let mut p = roots.rel_dir(f).unwrap_or_default();
p.push(sanitize_name(&f.name));
p
}
None => target_rel(f, layout),
}
}
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf { fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
let mut p = PathBuf::new(); let mut p = PathBuf::new();
match layout { match layout {
@@ -524,3 +634,65 @@ impl Source for FoundSource<'_> {
read_found_at(self.src, self.found, offset, buf) read_found_at(self.src, self.found, offset, buf)
} }
} }
#[cfg(test)]
mod selection_tests {
use super::*;
fn rel(roots: &[&str], f: &Found) -> String {
let roots = SelectionRoots::new(&roots.iter().map(|r| r.to_string()).collect::<Vec<_>>());
target_rel_in(f, RecoverLayout::Folders, Some(&roots))
.to_string_lossy()
.replace('\\', "/")
}
#[test]
fn selection_target_relative() {
let f = Found::test_file(1, "a/b/Projeto/sub", "x.jpg", 1);
assert_eq!(rel(&["a/b/Projeto"], &f), "Projeto/sub/x.jpg");
let top = Found::test_file(2, "a/b/Projeto", "y.txt", 1);
assert_eq!(rel(&["a/b/Projeto"], &top), "Projeto/y.txt");
}
#[test]
fn selection_nested_uses_outermost() {
let f = Found::test_file(1, "a/Projeto/sub", "x.jpg", 1);
assert_eq!(
rel(&["a/Projeto/sub", "a/Projeto"], &f),
"Projeto/sub/x.jpg"
);
}
#[test]
fn selection_same_name_suffix() {
let roots = ["b/Projeto", "a/Projeto"];
assert_eq!(
rel(&roots, &Found::test_file(1, "a/Projeto", "x.jpg", 1)),
"Projeto/x.jpg"
);
assert_eq!(
rel(&roots, &Found::test_file(2, "b/Projeto", "y.jpg", 1)),
"Projeto (2)/y.jpg"
);
}
#[test]
fn selection_loose_file_at_root() {
let f = Found::test_file(1, "Documentos", "solto.pdf", 1);
assert_eq!(rel(&["Outra"], &f), "solto.pdf");
assert_eq!(
rel(&["@carved"], &Found::test_carved(2, "c.jpg", 1)),
"Por assinatura/c.jpg"
);
assert_eq!(
rel(&[""], &Found::test_file(3, "a/b", "z.txt", 1)),
"a/b/z.txt"
);
}
#[test]
fn selection_target_sanitizes() {
let f = Found::test_file(1, "x/Proj:e?to/sub*", "a.jpg", 1);
assert_eq!(rel(&["x/Proj:e?to"], &f), "Proj_e_to/sub_/a.jpg");
}
}
+20 -2
View File
@@ -470,6 +470,7 @@ impl Engine {
scan_id: &str, scan_id: &str,
ids: Option<Vec<u64>>, ids: Option<Vec<u64>>,
filter: Option<Query>, filter: Option<Query>,
selection: Option<crate::explorer::Selection>,
dest: &str, dest: &str,
layout: recover::RecoverLayout, layout: recover::RecoverLayout,
) -> Result<String> { ) -> Result<String> {
@@ -477,6 +478,12 @@ impl Engine {
let src = sj.source().ok_or_else(no_source)?; let src = sj.source().ok_or_else(no_source)?;
let files: Vec<Found> = { let files: Vec<Found> = {
let s = sj.st.lock().unwrap(); let s = sj.st.lock().unwrap();
if let Some(sel) = &selection {
crate::explorer::resolve_selection(&s.found, sel)
.into_iter()
.cloned()
.collect()
} else {
match (&ids, &filter) { match (&ids, &filter) {
(Some(ids), _) => ids (Some(ids), _) => ids
.iter() .iter()
@@ -485,7 +492,11 @@ impl Engine {
(None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(),
(None, None) => s.found.clone(), (None, None) => s.found.clone(),
} }
}
}; };
let roots = selection
.as_ref()
.map(|sel| recover::SelectionRoots::new(&sel.folders));
if files.is_empty() { if files.is_empty() {
return Err(Error::msg("nenhum arquivo selecionado")); return Err(Error::msg("nenhum arquivo selecionado"));
} }
@@ -533,11 +544,12 @@ impl Engine {
}; };
}; };
let refs: Vec<&Found> = files.iter().collect(); let refs: Vec<&Found> = files.iter().collect();
let res = recover::recover_resumable( let res = recover::recover_resumable_with(
src.as_ref(), src.as_ref(),
&refs, &refs,
&dest_path, &dest_path,
layout, layout,
roots.as_ref(),
&source_key, &source_key,
true, true,
&cancelled, &cancelled,
@@ -1283,7 +1295,13 @@ impl Engine {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(recover::RecoverLayout::parse) .and_then(recover::RecoverLayout::parse)
.unwrap_or_default(); .unwrap_or_default();
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? }) let selection: Option<crate::explorer::Selection> = p
.get("selection")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
json!({ "job": self.start_recover(&s("job")?, ids, filter, selection, &s("dest")?, layout)? })
} }
"image_start" => { "image_start" => {
let format: image::ImageFormat = p let format: image::ImageFormat = p
+121
View File
@@ -381,6 +381,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
&job, &job,
None, None,
Some(service::Query::default()), Some(service::Query::default()),
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -437,6 +438,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -452,6 +454,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -712,6 +715,7 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -719,3 +723,120 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
assert!(e.to_string().contains("Espaço insuficiente"), "{e}"); assert!(e.to_string().contains("Espaço insuficiente"), "{e}");
assert!(recover::free_space(out.path()).unwrap_or(0) > 0); assert!(recover::free_space(out.path()).unwrap_or(0) > 0);
} }
/// Folder browsing on FAT32 (deleted short names lose their first character,
/// hence `_raia.jpg`): one level at a time, and recovering a marked folder
/// recreates only that folder (plus single marked files at the root, minus the
/// unmarked ones).
#[test]
fn navegacao_por_pastas_e_recuperacao_da_selecao() {
init();
let Some(dir) = fixtures() else { return };
let img = dir.join("fat32.img");
let eng = service::Engine::new();
let job = eng
.start_scan(img.to_str().unwrap(), ScanOptions::default())
.unwrap();
wait_done(&eng, &job);
let root = eng.browse(&job, "", &service::Query::default()).unwrap();
let folders: Vec<(String, u64)> = root["folders"]
.as_array()
.unwrap()
.iter()
.filter(|f| f["path"] != "@carved")
.map(|f| {
(
f["path"].as_str().unwrap().to_string(),
f["files"].as_u64().unwrap(),
)
})
.collect();
assert!(folders.contains(&("Documentos".into(), 5)), "{folders:?}");
assert!(folders.contains(&("Fotos".into(), 2)), "{folders:?}");
let docs = eng
.browse(&job, "Documentos", &service::Query::default())
.unwrap();
assert_eq!(docs["folders"][0]["path"], "Documentos/Trabalho");
assert_eq!(docs["folders"][0]["files"], 3);
let all = eng
.results(
&job,
&service::Query {
limit: 5000,
..Default::default()
},
)
.unwrap();
let id_of = |name: &str| {
all["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == name && i["origin"] == "fat")
.unwrap_or_else(|| panic!("{name} not found"))["id"]
.as_u64()
.unwrap()
};
let selection = explorer::Selection {
folders: vec!["Documentos/Trabalho".into()],
ids: vec![id_of("_raia.jpg")],
exclude: vec![id_of("tese.docx")],
};
let out = tempfile::tempdir().unwrap();
let rj = eng
.start_recover(
&job,
None,
None,
Some(selection),
out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
)
.unwrap();
wait_done(&eng, &rj);
let got = hashes_in(out.path());
let mut paths: Vec<String> = got
.values()
.map(|p| {
p.strip_prefix(out.path())
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
paths.sort();
assert_eq!(
paths,
vec!["Trabalho/_otas.txt", "Trabalho/relatorio.pdf", "_raia.jpg"]
);
let expected = expected(&dir, "fat32");
for (hash, original) in &expected {
let wanted = [
"Documentos/Trabalho/notas.txt",
"Documentos/Trabalho/relatorio.pdf",
"Fotos/praia.jpg",
];
if wanted.contains(&original.as_str()) {
assert!(got.contains_key(hash), "content of {original} differs");
}
}
assert!(out.path().join("guara-manifesto.json").is_file());
let empty = explorer::Selection {
folders: vec!["Nada/Aqui".into()],
..Default::default()
};
let e = eng
.start_recover(
&job,
None,
None,
Some(empty),
out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
)
.unwrap_err();
assert!(e.to_string().contains("nenhum arquivo selecionado"), "{e}");
}