feat: explorador de arquivos, miniaturas, metadados e recursos periciais (0.3.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s
- Explorador com grade/lista virtualizada, navegação por tipo, data EXIF, pasta e câmera, seleção estilo gerenciador de arquivos e visualização rápida. - Miniaturas no núcleo, metadados (EXIF, PDF, Office, MP4), datas MACB do NTFS/FAT/exFAT. - Hashes MD5/SHA-1/SHA-256, busca de texto no disco, exportação CSV, relatório HTML, detecção de duplicadas, tema, correções de interface e textos sem travessões. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1457caa453
commit
45d535bdb7
34 files changed
+4433
-365
No files matched your search
@@ -0,0 +1,507 @@
|
||||
//! Recursos periciais: hashes, metadados (EXIF/PDF/Office/MP4), miniaturas,
|
||||
//! busca de texto, exportações e navegação (facetas/duplicadas).
|
||||
|
||||
use guara_core::explorer::{self, Query};
|
||||
use guara_core::hashing::hash_bytes;
|
||||
use guara_core::source::MemSource;
|
||||
use guara_core::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn resident(name: &str, bytes: Vec<u8>) -> Found {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_string();
|
||||
Found {
|
||||
id: 1,
|
||||
name: name.into(),
|
||||
dir: None,
|
||||
category: signatures::category_for_ext(&ext),
|
||||
ext,
|
||||
size: bytes.len() as u64,
|
||||
origin: Origin::Carved,
|
||||
confidence: Confidence::High,
|
||||
data: DataLoc::Resident { bytes },
|
||||
modified: None,
|
||||
created: None,
|
||||
accessed: None,
|
||||
taken: None,
|
||||
camera: None,
|
||||
px: None,
|
||||
gps: false,
|
||||
sig: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ hashes
|
||||
|
||||
#[test]
|
||||
fn hashes_conferem_com_vetores_conhecidos() {
|
||||
let h = hash_bytes(b"abc");
|
||||
assert_eq!(h.md5, "900150983cd24fb0d6963f7d28e17f72");
|
||||
assert_eq!(h.sha1, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||
assert_eq!(
|
||||
h.sha256,
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ EXIF
|
||||
|
||||
fn ifd(entries: &[(u16, u16, u32, Vec<u8>)], start: usize) -> Vec<u8> {
|
||||
let n = entries.len();
|
||||
let mut table = (n as u16).to_le_bytes().to_vec();
|
||||
let mut data: Vec<u8> = vec![];
|
||||
let data_off = start + 2 + 12 * n + 4;
|
||||
for (tag, typ, cnt, val) in entries {
|
||||
table.extend(tag.to_le_bytes());
|
||||
table.extend(typ.to_le_bytes());
|
||||
table.extend(cnt.to_le_bytes());
|
||||
if val.len() <= 4 {
|
||||
let mut v = val.clone();
|
||||
v.resize(4, 0);
|
||||
table.extend(v);
|
||||
} else {
|
||||
if data.len() % 2 == 1 {
|
||||
data.push(0);
|
||||
}
|
||||
table.extend(((data_off + data.len()) as u32).to_le_bytes());
|
||||
data.extend(val);
|
||||
}
|
||||
}
|
||||
table.extend([0, 0, 0, 0]);
|
||||
table.extend(data);
|
||||
table
|
||||
}
|
||||
|
||||
fn rat(p: &[(u32, u32)]) -> Vec<u8> {
|
||||
p.iter()
|
||||
.flat_map(|(a, b)| a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn jpeg_with_exif(orientation: u16, w: usize, h: usize) -> Vec<u8> {
|
||||
// pixels: gradiente
|
||||
let mut px = Vec::with_capacity(w * h * 3);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
px.extend([(x * 255 / w) as u8, (y * 255 / h) as u8, 128]);
|
||||
}
|
||||
}
|
||||
let mut jpg = vec![];
|
||||
jpeg_encoder::Encoder::new(&mut jpg, 85)
|
||||
.encode(&px, w as u16, h as u16, jpeg_encoder::ColorType::Rgb)
|
||||
.unwrap();
|
||||
let asc = |s: &str| {
|
||||
let mut v = s.as_bytes().to_vec();
|
||||
v.push(0);
|
||||
v
|
||||
};
|
||||
let make = asc("Canon");
|
||||
let model = asc("Canon EOS 80D");
|
||||
let dt = asc("2023:07:14 18:22:05");
|
||||
let e0 = |exif: u32, gps: u32| {
|
||||
ifd(
|
||||
&[
|
||||
(0x010F, 2, make.len() as u32, make.clone()),
|
||||
(0x0110, 2, model.len() as u32, model.clone()),
|
||||
(0x0112, 3, 1, orientation.to_le_bytes().to_vec()),
|
||||
(0x8769, 4, 1, exif.to_le_bytes().to_vec()),
|
||||
(0x8825, 4, 1, gps.to_le_bytes().to_vec()),
|
||||
],
|
||||
8,
|
||||
)
|
||||
};
|
||||
let first = e0(0, 0);
|
||||
let exif_off = 8 + first.len();
|
||||
let exif_ifd = ifd(&[(0x9003, 2, dt.len() as u32, dt.clone())], exif_off);
|
||||
let gps_off = exif_off + exif_ifd.len();
|
||||
let gps_ifd = ifd(
|
||||
&[
|
||||
(1, 2, 2, b"S\0".to_vec()),
|
||||
(2, 5, 3, rat(&[(23, 1), (33, 1), (1800, 100)])),
|
||||
(3, 2, 2, b"W\0".to_vec()),
|
||||
(4, 5, 3, rat(&[(46, 1), (38, 1), (0, 1)])),
|
||||
],
|
||||
gps_off,
|
||||
);
|
||||
let mut tiff = b"II*\0".to_vec();
|
||||
tiff.extend(8u32.to_le_bytes());
|
||||
tiff.extend(e0(exif_off as u32, gps_off as u32));
|
||||
tiff.extend(exif_ifd);
|
||||
tiff.extend(gps_ifd);
|
||||
let mut app1 = b"Exif\0\0".to_vec();
|
||||
app1.extend(tiff);
|
||||
let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1];
|
||||
out.extend(((app1.len() + 2) as u16).to_be_bytes());
|
||||
out.extend(app1);
|
||||
out.extend(&jpg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exif_camera_data_e_gps() {
|
||||
let f = resident("foto.jpg", jpeg_with_exif(1, 64, 48));
|
||||
let m = meta::extract(&MemSource(vec![]), &f);
|
||||
assert_eq!(m.camera.as_deref(), Some("Canon EOS 80D"));
|
||||
assert_eq!(m.px, Some((64, 48)));
|
||||
// 2023-07-14 18:22:05 lido como UTC
|
||||
assert_eq!(m.taken, Some(1_689_358_925));
|
||||
let (lat, lon) = m.gps.unwrap();
|
||||
assert!((lat + 23.5550).abs() < 0.001, "{lat}");
|
||||
assert!((lon + 46.6333).abs() < 0.001, "{lon}");
|
||||
assert!(m
|
||||
.fields
|
||||
.iter()
|
||||
.any(|(k, v)| k == "Foto tirada em" && v == "14/07/2023 18:22:05"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miniatura_respeita_orientacao_e_tamanho() {
|
||||
let src = MemSource(vec![]);
|
||||
// 64x48 com orientação 6 (girar 90 graus) => retrato 48x64, reduzido para caber em 32
|
||||
let f = resident("foto.jpg", jpeg_with_exif(6, 64, 48));
|
||||
let t = thumb::thumbnail(&src, &f, 32).expect("miniatura");
|
||||
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
|
||||
dec.decode().unwrap();
|
||||
let i = dec.info().unwrap();
|
||||
assert_eq!((i.width, i.height), (24, 32));
|
||||
// PNG também
|
||||
let mut img = ::image::RgbImage::new(100, 50);
|
||||
for p in img.pixels_mut() {
|
||||
*p = ::image::Rgb([200, 30, 30]);
|
||||
}
|
||||
let mut png = std::io::Cursor::new(vec![]);
|
||||
img.write_to(&mut png, ::image::ImageFormat::Png).unwrap();
|
||||
let f = resident("x.png", png.into_inner());
|
||||
let t = thumb::thumbnail(&src, &f, 40).expect("miniatura png");
|
||||
let mut dec = jpeg_decoder::Decoder::new(&t[..]);
|
||||
dec.decode().unwrap();
|
||||
assert_eq!(
|
||||
(dec.info().unwrap().width, dec.info().unwrap().height),
|
||||
(40, 20)
|
||||
);
|
||||
// lixo não vira miniatura
|
||||
assert!(thumb::thumbnail(&src, &resident("x.jpg", vec![1, 2, 3, 4, 5]), 32).is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ PDF, Office, MP4
|
||||
|
||||
#[test]
|
||||
fn metadados_de_pdf() {
|
||||
let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count 12 /Kids [] >>\nendobj\n3 0 obj\n<< /Title (Relat\\363rio Final) /Author (Maria \\(Perita\\) Silva) /Creator (LibreOffice Writer) /Producer <FEFF00500044004600310032> /CreationDate (D:20230714182205-03'00') >>\nendobj\ntrailer\n%%EOF\n".to_vec();
|
||||
let m = meta::extract(&MemSource(vec![]), &resident("a.pdf", pdf));
|
||||
let get = |k: &str| {
|
||||
m.fields
|
||||
.iter()
|
||||
.find(|(a, _)| a == k)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
assert_eq!(get("Título").as_deref(), Some("Relatório Final"));
|
||||
assert_eq!(get("Autor").as_deref(), Some("Maria (Perita) Silva"));
|
||||
assert_eq!(get("Criado com").as_deref(), Some("LibreOffice Writer"));
|
||||
assert_eq!(get("Gerado por").as_deref(), Some("PDF12"));
|
||||
assert_eq!(get("Criado em").as_deref(), Some("14/07/2023 18:22:05"));
|
||||
assert_eq!(get("Páginas (aprox.)").as_deref(), Some("12"));
|
||||
}
|
||||
|
||||
fn zip_store(entries: &[(&str, &str)], deflate: bool) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let mut cd = vec![];
|
||||
for (name, body) in entries {
|
||||
let (method, data): (u16, Vec<u8>) = if deflate {
|
||||
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
|
||||
e.write_all(body.as_bytes()).unwrap();
|
||||
(8, e.finish().unwrap())
|
||||
} else {
|
||||
(0, body.as_bytes().to_vec())
|
||||
};
|
||||
let off = out.len() as u32;
|
||||
let crc = signatures::crc32(body.as_bytes());
|
||||
let mut lh = b"PK\x03\x04".to_vec();
|
||||
lh.extend([20, 0, 0, 0]);
|
||||
lh.extend(method.to_le_bytes());
|
||||
lh.extend([0, 0, 0, 0]);
|
||||
lh.extend(crc.to_le_bytes());
|
||||
lh.extend((data.len() as u32).to_le_bytes());
|
||||
lh.extend((body.len() as u32).to_le_bytes());
|
||||
lh.extend((name.len() as u16).to_le_bytes());
|
||||
lh.extend([0, 0]);
|
||||
lh.extend(name.as_bytes());
|
||||
out.extend(&lh);
|
||||
out.extend(&data);
|
||||
let mut c = b"PK\x01\x02".to_vec();
|
||||
c.extend([20, 0, 20, 0, 0, 0]);
|
||||
c.extend(method.to_le_bytes());
|
||||
c.extend([0, 0, 0, 0]);
|
||||
c.extend(crc.to_le_bytes());
|
||||
c.extend((data.len() as u32).to_le_bytes());
|
||||
c.extend((body.len() as u32).to_le_bytes());
|
||||
c.extend((name.len() as u16).to_le_bytes());
|
||||
c.extend([0; 12]);
|
||||
c.extend(off.to_le_bytes());
|
||||
c.extend(name.as_bytes());
|
||||
cd.extend(c);
|
||||
}
|
||||
let cd_off = out.len() as u32;
|
||||
out.extend(&cd);
|
||||
out.extend(b"PK\x05\x06\0\0\0\0");
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
out.extend((cd.len() as u32).to_le_bytes());
|
||||
out.extend(cd_off.to_le_bytes());
|
||||
out.extend([0, 0]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadados_de_documentos_office() {
|
||||
let core = r#"<?xml version="1.0"?><cp:coreProperties xmlns:cp="x" xmlns:dc="y" xmlns:dcterms="z"><dc:title>Tese & Defesa</dc:title><dc:creator>Ana Souza</dc:creator><cp:lastModifiedBy>Bruno Lima</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">2023-07-14T18:22:05Z</dcterms:created></cp:coreProperties>"#;
|
||||
let app = "<Properties><Application>Microsoft Office Word</Application><Company>ACME</Company></Properties>";
|
||||
for deflate in [false, true] {
|
||||
let z = zip_store(
|
||||
&[
|
||||
("[Content_Types].xml", "<Types/>"),
|
||||
("docProps/core.xml", core),
|
||||
("docProps/app.xml", app),
|
||||
],
|
||||
deflate,
|
||||
);
|
||||
let m = meta::extract(&MemSource(vec![]), &resident("t.docx", z));
|
||||
let get = |k: &str| {
|
||||
m.fields
|
||||
.iter()
|
||||
.find(|(a, _)| a == k)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
assert_eq!(
|
||||
get("Título").as_deref(),
|
||||
Some("Tese & Defesa"),
|
||||
"deflate={deflate}"
|
||||
);
|
||||
assert_eq!(get("Autor").as_deref(), Some("Ana Souza"));
|
||||
assert_eq!(get("Última modificação por").as_deref(), Some("Bruno Lima"));
|
||||
assert_eq!(
|
||||
get("Criado em").as_deref(),
|
||||
Some("14/07/2023 18:22:05 (UTC)")
|
||||
);
|
||||
assert_eq!(get("Aplicativo").as_deref(), Some("Microsoft Office Word"));
|
||||
assert_eq!(get("Empresa").as_deref(), Some("ACME"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadados_de_mp4() {
|
||||
fn bx(ty: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
|
||||
v.extend(ty);
|
||||
v.extend(body);
|
||||
v
|
||||
}
|
||||
let created_1904: u32 = 1_689_358_925 + 2_082_844_800;
|
||||
let mut mvhd = vec![0, 0, 0, 0];
|
||||
mvhd.extend(created_1904.to_be_bytes());
|
||||
mvhd.extend(created_1904.to_be_bytes());
|
||||
mvhd.extend(1000u32.to_be_bytes()); // timescale
|
||||
mvhd.extend(125_000u32.to_be_bytes()); // 125 s
|
||||
mvhd.extend(vec![0u8; 80]);
|
||||
let mut tkhd = vec![0u8; 84];
|
||||
tkhd[76..80].copy_from_slice(&(1920u32 << 16).to_be_bytes());
|
||||
tkhd[80..84].copy_from_slice(&(1080u32 << 16).to_be_bytes());
|
||||
let moov = bx(
|
||||
b"moov",
|
||||
&[bx(b"mvhd", &mvhd), bx(b"trak", &bx(b"tkhd", &tkhd))].concat(),
|
||||
);
|
||||
let mut file = bx(b"ftyp", b"isom\0\0\0\0isom");
|
||||
file.extend(moov);
|
||||
file.extend(bx(b"mdat", &[0u8; 32]));
|
||||
let m = meta::extract(&MemSource(vec![]), &resident("v.mp4", file));
|
||||
let get = |k: &str| {
|
||||
m.fields
|
||||
.iter()
|
||||
.find(|(a, _)| a == k)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
assert_eq!(get("Duração").as_deref(), Some("0:02:05"));
|
||||
assert_eq!(
|
||||
get("Gravado em (metadado)").as_deref(),
|
||||
Some("14/07/2023 18:22:05 (UTC)")
|
||||
);
|
||||
assert_eq!(get("Dimensões").as_deref(), Some("1920 × 1080 px"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ busca de texto
|
||||
|
||||
#[test]
|
||||
fn busca_acha_utf8_utf16_e_na_fronteira_dos_blocos() {
|
||||
let mb = 1024 * 1024;
|
||||
let mut disk = vec![0u8; 20 * mb];
|
||||
let put = |d: &mut Vec<u8>, at: usize, b: &[u8]| d[at..at + b.len()].copy_from_slice(b);
|
||||
put(&mut disk, 1000, b"... Segredo-123 ...");
|
||||
let u16s: Vec<u8> = "segredo-123"
|
||||
.encode_utf16()
|
||||
.flat_map(|c| c.to_le_bytes())
|
||||
.collect();
|
||||
put(&mut disk, 3 * mb + 6, &u16s);
|
||||
// exatamente cruzando o limite do primeiro bloco de 8 MiB
|
||||
put(&mut disk, 8 * mb - 4, b"SEGREDO-123");
|
||||
let opts = search::SearchOpts {
|
||||
terms: vec!["segredo-123".into()],
|
||||
ignore_case: true,
|
||||
utf16: true,
|
||||
max_hits: 100,
|
||||
};
|
||||
let mut hits = vec![];
|
||||
let n = search::search(
|
||||
&MemSource(disk.clone()),
|
||||
&opts,
|
||||
&|| false,
|
||||
&mut |_, _| {},
|
||||
&mut |h| {
|
||||
hits.push(h);
|
||||
true
|
||||
},
|
||||
);
|
||||
assert_eq!(n, 3, "{hits:?}");
|
||||
assert_eq!(hits[0].offset, 1004);
|
||||
assert_eq!(hits[0].encoding, "UTF-8");
|
||||
assert!(hits[0].context.contains("Segredo-123"));
|
||||
assert_eq!(hits[1].offset, 3 * mb as u64 + 6);
|
||||
assert_eq!(hits[1].encoding, "UTF-16");
|
||||
assert_eq!(hits[2].offset, 8 * mb as u64 - 4);
|
||||
// sem ignorar maiúsculas só acha a grafia exata
|
||||
let opts = search::SearchOpts {
|
||||
terms: vec!["Segredo-123".into()],
|
||||
ignore_case: false,
|
||||
utf16: false,
|
||||
max_hits: 100,
|
||||
};
|
||||
let n = search::search(
|
||||
&MemSource(disk),
|
||||
&opts,
|
||||
&|| false,
|
||||
&mut |_, _| {},
|
||||
&mut |_| true,
|
||||
);
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ exportações
|
||||
|
||||
#[test]
|
||||
fn csv_escapa_e_neutraliza_formulas() {
|
||||
let mut f = resident("=SOMA(A1).xlsx", vec![1]);
|
||||
f.note = Some("diz \"oi\"; ok".into());
|
||||
f.camera = Some("Cam;X".into());
|
||||
let csv = report::csv(&[&f]);
|
||||
assert!(csv.starts_with('\u{feff}'));
|
||||
assert!(csv.contains("\"'=SOMA(A1).xlsx\""), "{csv}");
|
||||
assert!(csv.contains("\"diz \"\"oi\"\"; ok\""));
|
||||
assert_eq!(csv.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relatorio_html_escapa_conteudo_e_traz_o_caso() {
|
||||
let f = resident("<script>alert(1)</script>.txt", vec![1, 2, 3]);
|
||||
let case = report::CaseInfo {
|
||||
case_id: "2026-001".into(),
|
||||
examiner: "Perita <Ana>".into(),
|
||||
organization: "Lab".into(),
|
||||
notes: "obs".into(),
|
||||
};
|
||||
let opts = ScanOptions::default();
|
||||
let hashes = hash_bytes(b"disco");
|
||||
let html = report::html(&report::ReportInput {
|
||||
case: &case,
|
||||
target: "/dev/sdb",
|
||||
options: &opts,
|
||||
layout: None,
|
||||
found: &[f],
|
||||
warnings: &["aviso <b>".to_string()],
|
||||
started_unix: Some(1_689_358_925),
|
||||
elapsed: 1.5,
|
||||
recoveries: &[],
|
||||
source_hashes: Some(&hashes),
|
||||
});
|
||||
assert!(!html.contains("<script>alert"), "HTML não escapado");
|
||||
assert!(html.contains("<script>alert(1)</script>.txt"));
|
||||
assert!(html.contains("2026-001") && html.contains("Perita <Ana>"));
|
||||
assert!(html.contains(&hashes.sha256));
|
||||
assert!(html.contains("somente para leitura"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ navegação
|
||||
|
||||
#[test]
|
||||
fn facetas_filtros_e_duplicadas() {
|
||||
let mk = |id: u64,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
taken: Option<i64>,
|
||||
cam: Option<&str>,
|
||||
sig: Option<u64>| {
|
||||
let mut f = resident(name, vec![0; 10]);
|
||||
f.id = id;
|
||||
f.dir = dir.map(String::from);
|
||||
f.origin = if dir.is_some() {
|
||||
Origin::Fat
|
||||
} else {
|
||||
Origin::Carved
|
||||
};
|
||||
f.taken = taken;
|
||||
f.camera = cam.map(String::from);
|
||||
f.sig = sig;
|
||||
f
|
||||
};
|
||||
let jul23 = 1_689_358_925; // 2023-07-14
|
||||
let jan24 = 1_705_000_000; // 2024-01-11
|
||||
let found = vec![
|
||||
mk(
|
||||
1,
|
||||
"a.jpg",
|
||||
Some("DCIM/100"),
|
||||
Some(jul23),
|
||||
Some("Canon"),
|
||||
Some(7),
|
||||
),
|
||||
mk(
|
||||
2,
|
||||
"b.jpg",
|
||||
Some("DCIM/100"),
|
||||
Some(jan24),
|
||||
Some("Canon"),
|
||||
Some(8),
|
||||
),
|
||||
mk(3, "c.jpg", Some("Viagens"), Some(jul23), None, Some(7)), // duplicada de a.jpg
|
||||
mk(4, "d.jpg", None, None, None, None),
|
||||
];
|
||||
let f = explorer::facets(&found);
|
||||
assert_eq!(f["total"], 4);
|
||||
assert_eq!(f["years"][0]["year"], 2024);
|
||||
assert_eq!(f["years"][1]["year"], 2023);
|
||||
assert_eq!(f["years"][1]["count"], 2);
|
||||
assert_eq!(f["undated"], 1);
|
||||
assert_eq!(f["carved"], 1);
|
||||
assert_eq!(f["duplicates"], 1);
|
||||
assert_eq!(f["cameras"][0]["name"], "Canon");
|
||||
let dcim = f["folders"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|d| d["path"] == "DCIM")
|
||||
.unwrap();
|
||||
assert_eq!(dcim["count"], 2);
|
||||
|
||||
let q = |json: &str| -> Query { serde_json::from_str(json).unwrap() };
|
||||
assert_eq!(q(r#"{"year":2023}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":7}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"year":2023,"month":8}"#).select(&found).len(), 0);
|
||||
assert_eq!(q(r#"{"folder":"DCIM"}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"folder":"@carved"}"#).select(&found).len(), 1);
|
||||
assert_eq!(q(r#"{"camera":"Canon"}"#).select(&found).len(), 2);
|
||||
assert_eq!(q(r#"{"undated":true}"#).select(&found).len(), 1);
|
||||
assert_eq!(q(r#"{"dedupe":true}"#).select(&found).len(), 3);
|
||||
assert_eq!(q(r#"{"query":"viagens"}"#).select(&found).len(), 1);
|
||||
let mut items = q("{}").select(&found);
|
||||
explorer::sort_items(&mut items, Some("date"));
|
||||
assert_eq!(items[0].id, 2); // mais recente primeiro
|
||||
assert_eq!(items.last().unwrap().id, 4); // sem data por último
|
||||
}
|
||||
Reference in new issue
Block a user