diff --git a/CHANGELOG.md b/CHANGELOG.md index 7197583..97ecef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer. +## [1.4.0] - 2026-11-03 + +### Added +- **Browse by folder.** A Folders view next to grid and list shows what was found one folder at a time, like a file manager: subfolders first with their file count and size, then the files, under a clickable breadcrumb. The sidebar folder tree navigates in this view, and filters still apply. +- **Recover whole folders or parts of them.** Folders and files are ticked with tri-state boxes that persist while navigating; a ticked folder takes everything inside it, and unticked subfolders or files stay out. Each ticked folder is recreated in the destination from itself down (`Documents/Clients/Project` → `Project/...`), with the usual hash manifest. + ## [1.3.5] - 2026-10-31 ### Added diff --git a/CHANGELOG.pt-BR.md b/CHANGELOG.pt-BR.md index 3d6763b..cd00724 100644 --- a/CHANGELOG.pt-BR.md +++ b/CHANGELOG.pt-BR.md @@ -4,6 +4,12 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. +## [1.4.0] - 2026-11-03 + +### Adicionado +- **Navegação por pastas.** Uma visualização Pastas, ao lado de grade e lista, mostra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos: primeiro as subpastas, com número de arquivos e tamanho, depois os arquivos, sob um caminho clicável. A árvore de pastas da barra lateral navega nessa visualização, e os filtros continuam valendo. +- **Recuperar pastas inteiras ou partes delas.** Pastas e arquivos são marcados por caixas de três estados que continuam ao navegar; uma pasta marcada leva tudo o que está dentro dela, e subpastas ou arquivos desmarcados ficam de fora. Cada pasta marcada é recriada no destino a partir dela mesma (`Documentos/Clientes/Projeto` → `Projeto/...`), com o manifesto de hashes de sempre. + ## [1.3.5] - 2026-10-31 ### Adicionado diff --git a/Cargo.lock b/Cargo.lock index fbc5d6a..8a33440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -416,7 +416,7 @@ dependencies = [ [[package]] name = "guara-cli" -version = "1.3.5" +version = "1.4.0" dependencies = [ "clap", "ctrlc", @@ -429,7 +429,7 @@ dependencies = [ [[package]] name = "guara-core" -version = "1.3.5" +version = "1.4.0" dependencies = [ "aes", "argon2", diff --git a/Cargo.toml b/Cargo.toml index e75668c..e462cd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/guara-core", "crates/guara-cli"] [workspace.package] -version = "1.3.5" +version = "1.4.0" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara" diff --git a/README.md b/README.md index 5a90f3c..80e23f3 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f

The explorer in the dark theme Settings with the language selector

- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files. +- **Folder browsing:** open what was found one folder at a time, like a file manager, and recover whole folders or only some subfolders and files, recreated from the ticked folder down. + +

The Folders view: the root of the recovered files with DCIM ticked, Viagens partly ticked and the folder details on the right

+ - **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates. - **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click). - **Details panel** with preview, dates and metadata. diff --git a/README.pt-BR.md b/README.pt-BR.md index 19fc1f0..df37f82 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -77,6 +77,10 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando

O explorador no tema escuro Configurações com o seletor de idioma

- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos. +- **Navegação por pastas:** abra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos, e recupere pastas inteiras ou só algumas subpastas e arquivos, recriados a partir da pasta marcada. + +

A visualização Pastas: a raiz dos arquivos encontrados com DCIM marcada, Viagens parcialmente marcada e os detalhes da pasta à direita

