Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5b972650 | ||
|
|
c5f21f749d | ||
|
|
bf93ca01b4 | ||
|
|
00aceecf70 | ||
|
|
ca677a8bc1 | ||
|
|
b274f0b7ef | ||
|
|
7683967650 | ||
|
|
43e9860b41 | ||
|
|
81f067abdb | ||
|
|
cdda2bc45f |
No files matched your search
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
|
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
|
||||||
|
- **One-click desktop update.** The "Update now" button actually downloads, verifies (SHA-256) and installs the new version now, instead of only linking to the Releases page. Works for all three desktop packages: AppImage (atomic self-replace), `.deb` (`dpkg -i`, no new password prompt — the app is already running as root/administrator by the time you click it), and the Windows installer (silent `/S` install). On Linux, the app restarts itself automatically; on Windows, the installer runs silently but the app does not reopen on its own yet — start it again once the install finishes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The repository moved to the Ciberfauna organization** (`ciberfauna/guara`, was `lucasrguerra/guara`). **Important notice for existing installations:** versions up to 1.3.4 have the old location baked into their update check; they will not find this release automatically. Download 1.3.5 once manually from the new Releases page, and future update checks will work through the new location.
|
||||||
|
|
||||||
## [1.3.4] - 2026-10-31
|
## [1.3.4] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
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
|
||||||
|
- **Atualização do app desktop em um clique.** O botão "Atualizar agora" agora baixa, confere (SHA-256) e instala a versão nova de verdade, em vez de só linkar pra página de Releases. Funciona nos três pacotes do desktop: AppImage (substituição atômica), `.deb` (`dpkg -i`, sem pedir senha de novo — o app já roda como root/administrador quando você clica), e o instalador do Windows (instalação silenciosa `/S`). No Linux o app reabre sozinho; no Windows o instalador roda silencioso mas o app ainda não reabre por conta própria — abra de novo depois que a instalação terminar.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- **O repositório se mudou pra organização Ciberfauna** (`ciberfauna/guara`, era `lucasrguerra/guara`). **Aviso importante pra instalações existentes:** versões até a 1.3.4 têm o endereço antigo fixo na checagem de atualização e não vão encontrar esta versão sozinhas. Baixe a 1.3.5 manualmente uma vez na nova página de Releases, e as próximas verificações voltam a funcionar pelo novo endereço.
|
||||||
|
|
||||||
## [1.3.4] - 2026-10-31
|
## [1.3.4] - 2026-10-31
|
||||||
|
|
||||||
### Corrigido
|
### Corrigido
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -429,7 +429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ resolver = "2"
|
|||||||
members = ["crates/guara-core", "crates/guara-cli"]
|
members = ["crates/guara-core", "crates/guara-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
|
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
|
||||||
authors = ["Guará contributors"]
|
authors = ["Guará contributors"]
|
||||||
rust-version = "1.80"
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
|
|||||||
<p align="center"><img src="docs/img/explorer-dark-en.png" width="640" alt="The explorer in the dark theme"> <img src="docs/img/settings-language-en.png" width="250" alt="Settings with the language selector"></p>
|
<p align="center"><img src="docs/img/explorer-dark-en.png" width="640" alt="The explorer in the dark theme"> <img src="docs/img/settings-language-en.png" width="250" alt="Settings with the language selector"></p>
|
||||||
|
|
||||||
- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files.
|
- **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.
|
||||||
|
|
||||||
|
<p align="center"><img src="docs/img/folders-light-en.png" width="720" alt="The Folders view: the root of the recovered files with DCIM ticked, Viagens partly ticked and the folder details on the right"></p>
|
||||||
|
|
||||||
- **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates.
|
- **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).
|
- **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
|
||||||
- **Details panel** with preview, dates and metadata.
|
- **Details panel** with preview, dates and metadata.
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
|
|||||||
<p align="center"><img src="docs/img/explorer-dark-pt.png" width="640" alt="O explorador no tema escuro"> <img src="docs/img/settings-language-pt.png" width="250" alt="Configurações com o seletor de idioma"></p>
|
<p align="center"><img src="docs/img/explorer-dark-pt.png" width="640" alt="O explorador no tema escuro"> <img src="docs/img/settings-language-pt.png" width="250" alt="Configurações com o seletor de idioma"></p>
|
||||||
|
|
||||||
- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos.
|
- **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.
|
||||||
|
|
||||||
|
<p align="center"><img src="docs/img/folders-light-pt.png" width="720" alt="A visualização Pastas: a raiz dos arquivos encontrados com DCIM marcada, Viagens parcialmente marcada e os detalhes da pasta à direita"></p>
|
||||||
|
|
||||||
- **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas.
|
- **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).
|
- **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.
|
- **Painel de detalhes** com pré-visualização, datas e metadados.
|
||||||
|
|||||||
@@ -1342,7 +1342,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1373,7 +1373,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"guara-core",
|
"guara-core",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
description = "Guará: graphical file recovery interface"
|
description = "Guará: graphical file recovery interface"
|
||||||
version = "1.3.4"
|
version = "1.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
authors = ["Guará contributors"]
|
authors = ["Guará contributors"]
|
||||||
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
|
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
|
||||||
rust-version = "1.80"
|
rust-version = "1.80"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
|
|||||||
@@ -28,8 +28,19 @@ fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
|
||||||
state.0.update_apply().map_err(|e| e.to_string())
|
use tauri::Emitter;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
let exe = std::env::current_exe().unwrap_or_default();
|
||||||
|
|
||||||
|
state
|
||||||
|
.0
|
||||||
|
.update_apply_desktop(&exe, move |phase| {
|
||||||
|
let _ = app.emit("update-progress", serde_json::json!({ "phase": phase }));
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when the process already has sufficient privileges to read raw disks.
|
/// `true` when the process already has sufficient privileges to read raw disks.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Guará",
|
"productName": "Guará",
|
||||||
"version": "1.3.4",
|
"version": "1.4.0",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
|
|||||||
@@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
|
.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-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; }
|
.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-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-insp-x { display: none; align-self: flex-end; }
|
||||||
.xp-veil { display: none; }
|
.xp-veil { display: none; }
|
||||||
|
|||||||
@@ -687,19 +687,25 @@ async function onProgressClick(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function recover(sel) {
|
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({
|
const f = await askForm({
|
||||||
title: 'Recuperar para…',
|
title: 'Recuperar para…',
|
||||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
|
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>'
|
||||||
|
+ (bySelection ? ' <span class="sub">Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.</span>' : '')
|
||||||
|
+ (bySelection && sel.filtered ? ' <span class="sub"><b>Atenção:</b> pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.</span>' : ''),
|
||||||
ok: 'Recuperar',
|
ok: 'Recuperar',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
{ 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;
|
if (!f || !f.dest) return;
|
||||||
const dest = f.dest;
|
const dest = f.dest;
|
||||||
|
const { filtered, ...target } = sel;
|
||||||
try {
|
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) => {
|
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||||
const r = st.report || {};
|
const r = st.report || {};
|
||||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||||
@@ -750,7 +756,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
|
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
|
||||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
|
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
|
||||||
d.querySelector('[name=lang]').value = SETTINGS.lang;
|
d.querySelector('[name=lang]').value = SETTINGS.lang;
|
||||||
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
|
d.querySelector('#updNow')?.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (UPD) applyUpdate();
|
||||||
|
else checkUpdate(true);
|
||||||
|
});
|
||||||
d.onclose = () => {
|
d.onclose = () => {
|
||||||
if (d.returnValue !== 'ok') return;
|
if (d.returnValue !== 'ok') return;
|
||||||
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
|
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
|
||||||
@@ -768,6 +778,44 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
// The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
|
// The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
|
||||||
// No-op in browser mode (`guara ui`).
|
// No-op in browser mode (`guara ui`).
|
||||||
let UPD = null;
|
let UPD = null;
|
||||||
|
let updating = false;
|
||||||
|
|
||||||
|
async function applyUpdate() {
|
||||||
|
if (updating || !tauri?.core?.invoke) return;
|
||||||
|
updating = true;
|
||||||
|
const updApply = $('#updApply');
|
||||||
|
const updNow = $('#updNow');
|
||||||
|
const setPhase = (txt) => {
|
||||||
|
if (updApply) { updApply.disabled = true; updApply.textContent = txt; }
|
||||||
|
if (updNow) { updNow.disabled = true; updNow.textContent = txt; }
|
||||||
|
};
|
||||||
|
setPhase('Baixando a atualização…');
|
||||||
|
|
||||||
|
let unlisten = null;
|
||||||
|
if (tauri?.event?.listen) {
|
||||||
|
try {
|
||||||
|
unlisten = await tauri.event.listen('update-progress', (event) => {
|
||||||
|
const phase = event.payload?.phase;
|
||||||
|
if (phase === 'downloading') setPhase('Baixando a atualização…');
|
||||||
|
else if (phase === 'verifying') setPhase('Verificando integridade…');
|
||||||
|
else if (phase === 'installing') setPhase('Instalando a atualização…');
|
||||||
|
else if (phase === 'restarting') setPhase('Reiniciando…');
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tauri.core.invoke('update_apply');
|
||||||
|
setPhase('Atualização concluída');
|
||||||
|
} catch (e) {
|
||||||
|
updating = false;
|
||||||
|
if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; }
|
||||||
|
if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; }
|
||||||
|
toast(`Falha na atualização: ${e}`);
|
||||||
|
} finally {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkUpdate(manual) {
|
async function checkUpdate(manual) {
|
||||||
if (!tauri?.core?.invoke) return;
|
if (!tauri?.core?.invoke) return;
|
||||||
@@ -801,11 +849,13 @@ function renderUpdateCard() {
|
|||||||
if (!UPD) { el?.remove(); return; }
|
if (!UPD) { el?.remove(); return; }
|
||||||
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
|
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
|
||||||
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
|
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
|
||||||
<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">${esc(UPD.url)}</p>
|
<p class="sub">Instalação automática da nova versão com um clique:</p>
|
||||||
<div class="upd-actions">
|
<div class="upd-actions">
|
||||||
|
<button class="btn primary sm" id="updApply">Atualizar agora</button>
|
||||||
<button class="btn sm" id="updLater">Depois</button>
|
<button class="btn sm" id="updLater">Depois</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
||||||
|
$('#updApply')?.addEventListener('click', () => applyUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ close while scan is running
|
// ------------------------------------------------------------------ close while scan is running
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// fullscreen quick look.
|
// fullscreen quick look.
|
||||||
|
|
||||||
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
||||||
import { LOC } from './i18n.js';
|
import { LOC, tr } from './i18n.js';
|
||||||
|
|
||||||
const CAT = {
|
const CAT = {
|
||||||
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
|
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
|
||||||
@@ -28,6 +28,8 @@ const I = {
|
|||||||
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
||||||
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
||||||
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
||||||
|
folder: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
|
||||||
|
folderBig: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>', 22),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createExplorer(deps) {
|
export function createExplorer(deps) {
|
||||||
@@ -62,6 +64,14 @@ export function createExplorer(deps) {
|
|||||||
lastFound: -1,
|
lastFound: -1,
|
||||||
wasEnriching: false,
|
wasEnriching: false,
|
||||||
status: null,
|
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 {
|
try {
|
||||||
E.view = localStorage.getItem('guara-view') || 'grid';
|
E.view = localStorage.getItem('guara-view') || 'grid';
|
||||||
@@ -74,7 +84,7 @@ export function createExplorer(deps) {
|
|||||||
<div class="xp-crumb" id="xpCrumb"></div>
|
<div class="xp-crumb" id="xpCrumb"></div>
|
||||||
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
||||||
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
||||||
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
|
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button><button data-view="folders" aria-label="Pastas" title="Navegar pelas pastas">${I.folder}</button></div>
|
||||||
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
||||||
</div>
|
</div>
|
||||||
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
||||||
@@ -112,7 +122,10 @@ export function createExplorer(deps) {
|
|||||||
};
|
};
|
||||||
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
||||||
|
|
||||||
|
const folderMode = () => E.view === 'folders';
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
if (folderMode()) return reloadFolder();
|
||||||
const token = ++E.reloadToken;
|
const token = ++E.reloadToken;
|
||||||
E.lastReload = Date.now();
|
E.lastReload = Date.now();
|
||||||
const base = { ...toQuery(), sort: E.sort };
|
const base = { ...toQuery(), sort: E.sort };
|
||||||
@@ -132,6 +145,29 @@ export function createExplorer(deps) {
|
|||||||
paintAll();
|
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() {
|
async function loadFacets() {
|
||||||
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
||||||
paintNav();
|
paintNav();
|
||||||
@@ -157,7 +193,7 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
function geometry() {
|
function geometry() {
|
||||||
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
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 T = E.tile;
|
||||||
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
||||||
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
||||||
@@ -176,6 +212,9 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let y = PAD;
|
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 = [];
|
let cur = null, rowItems = [];
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
|
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.top = `${row.y}px`;
|
||||||
el.style.height = `${row.h}px`;
|
el.style.height = `${row.h}px`;
|
||||||
el.dataset.ri = ri;
|
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);
|
E.rendered.set(ri, el);
|
||||||
frag.appendChild(el);
|
frag.appendChild(el);
|
||||||
}
|
}
|
||||||
@@ -246,7 +285,82 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ items
|
// ------------------------------------------------------------ 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 `<div class="xp-lrow xp-dir" role="option" data-dir="${d}" aria-selected="${st === 'on'}" data-state="${st}" ${E.curDir === it.path ? 'data-cur="1"' : ''} title="${esc(it.path === '@carved' ? 'Arquivos encontrados por assinatura, sem pasta conhecida' : it.path)}">
|
||||||
|
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth"><span class="xp-lico xp-fico">${I.folderBig}</span></div>
|
||||||
|
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${it.files.toLocaleString(LOC())} ${tr(it.files === 1 ? 'arquivo' : 'arquivos')}</small></div>
|
||||||
|
<span class="xp-lcol"></span><span class="xp-lcol num">${human(it.bytes)}</span><span></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function headHtml(row) {
|
||||||
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
||||||
@@ -310,7 +424,15 @@ export function createExplorer(deps) {
|
|||||||
el.setAttribute('aria-selected', String(isSel(id)));
|
el.setAttribute('aria-selected', String(isSel(id)));
|
||||||
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
|
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();
|
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);
|
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
|
||||||
|
|
||||||
scroll.addEventListener('click', (e) => {
|
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]');
|
const hs = e.target.closest('[data-hsel]');
|
||||||
if (hs) {
|
if (hs) {
|
||||||
const start = +hs.dataset.hsel, n = +hs.dataset.n;
|
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 });
|
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
|
||||||
});
|
});
|
||||||
scroll.addEventListener('dblclick', (e) => {
|
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');
|
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||||
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
||||||
});
|
});
|
||||||
scroll.addEventListener('keydown', (e) => {
|
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;
|
if (!E.items.length) return;
|
||||||
let i = idxOf(E.curId);
|
let i = idxOf(E.curId);
|
||||||
if (i < 0) i = 0;
|
if (i < 0) i = 0;
|
||||||
@@ -389,6 +540,19 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
// ------------------------------------------------------------ action bar
|
// ------------------------------------------------------------ action bar
|
||||||
function paintActions() {
|
function paintActions() {
|
||||||
|
if (folderMode()) {
|
||||||
|
const c = E.fsCount;
|
||||||
|
const here = dirState(E.path);
|
||||||
|
const html = !fsEmpty()
|
||||||
|
? `<span class="info">${c ? `<b>${c.files.toLocaleString(LOC())}</b> ${tr(c.files === 1 ? 'arquivo' : 'arquivos')} · ${human(c.bytes)}` : 'Calculando seleção…'}</span>
|
||||||
|
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||||
|
${here !== 'on' ? `<button class="btn sm" data-a="here">Marcar esta pasta</button>` : ''}
|
||||||
|
<button class="btn primary" data-a="frec" ${c && c.files ? '' : 'disabled'}>Recuperar seleção</button>`
|
||||||
|
: `<span class="info">${E.dirs.length.toLocaleString(LOC())} ${E.dirs.length === 1 ? 'pasta' : 'pastas'} · ${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'arquivo' : 'arquivos'} aqui</span>
|
||||||
|
${E.dirs.length || E.total ? '<button class="btn primary" data-a="here">Marcar esta pasta</button>' : ''}`;
|
||||||
|
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const n = selCount();
|
const n = selCount();
|
||||||
const html = n
|
const html = n
|
||||||
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
||||||
@@ -401,6 +565,9 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
actions.addEventListener('click', (e) => {
|
actions.addEventListener('click', (e) => {
|
||||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
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(); }
|
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||||
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
||||||
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||||
@@ -461,6 +628,12 @@ export function createExplorer(deps) {
|
|||||||
if (!b) return;
|
if (!b) return;
|
||||||
const F = E.filters;
|
const F = E.filters;
|
||||||
const f = b.dataset.f, v = b.dataset.v;
|
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);
|
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 === '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; }
|
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() {
|
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) => `<button class="xp-cpart" data-go="${esc(p)}">${esc(label)}</button>`;
|
||||||
|
$('#xpCrumb').innerHTML = `<nav class="xp-path" aria-label="Pasta atual">${[link('', 'Raiz'), ...parts.map((p) => link(p, dirLabel(p)))].join('<span class="xp-csep">›</span>')}</nav>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const F = E.filters, parts = [];
|
const F = E.filters, parts = [];
|
||||||
if (F.category) parts.push(CAT[F.category][0]);
|
if (F.category) parts.push(CAT[F.category][0]);
|
||||||
if (F.year) parts.push(String(F.year));
|
if (F.year) parts.push(String(F.year));
|
||||||
@@ -528,6 +707,11 @@ export function createExplorer(deps) {
|
|||||||
changed();
|
changed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#xpCrumb').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-go]');
|
||||||
|
if (b && folderMode()) goTo(b.dataset.go);
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------ top bar
|
// ------------------------------------------------------------ top bar
|
||||||
let qt;
|
let qt;
|
||||||
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
|
$('#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';
|
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
|
||||||
};
|
};
|
||||||
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
||||||
|
const was = folderMode();
|
||||||
E.view = b.dataset.view;
|
E.view = b.dataset.view;
|
||||||
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
|
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) => {
|
$('#xpSize').addEventListener('input', (e) => {
|
||||||
E.tile = +e.target.value;
|
E.tile = +e.target.value;
|
||||||
@@ -561,7 +747,17 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
function paintInspector() {
|
function paintInspector() {
|
||||||
stopPv?.(); stopPv = null;
|
stopPv?.(); stopPv = null;
|
||||||
const n = selCount();
|
if (folderMode() && E.curDir != null) {
|
||||||
|
const d = E.dirs.find((x) => x.path === E.curDir);
|
||||||
|
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||||
|
if (d) {
|
||||||
|
insp.innerHTML = `${close}<div class="xp-folder-insp"><div class="xp-fbig">${I.folderBig}</div><h3>${esc(d.name)}</h3>
|
||||||
|
<dl class="kv"><dt>Caminho</dt><dd>${esc(d.path === '@carved' ? 'Encontrados por assinatura (sem pasta conhecida)' : d.path)}</dd><dt>Arquivos</dt><dd>${d.files.toLocaleString(LOC())}</dd><dt>Tamanho</dt><dd>${human(d.bytes)}</dd></dl>
|
||||||
|
<div class="row"><button class="btn" data-i="open">Abrir pasta</button><button class="btn primary" data-i="mark">${dirState(d.path) === 'on' ? 'Desmarcar pasta' : 'Marcar pasta inteira'}</button></div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = folderMode() ? 0 : selCount();
|
||||||
const it = E.items[idxOf(E.curId)];
|
const it = E.items[idxOf(E.curId)];
|
||||||
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||||
if (n > 1) {
|
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 === '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 === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
|
||||||
else if (a === 'close') root.classList.remove('show-insp');
|
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
|
// ------------------------------------------------------------ fullscreen
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ export const EN = {
|
|||||||
'Atualizando o Guará…': 'Updating Guará…',
|
'Atualizando o Guará…': 'Updating Guará…',
|
||||||
'Baixando a atualização…': 'Downloading the update…',
|
'Baixando a atualização…': 'Downloading the update…',
|
||||||
'Baixando… {n}%': 'Downloading… {n}%',
|
'Baixando… {n}%': 'Downloading… {n}%',
|
||||||
'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.',
|
'Instalando a atualização…': 'Installing the update…',
|
||||||
'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
|
'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
|
||||||
'Atualizações automáticas': 'Automatic updates',
|
'Atualizações automáticas': 'Automatic updates',
|
||||||
'Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks Gitea for a new version (one request, nothing of yours is sent)',
|
'Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks Gitea for a new version (one request, nothing of yours is sent)',
|
||||||
@@ -593,4 +593,30 @@ export const EN = {
|
|||||||
'Nova versão {n} disponível': 'New version {n} available',
|
'Nova versão {n} disponível': 'New version {n} available',
|
||||||
'Você já está na versão mais recente': 'You are already on the latest version',
|
'Você já está na versão mais recente': 'You are already on the latest version',
|
||||||
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
|
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
|
||||||
|
'Instalação automática da nova versão com um clique:': 'One-click automatic installation of the new version:',
|
||||||
|
'Verificando integridade…': 'Verifying checksum…',
|
||||||
|
'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}',
|
||||||
};
|
};
|
||||||
@@ -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!();
|
||||||
|
|||||||
@@ -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<Value> = 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<Value> = 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ids: Vec<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude_folders: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u64> = sel.ids.iter().copied().collect();
|
||||||
|
let exclude: std::collections::HashSet<u64> = 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::<u64>() })
|
||||||
|
}
|
||||||
|
|
||||||
/// 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();
|
||||||
@@ -375,3 +509,180 @@ pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
Some(h)
|
Some(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> Vec<Found> {
|
||||||
|
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<String> {
|
||||||
|
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<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![],
|
||||||
|
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<Found> = (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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,37 @@ pub fn csv(found: &[&Found]) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Guará logo, embedded so the report renders identically as a
|
||||||
|
/// standalone, offline HTML/PDF export (same reasoning as
|
||||||
|
/// `Sabiá/crates/sabia-core/src/report.rs`, which this mirrors): a
|
||||||
|
/// `src="/logo.png"` reference would 404 once the file is saved outside the
|
||||||
|
/// app's own server, and a forensic report that only half-renders without
|
||||||
|
/// network access defeats its purpose as a portable piece of evidence.
|
||||||
|
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
|
||||||
|
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
|
||||||
|
fn logo_data_uri() -> String {
|
||||||
|
let mut out = String::with_capacity(LOGO_PNG.len().div_ceil(3) * 4 + 22);
|
||||||
|
out.push_str("data:image/png;base64,");
|
||||||
|
for chunk in LOGO_PNG.chunks(3) {
|
||||||
|
let b0 = chunk[0];
|
||||||
|
let b1 = chunk.get(1).copied();
|
||||||
|
let b2 = chunk.get(2).copied();
|
||||||
|
out.push(B64[(b0 >> 2) as usize] as char);
|
||||||
|
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
|
||||||
|
out.push(match b1 {
|
||||||
|
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
out.push(match b2 {
|
||||||
|
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
|
||||||
|
None => '=',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn esc(s: &str) -> String {
|
fn esc(s: &str) -> String {
|
||||||
s.replace('&', "&")
|
s.replace('&', "&")
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
@@ -135,6 +166,9 @@ pub fn html(r: &ReportInput) -> String {
|
|||||||
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
|
||||||
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
|
||||||
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
|
||||||
|
.brand{display:flex;align-items:center;gap:10px}.brand img{width:30px;height:30px;object-fit:contain}
|
||||||
|
.badges{display:flex;gap:6px;margin-top:8px}.badge{font:700 11px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;padding:2px 8px;border-radius:3px;border:1px solid #0f766e;color:#0f766e;background:#e7f3f2}
|
||||||
|
.badge-tlp{color:#8a5a00;border-color:#8a5a00;background:#fff0cf}
|
||||||
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
|
||||||
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||||||
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
|
||||||
@@ -145,7 +179,9 @@ td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-mon
|
|||||||
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
|
||||||
</style></head><body><main>"#,
|
</style></head><body><main>"#,
|
||||||
);
|
);
|
||||||
o.push_str("<header><h1>Relatório de recuperação de arquivos</h1><div class=\"mut\">Gerado pelo Guará ");
|
o.push_str("<header><div class=\"brand\"><img src=\"");
|
||||||
|
o.push_str(&logo_data_uri());
|
||||||
|
o.push_str("\" alt=\"\"><h1>Relatório de recuperação de arquivos</h1></div><div class=\"badges\"><span class=\"badge badge-tlp\">TLP:AMBER</span><span class=\"badge\">Uso autorizado</span></div><div class=\"mut\">Gerado pelo Guará ");
|
||||||
o.push_str(env!("CARGO_PKG_VERSION"));
|
o.push_str(env!("CARGO_PKG_VERSION"));
|
||||||
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
|
||||||
|
|
||||||
|
|||||||
@@ -359,6 +359,13 @@ impl Engine {
|
|||||||
Ok(json!({ "total": total, "items": page }))
|
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<Value> {
|
||||||
|
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).
|
/// Sidebar navigation facet counts (categories, dates, folders, cameras).
|
||||||
pub fn facets(&self, id: &str) -> Result<Value> {
|
pub fn facets(&self, id: &str) -> Result<Value> {
|
||||||
let j = self.job(id)?;
|
let j = self.job(id)?;
|
||||||
@@ -463,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> {
|
||||||
@@ -470,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()
|
||||||
@@ -478,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"));
|
||||||
}
|
}
|
||||||
@@ -526,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,
|
||||||
@@ -1232,6 +1251,28 @@ impl Engine {
|
|||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.ok_or_else(|| Error::msg("id ausente"))?
|
.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" => {
|
"results" => {
|
||||||
let q: Query = p
|
let q: Query = p
|
||||||
.get("query")
|
.get("query")
|
||||||
@@ -1265,7 +1306,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
|
||||||
@@ -1323,6 +1370,17 @@ impl Engine {
|
|||||||
let exe = std::env::current_exe()?;
|
let exe = std::env::current_exe()?;
|
||||||
crate::update::apply(&exe)
|
crate::update::apply(&exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_apply_desktop<F>(
|
||||||
|
&self,
|
||||||
|
current_exe: &std::path::Path,
|
||||||
|
on_progress: F,
|
||||||
|
) -> Result<String>
|
||||||
|
where
|
||||||
|
F: Fn(&str),
|
||||||
|
{
|
||||||
|
crate::update::apply_desktop_with_progress(current_exe, on_progress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use crate::explorer::Query;
|
pub use crate::explorer::Query;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,15 @@ use crate::error::{Error, Result};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
|
||||||
const LATEST_RELEASE_URL: &str =
|
const LATEST_RELEASE_URL: &str =
|
||||||
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/guara/releases/latest";
|
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/guara/releases/latest";
|
||||||
|
|
||||||
/// Human-facing Gitea Releases page for desktop GUI users.
|
/// Human-facing Gitea Releases page for desktop GUI users.
|
||||||
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases";
|
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/guara/releases";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct Release {
|
struct Release {
|
||||||
@@ -52,7 +52,7 @@ fn curl_text(url: &str) -> Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
|
fn curl_download(url: &str, dest: &Path) -> Result<()> {
|
||||||
let status = Command::new("curl")
|
let status = Command::new("curl")
|
||||||
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
||||||
.arg(dest)
|
.arg(dest)
|
||||||
@@ -223,6 +223,273 @@ fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
|
|||||||
Err(Error::msg("binário não encontrado no pacote baixado"))
|
Err(Error::msg("binário não encontrado no pacote baixado"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Desktop GUI installation type detected at runtime.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum DesktopInstall {
|
||||||
|
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
|
||||||
|
AppImage(PathBuf),
|
||||||
|
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
|
||||||
|
Deb,
|
||||||
|
/// Windows NSIS installer: downloaded setup executable run with `/S`
|
||||||
|
/// (silent). Does not relaunch the app on its own — see the comment on
|
||||||
|
/// the `WindowsInstaller` apply path.
|
||||||
|
WindowsInstaller,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects the desktop installation type given an optional `APPIMAGE` environment
|
||||||
|
/// variable value and the current executable path.
|
||||||
|
pub fn detect_desktop_install_from(
|
||||||
|
appimage_env: Option<&std::ffi::OsStr>,
|
||||||
|
current_exe: &Path,
|
||||||
|
) -> Result<DesktopInstall> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = (appimage_env, current_exe);
|
||||||
|
Ok(DesktopInstall::WindowsInstaller)
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if let Some(val) = appimage_env {
|
||||||
|
let path = PathBuf::from(val);
|
||||||
|
if path.is_file() {
|
||||||
|
return Ok(DesktopInstall::AppImage(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current_exe.is_file()
|
||||||
|
&& current_exe
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("appimage"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
|
||||||
|
}
|
||||||
|
Ok(DesktopInstall::Deb)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
{
|
||||||
|
let _ = (appimage_env, current_exe);
|
||||||
|
Err(Error::msg(format!(
|
||||||
|
"automatic self-update is not supported on this platform; please download manually from {RELEASES_PAGE_URL}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects the desktop installation type using the live environment.
|
||||||
|
pub fn detect_desktop_install() -> Result<DesktopInstall> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
|
||||||
|
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
|
||||||
|
match install {
|
||||||
|
DesktopInstall::AppImage(_) => match arch {
|
||||||
|
"x86_64" => Ok("_amd64.AppImage"),
|
||||||
|
"aarch64" => Ok("_aarch64.AppImage"),
|
||||||
|
other => Err(Error::msg(format!(
|
||||||
|
"unsupported architecture for AppImage: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
DesktopInstall::Deb => match arch {
|
||||||
|
"x86_64" => Ok("_amd64.deb"),
|
||||||
|
"aarch64" => Ok("_arm64.deb"),
|
||||||
|
other => Err(Error::msg(format!(
|
||||||
|
"unsupported architecture for deb package: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
DesktopInstall::WindowsInstaller => match arch {
|
||||||
|
"x86_64" => Ok("_x64-setup.exe"),
|
||||||
|
other => Err(Error::msg(format!(
|
||||||
|
"unsupported architecture for Windows installer: {other}"
|
||||||
|
))),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the expected release asset suffix for the current architecture.
|
||||||
|
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
|
||||||
|
gui_asset_suffix_for(install, std::env::consts::ARCH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads, verifies, and installs the latest desktop GUI release in place.
|
||||||
|
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
|
||||||
|
apply_desktop_with_progress(current_exe, |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads, verifies, and installs the latest desktop GUI release in place,
|
||||||
|
/// notifying the progress callback of phase transitions (`"downloading"`,
|
||||||
|
/// `"verifying"`, `"installing"`, `"restarting"`).
|
||||||
|
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
|
||||||
|
where
|
||||||
|
F: Fn(&str),
|
||||||
|
{
|
||||||
|
let install =
|
||||||
|
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
|
||||||
|
let body = curl_text(LATEST_RELEASE_URL)?;
|
||||||
|
let release: Release = serde_json::from_str(&body)?;
|
||||||
|
|
||||||
|
let suffix = gui_asset_suffix(&install)?;
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.ends_with(suffix))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::msg(format!(
|
||||||
|
"no asset ending with `{suffix}` in release {}",
|
||||||
|
release.tag_name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let checksum_asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == format!("{}.sha256", asset.name));
|
||||||
|
|
||||||
|
let tmp_dir = std::env::temp_dir().join("guara-desktop-update");
|
||||||
|
fs::create_dir_all(&tmp_dir)?;
|
||||||
|
let download_path = tmp_dir.join(&asset.name);
|
||||||
|
|
||||||
|
on_progress("downloading");
|
||||||
|
curl_download(&asset.browser_download_url, &download_path)?;
|
||||||
|
|
||||||
|
if let Some(checksum_asset) = checksum_asset {
|
||||||
|
on_progress("verifying");
|
||||||
|
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||||
|
let expected_hash = expected
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let data = fs::read(&download_path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&data);
|
||||||
|
let actual_hash: String = hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect();
|
||||||
|
if actual_hash != expected_hash {
|
||||||
|
return Err(Error::msg(
|
||||||
|
"checksum SHA-256 não confere — atualização descartada",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on_progress("installing");
|
||||||
|
match install {
|
||||||
|
DesktopInstall::AppImage(target_appimage) => {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let temp_name = format!(
|
||||||
|
".{}.tmp-{}",
|
||||||
|
target_appimage
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("appimage"),
|
||||||
|
std::process::id()
|
||||||
|
);
|
||||||
|
let temp_file = parent_dir.join(temp_name);
|
||||||
|
fs::copy(&download_path, &temp_file)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mut perms = fs::metadata(&temp_file)?.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&temp_file, perms)?;
|
||||||
|
}
|
||||||
|
fs::rename(&temp_file, &target_appimage)?;
|
||||||
|
let _ = fs::remove_file(&download_path);
|
||||||
|
|
||||||
|
on_progress("restarting");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let err = Command::new(&target_appimage)
|
||||||
|
.args(std::env::args().skip(1))
|
||||||
|
.exec();
|
||||||
|
return Err(Error::msg(format!("failed to relaunch AppImage: {err}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = (&target_appimage, &download_path);
|
||||||
|
return Err(Error::msg("AppImage updates are only supported on Linux"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesktopInstall::Deb => {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let status = Command::new("dpkg")
|
||||||
|
.args(["-i"])
|
||||||
|
.arg(&download_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| Error::msg(format!("dpkg -i: {e}")))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(Error::msg(format!(
|
||||||
|
"dpkg -i failed with exit code {:?}",
|
||||||
|
status.code()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&download_path);
|
||||||
|
|
||||||
|
on_progress("restarting");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let err = Command::new(current_exe)
|
||||||
|
.args(std::env::args().skip(1))
|
||||||
|
.exec();
|
||||||
|
return Err(Error::msg(format!("failed to relaunch updated app: {err}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let _ = (&download_path, current_exe);
|
||||||
|
return Err(Error::msg("deb updates are only supported on Linux"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesktopInstall::WindowsInstaller => {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
// `/S` is the one NSIS silent-install switch with a documented,
|
||||||
|
// stable meaning. There is no standard NSIS or Tauri flag that
|
||||||
|
// makes a silent install relaunch the app afterwards — that was
|
||||||
|
// claimed in an earlier draft of this code and could not be
|
||||||
|
// verified against NSIS's or Tauri's documentation; passing an
|
||||||
|
// invented flag to a stock NSIS installer is silently ignored,
|
||||||
|
// not harmful, but also not a relaunch mechanism. Spawning
|
||||||
|
// detached and exiting immediately (instead of waiting on the
|
||||||
|
// child) also avoids a self-deadlock: if the installer's script
|
||||||
|
// waits for this process to close before it can overwrite the
|
||||||
|
// running .exe, blocking here on the installer's exit status
|
||||||
|
// would hang forever. The practical effect: the update installs
|
||||||
|
// silently, but the app does not reopen itself — the user needs
|
||||||
|
// to start it again. This needs real-world confirmation on
|
||||||
|
// Windows before relying on it; if testing shows the bundled
|
||||||
|
// NSIS template does support an explicit relaunch flag or step,
|
||||||
|
// wire it in here instead of this comment.
|
||||||
|
on_progress("restarting");
|
||||||
|
Command::new(&download_path)
|
||||||
|
.arg("/S")
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| Error::msg(format!("failed to launch installer: {e}")))?;
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = &download_path;
|
||||||
|
return Err(Error::msg(
|
||||||
|
"Windows installer updates are only supported on Windows",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok(release.tag_name)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -234,4 +501,70 @@ mod tests {
|
|||||||
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
|
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
|
||||||
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
|
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_desktop_install_variants() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let temp_appimage =
|
||||||
|
std::env::temp_dir().join(format!("test-{}.AppImage", std::process::id()));
|
||||||
|
let _ = fs::write(&temp_appimage, b"dummy appimage");
|
||||||
|
|
||||||
|
let res = detect_desktop_install_from(
|
||||||
|
Some(temp_appimage.as_os_str()),
|
||||||
|
Path::new("/usr/bin/guara-gui"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
|
||||||
|
|
||||||
|
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res_fallback,
|
||||||
|
DesktopInstall::AppImage(temp_appimage.clone())
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&temp_appimage);
|
||||||
|
|
||||||
|
let res_deb =
|
||||||
|
detect_desktop_install_from(None, Path::new("/usr/bin/guara-gui")).unwrap();
|
||||||
|
assert_eq!(res_deb, DesktopInstall::Deb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let res = detect_desktop_install_from(
|
||||||
|
None,
|
||||||
|
Path::new("C:/Program Files/Guara/guara-gui.exe"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(res, DesktopInstall::WindowsInstaller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gui_asset_suffix_matches_known_release_naming() {
|
||||||
|
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Guara.AppImage"));
|
||||||
|
assert_eq!(
|
||||||
|
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
|
||||||
|
"_amd64.AppImage"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
|
||||||
|
"_aarch64.AppImage"
|
||||||
|
);
|
||||||
|
|
||||||
|
let deb = DesktopInstall::Deb;
|
||||||
|
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
|
||||||
|
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
|
||||||
|
|
||||||
|
let win = DesktopInstall::WindowsInstaller;
|
||||||
|
assert_eq!(
|
||||||
|
gui_asset_suffix_for(&win, "x86_64").unwrap(),
|
||||||
|
"_x64-setup.exe"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
|
||||||
|
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
|
||||||
|
assert!(gui_asset_suffix_for(&win, "arm").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,121 @@ 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")],
|
||||||
|
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<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}");
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 672 KiB After Width: | Height: | Size: 693 KiB |
|
Before Width: | Height: | Size: 679 KiB After Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 676 KiB After Width: | Height: | Size: 698 KiB |
|
Before Width: | Height: | Size: 683 KiB After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -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:
|
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.
|
- **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).
|
- **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.
|
- **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).
|
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
|
||||||
|
|
||||||
|
<p align="center"><img src="img/folders-light-en.png" width="900" alt="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"></p>
|
||||||
|
|
||||||
|
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
|
## Themes
|
||||||
|
|
||||||
The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved.
|
The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved.
|
||||||
|
|||||||
@@ -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:
|
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.
|
- **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).
|
- **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.
|
- **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).
|
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
|
||||||
|
|
||||||
|
<p align="center"><img src="../img/folders-light-pt.png" width="900" alt="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"></p>
|
||||||
|
|
||||||
|
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
|
## Temas
|
||||||
|
|
||||||
O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva.
|
O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva.
|
||||||
|
|||||||
@@ -167,4 +167,17 @@ EOF
|
|||||||
*) echo "unknown platform: $platform" >&2; exit 2 ;;
|
*) echo "unknown platform: $platform" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
(
|
||||||
|
cd ../../../dist-gui
|
||||||
|
for asset in *; do
|
||||||
|
[ -f "$asset" ] || continue
|
||||||
|
case "$asset" in
|
||||||
|
*.sha256) ;;
|
||||||
|
*)
|
||||||
|
sha256sum "$asset" > "$asset.sha256"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
)
|
||||||
|
|
||||||
ls -1 ../../../dist-gui
|
ls -1 ../../../dist-gui
|
||||||