diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index c3f9787..c1e2710 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -812,7 +812,7 @@ fn recover_job( filter.organize ))?; 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)?; let st = wait(eng, &rj, None)?; eprintln!(); diff --git a/crates/guara-core/src/explorer.rs b/crates/guara-core/src/explorer.rs index 9f2f9fe..26ad40e 100644 --- a/crates/guara-core/src/explorer.rs +++ b/crates/guara-core/src/explorer.rs @@ -240,6 +240,44 @@ pub fn browse(found: &[Found], path: &str, q: &Query) -> Value { 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, + #[serde(default)] + pub ids: Vec, + #[serde(default)] + pub exclude: Vec, +} + +/// 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 = sel.ids.iter().copied().collect(); + let exclude: std::collections::HashSet = 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. pub fn facets(found: &[Found]) -> Value { let mut cats: HashMap<&'static str, usize> = HashMap::new(); @@ -532,6 +570,56 @@ mod tests { assert!(folders(&v).is_empty()); } + fn ids(v: Vec<&Found>) -> Vec { + 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] fn browse_paginates_files() { let found: Vec = (0..250) diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index dfe01cb..1d11f07 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -169,6 +169,25 @@ pub fn recover_resumable( resume: bool, cancelled: &(dyn Fn() -> bool + Sync), progress: &mut dyn FnMut(usize, usize, u64), +) -> Result { + 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 { use std::io::Write; crate::owner::create_dir_all_owned(dest)?; @@ -219,7 +238,7 @@ pub fn recover_resumable( 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); if let Some(parent) = path.parent() { if let Err(e) = crate::owner::create_dir_all_owned(parent) { @@ -292,6 +311,97 @@ pub fn recover_resumable( 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 = 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 = paths + .iter() + .filter(|p| !paths.iter().any(|q| covered(p, q))) + .cloned() + .collect(); + let mut used: HashSet = 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 { + 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 { let mut p = PathBuf::new(); match layout { @@ -524,3 +634,65 @@ impl Source for FoundSource<'_> { 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::>()); + 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"); + } +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 70d422f..aa2d69b 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -470,6 +470,7 @@ impl Engine { scan_id: &str, ids: Option>, filter: Option, + selection: Option, dest: &str, layout: recover::RecoverLayout, ) -> Result { @@ -477,15 +478,25 @@ impl Engine { let src = sj.source().ok_or_else(no_source)?; let files: Vec = { let s = sj.st.lock().unwrap(); - match (&ids, &filter) { - (Some(ids), _) => ids - .iter() - .filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone())) - .collect(), - (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), - (None, None) => s.found.clone(), + if let Some(sel) = &selection { + crate::explorer::resolve_selection(&s.found, sel) + .into_iter() + .cloned() + .collect() + } else { + match (&ids, &filter) { + (Some(ids), _) => ids + .iter() + .filter_map(|i| s.index.get(i).map(|&k| s.found[k].clone())) + .collect(), + (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), + (None, None) => s.found.clone(), + } } }; + let roots = selection + .as_ref() + .map(|sel| recover::SelectionRoots::new(&sel.folders)); if files.is_empty() { return Err(Error::msg("nenhum arquivo selecionado")); } @@ -533,11 +544,12 @@ impl Engine { }; }; let refs: Vec<&Found> = files.iter().collect(); - let res = recover::recover_resumable( + let res = recover::recover_resumable_with( src.as_ref(), &refs, &dest_path, layout, + roots.as_ref(), &source_key, true, &cancelled, @@ -1283,7 +1295,13 @@ impl Engine { .and_then(|v| v.as_str()) .and_then(recover::RecoverLayout::parse) .unwrap_or_default(); - json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? }) + let selection: Option = 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" => { let format: image::ImageFormat = p diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index 10c0c20..dab5e84 100644 --- a/crates/guara-core/tests/disk.rs +++ b/crates/guara-core/tests/disk.rs @@ -381,6 +381,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() { &job, None, Some(service::Query::default()), + None, out.path().to_str().unwrap(), recover::RecoverLayout::Folders, ) @@ -437,6 +438,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() { &job, None, None, + None, out.path().to_str().unwrap(), recover::RecoverLayout::Folders, ) @@ -452,6 +454,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() { &job, None, None, + None, out.path().to_str().unwrap(), recover::RecoverLayout::Folders, ) @@ -712,6 +715,7 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() { &job, None, None, + None, out.path().to_str().unwrap(), 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!(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 = 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}"); +}