+ - **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas. - **Seleção como no Finder ou Explorer** e **visualização rápida** em tela cheia (Espaço, Enter ou duplo clique). - **Painel de detalhes** com pré-visualização, datas e metadados. diff --git a/apps/gui/src-tauri/Cargo.lock b/apps/gui/src-tauri/Cargo.lock index 28a5186..e9ec621 100644 --- a/apps/gui/src-tauri/Cargo.lock +++ b/apps/gui/src-tauri/Cargo.lock @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "guara-core" -version = "1.3.5" +version = "1.4.0" dependencies = [ "aes", "argon2", @@ -1373,7 +1373,7 @@ dependencies = [ [[package]] name = "guara-gui" -version = "1.3.5" +version = "1.4.0" dependencies = [ "guara-core", "libc", diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index 4598033..96ea3eb 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "guara-gui" description = "Guará: graphical file recovery interface" -version = "1.3.5" +version = "1.4.0" edition = "2021" license = "MIT OR Apache-2.0" authors = ["Guará contributors"] diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json index d28711b..b5cb1cf 100644 --- a/apps/gui/src-tauri/tauri.conf.json +++ b/apps/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Guará", - "version": "1.3.5", + "version": "1.4.0", "identifier": "dev.guara.app", "build": { "frontendDist": "../ui" diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 88fc6ce..544be89 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; } .xp-insp .viewer { max-height: 38vh; min-height: 150px; } .xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; } .xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; } +/* folder browsing */ +.xp-path { display: flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; } +.xp-cpart { border: 0; background: none; color: var(--text-2); font: inherit; font-size: 14px; padding: 4px 6px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; } +.xp-cpart:hover { background: var(--surface-2); color: var(--text); } +.xp-cpart:last-child { color: var(--text); font-weight: 600; } +.xp-csep { color: var(--text-3); } +.xp-lth .xp-fico { color: var(--accent); background: var(--accent-soft); border-radius: 8px; } +.xp-dir .xp-lnm b { font-weight: 600; } +.xp-dir[data-state="mixed"] .xp-chk { opacity: 1; background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round'%3E%3Cpath d='M7 12h10'/%3E%3C/svg%3E") center / 14px no-repeat; } +.xp-lrow[data-cur="1"] { box-shadow: inset 0 0 0 2px var(--accent); } +.xp-folder-insp { display: grid; gap: 14px; padding: 20px 4px; } +.xp-folder-insp h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; } +.xp-fbig { width: 64px; height: 64px; display: grid; place-items: center; border-radius: 16px; color: var(--accent); background: var(--accent-soft); } +.xp-fbig svg { width: 34px; height: 34px; } +.xp-folder-insp .row { display: flex; gap: 8px; flex-wrap: wrap; } .xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; } .xp-insp-x { display: none; align-self: flex-end; } .xp-veil { display: none; } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 7ec58f7..201dd96 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -687,19 +687,25 @@ async function onProgressClick(e) { } async function recover(sel) { + // From folder browsing: each marked folder is recreated from itself down + // (a/b/Projeto -> Projeto/...), so there is no layout to choose. + const bySelection = !!sel.selection; const f = await askForm({ title: 'Recuperar para…', - text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.', + text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.' + + (bySelection ? ' Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.' : '') + + (bySelection && sel.filtered ? ' Atenção: pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.' : ''), ok: 'Recuperar', fields: [ { name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } }, - { name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }, + ...(bySelection ? [] : [{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }]), ], }); if (!f || !f.dest) return; const dest = f.dest; + const { filtered, ...target } = sel; try { - const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel }); + const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout || 'folders', ...target }); watchAux(job, 'Recuperando arquivos', (st) => { const r = st.report || {}; const ok = (r.recovered || []).length, bad = (r.failed || []).length; diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js index c64093f..5fef63d 100644 --- a/apps/gui/ui/explorer.js +++ b/apps/gui/ui/explorer.js @@ -4,7 +4,7 @@ // fullscreen quick look. import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; -import { LOC } from './i18n.js'; +import { LOC, tr } from './i18n.js'; const CAT = { image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'], @@ -28,6 +28,8 @@ const I = { pin: svg('', 14), warn: svg('', 22), close: svg('', 20), + folder: svg(''), + folderBig: svg('', 22), }; export function createExplorer(deps) { @@ -62,6 +64,14 @@ export function createExplorer(deps) { lastFound: -1, wasEnriching: false, status: null, + // folder browsing: current folder ('' = root, '@carved' = found by + // signature), its subfolders, and a selection of its own that keeps + // marked and unmarked folders and files across levels + path: '', + dirs: [], + curDir: null, + fs: { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }, + fsCount: null, }; try { E.view = localStorage.getItem('guara-view') || 'grid'; @@ -74,7 +84,7 @@ export function createExplorer(deps) {
-
+
@@ -112,7 +122,10 @@ export function createExplorer(deps) { }; const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v)); + const folderMode = () => E.view === 'folders'; + async function reload() { + if (folderMode()) return reloadFolder(); const token = ++E.reloadToken; E.lastReload = Date.now(); const base = { ...toQuery(), sort: E.sort }; @@ -132,6 +145,29 @@ export function createExplorer(deps) { paintAll(); } + async function reloadFolder() { + const token = ++E.reloadToken; + E.lastReload = Date.now(); + const { folder, ...filters } = toQuery(); + const base = { ...filters, sort: E.sort === 'date' || E.sort === 'date_asc' ? E.sort : E.sort || 'name' }; + let items = [], total = 0, dirs = null; + for (;;) { + let r; + try { r = await rpc('browse', { job, path: E.path, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; } + if (token !== E.reloadToken) return; + if (!dirs) dirs = r.folders; + items = items.concat(r.files); + total = r.total_files; + if (!r.files.length || items.length >= total || items.length >= 30000) break; + } + E.dirs = dirs || []; + E.items = items; + E.total = total; + paintCrumb(); + relayout(); + paintAll(); + } + async function loadFacets() { try { E.facets = await rpc('facets', { job }); } catch { return; } paintNav(); @@ -157,7 +193,7 @@ export function createExplorer(deps) { function geometry() { const W = Math.max(200, scroll.clientWidth - PAD * 2); - if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 }; + if (E.view === 'list' || E.view === 'folders') return { W, cols: 1, tw: W, cap: 0, rowH: 54 }; const T = E.tile; const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP))); const tw = Math.floor((W - GAP * (cols - 1)) / cols); @@ -176,6 +212,9 @@ export function createExplorer(deps) { } const rows = []; let y = PAD; + if (folderMode()) { + E.dirs.forEach((_, d) => { rows.push({ t: 'dir', y, h: G.rowH, d }); y += G.rowH; }); + } let cur = null, rowItems = []; const flush = () => { if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; } @@ -238,7 +277,7 @@ export function createExplorer(deps) { el.style.top = `${row.y}px`; el.style.height = `${row.h}px`; el.dataset.ri = ri; - el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join(''); + el.innerHTML = row.t === 'head' ? headHtml(row) : row.t === 'dir' ? dirHtml(row.d) : row.idx.map((i) => (E.view === 'grid' ? tileHtml(i) : listHtml(i))).join(''); E.rendered.set(ri, el); frag.appendChild(el); } @@ -246,7 +285,82 @@ export function createExplorer(deps) { } // ------------------------------------------------------------ items - const isSel = (id) => E.allFiltered || E.sel.has(id); + // `p` is inside `d` (or is `d`); '' holds everything, '@carved' only itself. + const under = (p, d) => d === '' || p === d || (d !== '@carved' && p.startsWith(d + '/')); + // Deepest marked/unmarked folder holding `p` decides, as in the core. + const covered = (p) => { + let best = null; + for (const d of E.fs.folders) if (under(p, d) && (best == null || d.length > best[0].length)) best = [d, true]; + for (const d of E.fs.xfolders) if (under(p, d) && (best == null || d.length >= best[0].length)) best = [d, false]; + return !!best && best[1]; + }; + const dirOf = (it) => (it.origin === 'carved' ? '@carved' : (it.dir || '').replace(/^\/+|\/+$/g, '')); + const fileOn = (it) => !E.fs.xfiles.has(it.id) && (E.fs.files.has(it.id) || covered(dirOf(it))); + // 'on', 'off' or 'mixed' for a folder row + const dirState = (d) => { + const on = covered(d); + const inside = (p) => p !== d && under(p, d); + const marks = on + ? [...E.fs.xfolders].some(inside) || [...E.fs.xfiles.values()].some((p) => under(p, d)) || [...E.fs.folders].some(inside) + : [...E.fs.folders].some(inside) || [...E.fs.files.values()].some((p) => under(p, d)); + return marks ? 'mixed' : on ? 'on' : 'off'; + }; + const fsEmpty = () => !E.fs.folders.size && !E.fs.files.size; + const dropInside = (d) => { + for (const k of [...E.fs.folders]) if (under(k, d)) E.fs.folders.delete(k); + for (const k of [...E.fs.xfolders]) if (under(k, d)) E.fs.xfolders.delete(k); + for (const [id, p] of [...E.fs.files]) if (under(p, d)) E.fs.files.delete(id); + for (const [id, p] of [...E.fs.xfiles]) if (under(p, d)) E.fs.xfiles.delete(id); + }; + // Like a file manager's tri-state box: a fully marked folder is unmarked; + // an unmarked or partly marked one becomes fully marked. + function toggleDir(d) { + const unmark = dirState(d) === 'on'; + dropInside(d); + if (unmark) { if (covered(d)) E.fs.xfolders.add(d); } else if (!covered(d)) E.fs.folders.add(d); + fsChanged(); + } + function toggleFile(it) { + const p = dirOf(it); + if (fileOn(it)) { E.fs.files.delete(it.id); if (covered(p)) E.fs.xfiles.set(it.id, p); } else { E.fs.xfiles.delete(it.id); if (!covered(p)) E.fs.files.set(it.id, p); } + fsChanged(); + } + const fsSelection = () => ({ + folders: [...E.fs.folders], ids: [...E.fs.files.keys()], exclude: [...E.fs.xfiles.keys()], exclude_folders: [...E.fs.xfolders], + }); + let countT; + function fsChanged() { + paintSel(); + clearTimeout(countT); + if (fsEmpty()) { E.fsCount = null; paintActions(); return; } + countT = setTimeout(async () => { + try { E.fsCount = await rpc('selection_count', { job, selection: fsSelection() }); } catch { E.fsCount = null; } + paintActions(); + }, 120); + } + function goTo(path) { + E.path = path; E.curDir = null; E.curId = null; + scroll.scrollTop = 0; + paintInspector(); + reload(); + } + const parentOf = (p) => (p === '@carved' || !p.includes('/') ? '' : p.slice(0, p.lastIndexOf('/'))); + const dirLabel = (p) => (p === '' ? 'Raiz' : p === '@carved' ? 'Por assinatura' : p.split('/').pop()); + + function dirHtml(d) { + const it = E.dirs[d]; + const st = dirState(it.path); + return `
+
${I.folderBig}
+
${esc(it.name)}${it.files.toLocaleString(LOC())} ${tr(it.files === 1 ? 'arquivo' : 'arquivos')}
+ ${human(it.bytes)} +
`; + } + + const isSel = (id) => { + if (folderMode()) { const it = E.items[idxOf(id)]; return !!it && fileOn(it); } + return E.allFiltered || E.sel.has(id); + }; function headHtml(row) { return `
${esc(dayLabel(row.key))}${row.count} ${row.count === 1 ? 'item' : 'itens'}
`; @@ -310,7 +424,15 @@ export function createExplorer(deps) { el.setAttribute('aria-selected', String(isSel(id))); if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur; }; - scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set); + scroll.querySelectorAll('.xp-tile, .xp-lrow:not(.xp-dir)').forEach(set); + scroll.querySelectorAll('.xp-dir').forEach((el) => { + const d = E.dirs[+el.dataset.dir]; + if (!d) return; + const st = dirState(d.path); + el.dataset.state = st; + el.setAttribute('aria-selected', String(st === 'on')); + if (E.curDir === d.path) el.dataset.cur = '1'; else delete el.dataset.cur; + }); paintActions(); } @@ -353,6 +475,21 @@ export function createExplorer(deps) { new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll); scroll.addEventListener('click', (e) => { + const dr = e.target.closest('.xp-dir'); + if (dr) { + const d = E.dirs[+dr.dataset.dir]; + if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleDir(d.path); + else { E.curDir = d.path; E.curId = null; paintSel(); paintInspector(); } + return; + } + if (folderMode()) { + const el = e.target.closest('.xp-lrow'); + if (!el) return; + const it = E.items[+el.dataset.i]; + if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleFile(it); + else { E.curDir = null; E.curId = it.id; paintSel(); schedInspector(); } + return; + } const hs = e.target.closest('[data-hsel]'); if (hs) { const start = +hs.dataset.hsel, n = +hs.dataset.n; @@ -368,10 +505,24 @@ export function createExplorer(deps) { else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey }); }); scroll.addEventListener('dblclick', (e) => { + const dr = e.target.closest('.xp-dir'); + if (dr) { if (!e.target.closest('[data-chk]')) goTo(E.dirs[+dr.dataset.dir].path); return; } + if (folderMode()) { + const el = e.target.closest('.xp-lrow'); + if (el && !e.target.closest('[data-chk]')) openLightbox(+el.dataset.i); + return; + } const el = e.target.closest('.xp-tile, .xp-lrow'); if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); } }); scroll.addEventListener('keydown', (e) => { + if (folderMode()) { + if ((e.key === 'Backspace' || (e.altKey && e.key === 'ArrowUp')) && E.path !== '') { e.preventDefault(); goTo(parentOf(E.path)); return; } + if (e.key === 'Enter' && E.curDir != null) { e.preventDefault(); goTo(E.curDir); return; } + if (e.key === ' ' && E.curDir != null) { e.preventDefault(); toggleDir(E.curDir); return; } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); toggleDir(E.path); return; } + if (e.key === 'Escape') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; } + } if (!E.items.length) return; let i = idxOf(E.curId); if (i < 0) i = 0; @@ -389,6 +540,19 @@ export function createExplorer(deps) { // ------------------------------------------------------------ action bar function paintActions() { + if (folderMode()) { + const c = E.fsCount; + const here = dirState(E.path); + const html = !fsEmpty() + ? `${c ? `${c.files.toLocaleString(LOC())} ${tr(c.files === 1 ? 'arquivo' : 'arquivos')} · ${human(c.bytes)}` : 'Calculando seleção…'} + + ${here !== 'on' ? `` : ''} + ` + : `${E.dirs.length.toLocaleString(LOC())} ${E.dirs.length === 1 ? 'pasta' : 'pastas'} · ${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'arquivo' : 'arquivos'} aqui + ${E.dirs.length || E.total ? '' : ''}`; + if (actions._h !== html) { actions._h = html; actions.innerHTML = html; } + return; + } const n = selCount(); const html = n ? `${n.toLocaleString(LOC())} selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''} @@ -401,6 +565,9 @@ export function createExplorer(deps) { } actions.addEventListener('click', (e) => { const a = e.target.closest('[data-a]')?.dataset.a; + if (folderMode() && a === 'clear') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; } + if (a === 'here') { if (dirState(E.path) !== 'on') { dropInside(E.path); if (!covered(E.path)) E.fs.folders.add(E.path); fsChanged(); } return; } + if (a === 'frec') { onRecover({ selection: fsSelection(), filtered: anyFilter() }); return; } if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); } else if (a === 'all') { E.allFiltered = true; paintSel(); } else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); @@ -461,6 +628,12 @@ export function createExplorer(deps) { if (!b) return; const F = E.filters; const f = b.dataset.f, v = b.dataset.v; + if (folderMode() && f === 'folder') { + if (!v.startsWith('@')) E.expanded.add('d' + v); + nav._h = null; paintNav(); + root.classList.remove('show-nav'); + return goTo(v === '@raiz' ? '' : v); + } if (f === 'all') clearFilters(false); else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); } else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; } @@ -490,6 +663,12 @@ export function createExplorer(deps) { } function paintCrumb() { + if (folderMode()) { + const parts = E.path === '' ? [] : E.path === '@carved' ? ['@carved'] : E.path.split('/').map((_, i, a) => a.slice(0, i + 1).join('/')); + const link = (p, label) => ``; + $('#xpCrumb').innerHTML = ``; + return; + } const F = E.filters, parts = []; if (F.category) parts.push(CAT[F.category][0]); if (F.year) parts.push(String(F.year)); @@ -528,6 +707,11 @@ export function createExplorer(deps) { changed(); }); + $('#xpCrumb').addEventListener('click', (e) => { + const b = e.target.closest('[data-go]'); + if (b && folderMode()) goTo(b.dataset.go); + }); + // ------------------------------------------------------------ top bar let qt; $('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); }); @@ -538,9 +722,11 @@ export function createExplorer(deps) { $('#xpSize').style.display = E.view === 'grid' ? '' : 'none'; }; host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => { + const was = folderMode(); E.view = b.dataset.view; try { localStorage.setItem('guara-view', E.view); } catch { /* */ } - paintView(); relayout(); + paintView(); + if (was !== folderMode()) { E.curId = null; E.curDir = null; E.dirs = []; paintCrumb(); paintInspector(); actions._h = null; reload(); } else relayout(); })); $('#xpSize').addEventListener('input', (e) => { E.tile = +e.target.value; @@ -561,7 +747,17 @@ export function createExplorer(deps) { function paintInspector() { stopPv?.(); stopPv = null; - const n = selCount(); + if (folderMode() && E.curDir != null) { + const d = E.dirs.find((x) => x.path === E.curDir); + const close = ``; + if (d) { + insp.innerHTML = `${close}
${I.folderBig}

