Browse and recover by folder (1.4.0) #1
No files matched your search
@@ -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!();
|
||||
|
||||
@@ -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<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.
|
||||
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<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]
|
||||
fn browse_paginates_files() {
|
||||
let found: Vec<Found> = (0..250)
|
||||
|
||||
@@ -169,6 +169,25 @@ pub fn recover_resumable(
|
||||
resume: bool,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
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> {
|
||||
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<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 {
|
||||
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::<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");
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,7 @@ impl Engine {
|
||||
scan_id: &str,
|
||||
ids: Option<Vec<u64>>,
|
||||
filter: Option<Query>,
|
||||
selection: Option<crate::explorer::Selection>,
|
||||
dest: &str,
|
||||
layout: recover::RecoverLayout,
|
||||
) -> Result<String> {
|
||||
@@ -477,15 +478,25 @@ impl Engine {
|
||||
let src = sj.source().ok_or_else(no_source)?;
|
||||
let files: Vec<Found> = {
|
||||
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<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" => {
|
||||
let format: image::ImageFormat = p
|
||||
|
||||
@@ -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<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}");
|
||||
}
|
||||
Reference in new issue
Block a user