feat(recover): recover a selection of folders relative to each marked folder
A marked folder a/b/Projeto is recreated as Projeto/... with everything
inside it; single marked files go to the destination root and unmarked
files inside a marked folder are left out. recover_start accepts
{selection: {folders, ids, exclude}}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b274f0b7ef
commit
ca677a8bc1
5 files changed
+403
-4
No files matched your search
@@ -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!();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user