${esc(d.name)}

+
Caminho
${esc(d.path === '@carved' ? 'Encontrados por assinatura (sem pasta conhecida)' : d.path)}
Arquivos
${d.files.toLocaleString(LOC())}
Tamanho
${human(d.bytes)}
+
`; + return; + } + } + const n = folderMode() ? 0 : selCount(); const it = E.items[idxOf(E.curId)]; const close = ``; if (n > 1) { @@ -623,6 +819,8 @@ export function createExplorer(deps) { else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); } else if (a === 'close') root.classList.remove('show-insp'); + else if (a === 'open' && E.curDir != null) goTo(E.curDir); + else if (a === 'mark' && E.curDir != null) { toggleDir(E.curDir); paintInspector(); } }); // ------------------------------------------------------------ fullscreen diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 2355d1c..f73db56 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -598,4 +598,25 @@ export const EN = { 'Reiniciando…': 'Restarting…', 'Atualização concluída': 'Update completed', 'Falha na atualização: {n}': 'Update failed: {n}', + 'Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.': 'Each marked folder is recreated in the destination from itself down, with all its subfolders. Files marked on their own go to the root of the destination.', + 'Atenção:': 'Note:', + 'pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.': 'marked folders are recovered whole; the active filters do not apply to them.', + 'Navegar pelas pastas': 'Browse folders', + 'Arquivos encontrados por assinatura, sem pasta conhecida': 'Files found by signature, with no known folder', + 'Calculando seleção…': 'Counting the selection…', + 'Marcar esta pasta': 'Mark this folder', + '{n} {n} · {n} {n} aqui': '{n} {n} · {n} {n} here', + 'Pasta atual': 'Current folder', + 'Encontrados por assinatura (sem pasta conhecida)': 'Found by signature (no known folder)', + 'Desmarcar pasta': 'Unmark folder', + 'Marcar pasta inteira': 'Mark whole folder', + 'Abrir pasta': 'Open folder', + 'Recuperar seleção': 'Recover selection', + 'Caminho': 'Path', + 'Arquivos': 'Files', + 'arquivo': 'file', + 'pastas': 'folders', + '{n} arquivo': '{n} file', + '{n} arquivos': '{n} files', + '{n} {n} · {n}': '{n} {n} · {n}', }; 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 201f64a..af78d1c 100644 --- a/crates/guara-core/src/explorer.rs +++ b/crates/guara-core/src/explorer.rs @@ -170,6 +170,140 @@ pub fn item_json(f: &Found) -> Value { }) } +/// Folder a file belongs to for browsing: `None` for signature-carved files, +/// `""` for the volume root. +fn browse_dir(f: &Found) -> Option<&str> { + if f.origin == Origin::Carved { + return None; + } + Some(f.dir.as_deref().unwrap_or("").trim_matches('/')) +} + +/// One level of the folder tree: the direct subfolders of `path` (with file and +/// byte totals over everything below them) and the files directly inside it, +/// paginated. `path` is `""` for the root or `"@carved"` for files found by +/// signature, which have no folder. Filters in `q` apply; its `folder` is ignored. +pub fn browse(found: &[Found], path: &str, q: &Query) -> Value { + let path = path.trim_matches('/'); + let q = Query { + folder: None, + ..q.clone() + }; + let selected = q.select(found); + let mut folders: BTreeMap<&str, (u64, u64)> = BTreeMap::new(); + let mut files: Vec<&Found> = Vec::new(); + let mut carved = (0u64, 0u64); + for f in selected { + let Some(dir) = browse_dir(f) else { + if path == "@carved" { + files.push(f); + } else if path.is_empty() { + carved.0 += 1; + carved.1 += f.size; + } + continue; + }; + if dir == path { + files.push(f); + continue; + } + let rest = if path.is_empty() { + dir + } else if let Some(rest) = dir.strip_prefix(path).and_then(|r| r.strip_prefix('/')) { + rest + } else { + continue; + }; + let child = rest.split('/').next().unwrap_or(rest); + let child_path = &dir[..dir.len() - rest.len() + child.len()]; + let entry = folders.entry(child_path).or_default(); + entry.0 += 1; + entry.1 += f.size; + } + let mut out: Vec = folders + .iter() + .map(|(p, (n, bytes))| { + json!({ "name": p.rsplit('/').next().unwrap_or(p), "path": p, "files": n, "bytes": bytes }) + }) + .collect(); + if carved.0 > 0 { + out.push(json!({ "name": "Por assinatura", "path": "@carved", "files": carved.0, "bytes": carved.1 })); + } + sort_items(&mut files, Some(q.sort.as_deref().unwrap_or("name"))); + let total = files.len(); + let page: Vec = files + .into_iter() + .skip(q.offset) + .take(q.limit.clamp(1, 5000)) + .map(item_json) + .collect(); + 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 or subfolders 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, + #[serde(default)] + pub exclude_folders: 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. For each file the +/// deepest marked or unmarked folder holding it decides; marked folders take +/// everything inside them, whatever filters the view had. A file marked on its +/// own is in, and 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(); + let marks: Vec<(&str, bool)> = sel + .folders + .iter() + .map(|d| (d.trim_matches('/'), true)) + .chain( + sel.exclude_folders + .iter() + .map(|d| (d.trim_matches('/'), false)), + ) + .collect(); + found + .iter() + .filter(|f| !exclude.contains(&f.id)) + .filter(|f| { + ids.contains(&f.id) + || marks + .iter() + .filter(|(d, _)| in_folder(f, d)) + .max_by_key(|(d, included)| (d.len(), !included)) + .is_some_and(|(_, included)| *included) + }) + .collect() +} + +/// Files and bytes a selection resolves to. +pub fn selection_count(found: &[Found], sel: &Selection) -> Value { + let files = resolve_selection(found, sel); + json!({ "files": files.len(), "bytes": files.iter().map(|f| f.size).sum::() }) +} + /// Facet counts for sidebar navigation. pub fn facets(found: &[Found]) -> Value { let mut cats: HashMap<&'static str, usize> = HashMap::new(); @@ -375,3 +509,180 @@ pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option { } Some(h) } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + vec![ + Found::test_file(1, "DCIM/100CANON", "a.jpg", 100), + Found::test_file(2, "DCIM", "b.jpg", 10), + Found::test_file(3, "", "x.txt", 5), + Found::test_carved(4, "c.jpg", 7), + Found::test_file(5, "DCIM/100CANON", "notes.txt", 1), + ] + } + + fn folders(v: &Value) -> Vec<(String, u64, u64)> { + v["folders"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["path"].as_str().unwrap().to_string(), + f["files"].as_u64().unwrap(), + f["bytes"].as_u64().unwrap(), + ) + }) + .collect() + } + + fn names(v: &Value) -> Vec { + v["files"] + .as_array() + .unwrap() + .iter() + .map(|f| f["name"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn browse_lists_direct_children() { + let v = browse(&sample(), "", &Query::default()); + assert_eq!( + folders(&v), + vec![("DCIM".to_string(), 3, 111), ("@carved".to_string(), 1, 7)] + ); + assert_eq!(v["folders"][1]["name"], "Por assinatura"); + assert_eq!(names(&v), vec!["x.txt"]); + assert_eq!(v["total_files"], 1); + } + + #[test] + fn browse_inside_folder() { + let v = browse(&sample(), "DCIM", &Query::default()); + assert_eq!(folders(&v), vec![("DCIM/100CANON".to_string(), 2, 101)]); + assert_eq!(v["folders"][0]["name"], "100CANON"); + assert_eq!(names(&v), vec!["b.jpg"]); + let carved = browse(&sample(), "@carved", &Query::default()); + assert!(folders(&carved).is_empty()); + assert_eq!(names(&carved), vec!["c.jpg"]); + } + + #[test] + fn browse_applies_filters() { + let q = Query { + category: Some(Category::Image), + ..Query::default() + }; + let v = browse(&sample(), "", &q); + assert_eq!( + folders(&v), + vec![("DCIM".to_string(), 2, 110), ("@carved".to_string(), 1, 7)] + ); + assert!(names(&v).is_empty()); + } + + #[test] + fn prefix_is_not_parent() { + let found = vec![ + Found::test_file(1, "Foto", "a.jpg", 1), + Found::test_file(2, "Fotos", "b.jpg", 1), + ]; + let v = browse(&found, "Foto", &Query::default()); + assert_eq!(names(&v), vec!["a.jpg"]); + 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![], + exclude_folders: 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], + exclude_folders: vec![], + }; + assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]); + } + + #[test] + fn excluded_subfolder_is_left_out() { + let found = sample(); + let sel = Selection { + folders: vec!["DCIM".into()], + exclude_folders: vec!["DCIM/100CANON".into()], + ..Selection::default() + }; + assert_eq!(ids(resolve_selection(&found, &sel)), vec![2]); + // a folder marked again inside the excluded one comes back + let sel = Selection { + folders: vec!["".into(), "DCIM/100CANON".into()], + exclude_folders: vec!["DCIM".into()], + ..Selection::default() + }; + assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 4, 5]); + assert_eq!( + selection_count(&found, &sel), + json!({ "files": 4, "bytes": 113 }) + ); + } + + #[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) + .map(|i| Found::test_file(i, "big", &format!("f{i:03}.txt"), 1)) + .collect(); + let q = Query { + limit: 100, + offset: 200, + ..Query::default() + }; + let v = browse(&found, "big", &q); + assert_eq!(v["total_files"], 250); + assert_eq!(names(&v).len(), 50); + assert_eq!(names(&v)[0], "f200.txt"); + } +} 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 67c4863..51a89f0 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -359,6 +359,13 @@ impl Engine { Ok(json!({ "total": total, "items": page })) } + /// One level of the folder tree (see [`crate::explorer::browse`]). + pub fn browse(&self, id: &str, path: &str, q: &Query) -> Result { + let j = self.job(id)?; + let s = j.st.lock().unwrap(); + Ok(crate::explorer::browse(&s.found, path, q)) + } + /// Sidebar navigation facet counts (categories, dates, folders, cameras). pub fn facets(&self, id: &str) -> Result { let j = self.job(id)?; @@ -463,6 +470,7 @@ impl Engine { scan_id: &str, ids: Option>, filter: Option, + selection: Option, dest: &str, layout: recover::RecoverLayout, ) -> Result { @@ -470,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")); } @@ -526,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, @@ -1232,6 +1251,28 @@ impl Engine { .and_then(|v| v.as_u64()) .ok_or_else(|| Error::msg("id ausente"))? )?), + "browse" => { + let q: Query = p + .get("query") + .cloned() + .filter(|v| !v.is_null()) + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let path = p.get("path").and_then(|v| v.as_str()).unwrap_or(""); + self.browse(&s("job")?, path, &q)? + } + "selection_count" => { + let sel: crate::explorer::Selection = p + .get("selection") + .cloned() + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let j = self.job(&s("job")?)?; + let st = j.st.lock().unwrap(); + crate::explorer::selection_count(&st.found, &sel) + } "results" => { let q: Query = p .get("query") @@ -1265,7 +1306,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/src/types.rs b/crates/guara-core/src/types.rs index c0ce3ad..0fa5b49 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -510,3 +510,40 @@ impl Default for ScanOptions { } } } + +#[cfg(test)] +impl Found { + /// Minimal resident file for unit tests: `dir` `""` means the volume root. + pub(crate) fn test_file(id: u64, dir: &str, name: &str, size: u64) -> Self { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + Found { + id, + name: name.into(), + dir: Some(dir.into()), + category: crate::signatures::category_for_ext(&ext), + ext, + size, + origin: Origin::Fat, + confidence: Confidence::High, + data: DataLoc::Resident { bytes: Vec::new() }, + modified: None, + created: None, + accessed: None, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, + note: None, + } + } + + /// The same, found by signature (no folder). + pub(crate) fn test_carved(id: u64, name: &str, size: u64) -> Self { + Found { + dir: None, + origin: Origin::Carved, + ..Self::test_file(id, "", name, size) + } + } +} diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index 10c0c20..3100a4e 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,121 @@ 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")], + exclude_folders: vec![], + }; + 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}"); +} diff --git a/docs/img/explorer-dark-en.png b/docs/img/explorer-dark-en.png index 2d97aac..19a8199 100644 Binary files a/docs/img/explorer-dark-en.png and b/docs/img/explorer-dark-en.png differ diff --git a/docs/img/explorer-dark-pt.png b/docs/img/explorer-dark-pt.png index 15c24d3..3ddfce2 100644 Binary files a/docs/img/explorer-dark-pt.png and b/docs/img/explorer-dark-pt.png differ diff --git a/docs/img/explorer-light-en.png b/docs/img/explorer-light-en.png index 4f8b2c5..664192b 100644 Binary files a/docs/img/explorer-light-en.png and b/docs/img/explorer-light-en.png differ diff --git a/docs/img/explorer-light-pt.png b/docs/img/explorer-light-pt.png index 412957e..ea239d0 100644 Binary files a/docs/img/explorer-light-pt.png and b/docs/img/explorer-light-pt.png differ diff --git a/docs/img/folders-light-en.png b/docs/img/folders-light-en.png new file mode 100644 index 0000000..49c00f3 Binary files /dev/null and b/docs/img/folders-light-en.png differ diff --git a/docs/img/folders-light-pt.png b/docs/img/folders-light-pt.png new file mode 100644 index 0000000..9aada8e Binary files /dev/null and b/docs/img/folders-light-pt.png differ diff --git a/docs/interface.md b/docs/interface.md index 88b0e1e..118dc34 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -11,12 +11,22 @@ Guará has a desktop app (Tauri) and the same front end in the browser with `gua After a scan, results open in a file-manager style explorer built for thousands of items: - **Left:** navigation by type, real photo date (EXIF, by year and month), folder, camera, confidence and GPS, with an option to hide small files and duplicates. -- **Center:** thumbnail grid or list, grouped by day or month, with virtual scrolling. A filter chip shows what is active and clears it in one click. +- **Center:** thumbnail grid or list, grouped by day or month, with virtual scrolling, or the **Folders** view (below) to browse one folder at a time. A filter chip shows what is active and clears it in one click. - **Right:** details panel with the preview, folder, dates (taken, modified, created, accessed), camera, resolution and the file metadata. The badge shows the **confidence** of the file (high, medium or low). - **Bottom bar:** selection count and the recovery buttons. Recovery always goes to another folder and writes a manifest with MD5, SHA-1 and SHA-256. Arrow keys move through the files, and Space opens the full-screen quick look. Selection works like Finder or Explorer (Ctrl, Shift, keyboard). +### Browsing by folder + +

The Folders view: the root of the recovered files with DCIM ticked, Viagens partly ticked, the selection total at the bottom and the folder details on the right

+ +The third view button (the folder icon, next to grid and list) turns the explorer into a file manager over what was found. The center shows **one folder at a time**: its subfolders first, each with how many files and how much space it holds counting everything below it, then the files directly inside. A breadcrumb at the top (`Root › Documents › Project`) goes back up; double-click or Enter opens a folder, Backspace or Alt+↑ goes up, and clicking a folder in the sidebar tree jumps to it. Files found only by signature, which have no known folder, sit in **By signature** at the root. Filters (type, date, confidence…) still apply and change the counts. + +To recover, tick folders and files with their boxes; the selection survives navigation. Ticking a folder takes **everything inside it**, at any depth, even what the active filters hide. Unticking a subfolder or a file inside a ticked folder leaves the folder half-ticked and keeps that item out. The bottom bar shows the exact total, and **Recover selection** recreates each ticked folder in the destination **from itself down**: recovering `Documents/Clients/Project` writes `Project/...` with all its subfolders. Files ticked on their own go to the root of the destination, and two ticked folders with the same name become `Project` and `Project (2)`. The manifest with MD5, SHA-1 and SHA-256 is written as always. + +Only folders that hold at least one recovered file appear: Guará knows folders through the paths of the files it finds. + ## Themes The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved. diff --git a/docs/pt-BR/interface.md b/docs/pt-BR/interface.md index 5cad278..bb7a4a7 100644 --- a/docs/pt-BR/interface.md +++ b/docs/pt-BR/interface.md @@ -11,12 +11,22 @@ O Guará tem um app desktop (Tauri) e o mesmo front-end no navegador com `guara Depois da varredura, os resultados abrem num explorador no estilo de um gerenciador de arquivos, pensado para milhares de itens: - **Esquerda:** navegação por tipo, data real da foto (EXIF, por ano e mês), pasta, câmera, confiança e GPS, com opção de ocultar arquivos pequenos e duplicadas. -- **Centro:** grade de miniaturas ou lista, agrupadas por dia ou mês, com rolagem virtual. Uma etiqueta mostra o filtro ativo e o remove com um clique. +- **Centro:** grade de miniaturas ou lista, agrupadas por dia ou mês, com rolagem virtual, ou a visualização **Pastas** (abaixo) para navegar uma pasta por vez. Uma etiqueta mostra o filtro ativo e o remove com um clique. - **Direita:** painel de detalhes com a pré-visualização, pasta, datas (tirada, modificada, criada, acessada), câmera, resolução e os metadados do arquivo. O selo mostra a **confiança** do arquivo (alta, média ou baixa). - **Barra inferior:** quantidade selecionada e os botões de recuperação. A recuperação sempre vai para outra pasta e grava um manifesto com MD5, SHA-1 e SHA-256. As setas navegam pelos arquivos e a barra de espaço abre a visualização rápida em tela cheia. A seleção funciona como no Finder ou no Explorer (Ctrl, Shift e teclado). +### Navegando por pastas + +

A visualização Pastas: a raiz dos arquivos encontrados com DCIM marcada, Viagens parcialmente marcada, o total da seleção embaixo e os detalhes da pasta à direita

+ +O terceiro botão de visualização (o ícone de pasta, ao lado de grade e lista) transforma o explorador num gerenciador de arquivos sobre o que foi encontrado. O centro mostra **uma pasta por vez**: primeiro as subpastas, cada uma com quantos arquivos e quanto espaço contém somando tudo abaixo dela, depois os arquivos que estão diretamente nela. Um caminho no topo (`Raiz › Documentos › Projeto`) volta para cima; duplo clique ou Enter abre uma pasta, Backspace ou Alt+↑ sobe, e clicar numa pasta da árvore lateral vai direto até ela. Arquivos achados só por assinatura, que não têm pasta conhecida, ficam em **Por assinatura** na raiz. Os filtros (tipo, data, confiança…) continuam valendo e mudam as contagens. + +Para recuperar, marque pastas e arquivos pelas caixas; a seleção continua ao navegar. Marcar uma pasta leva **tudo o que está dentro dela**, em qualquer profundidade, inclusive o que os filtros ativos escondem. Desmarcar uma subpasta ou um arquivo dentro de uma pasta marcada deixa a pasta parcialmente marcada e mantém aquele item de fora. A barra de baixo mostra o total exato, e **Recuperar seleção** recria cada pasta marcada no destino **a partir dela mesma**: recuperar `Documentos/Clientes/Projeto` grava `Projeto/...` com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino, e duas pastas marcadas com o mesmo nome viram `Projeto` e `Projeto (2)`. O manifesto com MD5, SHA-1 e SHA-256 é gravado como sempre. + +Só aparecem pastas que contêm pelo menos um arquivo recuperado: o Guará conhece as pastas pelos caminhos dos arquivos que encontra. + ## Temas O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva.