Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9a16e8a0 | ||
|
|
db5b972650 | ||
|
|
c5f21f749d | ||
|
|
bf93ca01b4 | ||
|
|
00aceecf70 | ||
|
|
ca677a8bc1 | ||
|
|
b274f0b7ef | ||
|
|
7683967650 | ||
|
|
43e9860b41 | ||
|
|
81f067abdb | ||
|
|
cdda2bc45f | ||
|
|
21eb4eafae | ||
|
|
5ecf40056f |
No files matched your search
@@ -4,6 +4,35 @@
|
|||||||
|
|
||||||
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.5.0] - 2026-11-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Update progress bar.** The in-app update shows a real download percentage bar followed by the verify/install/restart phases.
|
||||||
|
- **Delete saved sessions.** Each autosaved session in the sidebar has a delete button, plus a "delete all" button. Settings gained "Delete autosaved sessions after" (never, 7, 30 or 90 days), applied on startup. Sessions open in the app are never removed automatically.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Updating no longer freezes the app.** The update check and install ran on the UI thread; they now run in the background.
|
||||||
|
- **Pause and Stop respond right away.** The signature scan only checked for the request once per 8 MiB block; it now checks continuously, and the UI shows "Pausing…" immediately.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved — either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance — so only one window ever appears, matching Sabiá.
|
||||||
|
|
||||||
## [1.3.3] - 2026-10-30
|
## [1.3.3] - 2026-10-30
|
||||||
|
|
||||||
Unified updater architecture with Sabiá: migrated from the signed `tauri-plugin-updater` to direct Gitea Releases API checking and self-updating with SHA-256 verification.
|
Unified updater architecture with Sabiá: migrated from the signed `tauri-plugin-updater` to direct Gitea Releases API checking and self-updating with SHA-256 verification.
|
||||||
|
|||||||
@@ -4,6 +4,35 @@
|
|||||||
|
|
||||||
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.5.0] - 2026-11-03
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- **Barra de progresso da atualização.** A atualização dentro do app mostra uma barra com a porcentagem real do download, seguida das fases de verificação, instalação e reinício.
|
||||||
|
- **Apagar sessões salvas.** Cada sessão salva sozinha na lateral tem um botão de apagar, além de um botão "apagar todas". As configurações ganharam "Apagar sessões salvas sozinhas após" (nunca, 7, 30 ou 90 dias), aplicado ao abrir. Sessões abertas no app nunca são removidas automaticamente.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- **Atualizar não trava mais o app.** A verificação e a instalação rodavam na thread da interface; agora rodam em segundo plano.
|
||||||
|
- **Pausar e Parar respondem na hora.** A varredura por assinatura só checava o pedido a cada bloco de 8 MiB; agora checa continuamente, e a interface mostra "Pausando…" imediatamente.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida — já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador — então só uma janela aparece, igual ao Sabiá.
|
||||||
|
|
||||||
## [1.3.3] - 2026-10-30
|
## [1.3.3] - 2026-10-30
|
||||||
|
|
||||||
Arquitetura de atualizador unificada com o Sabiá: migração do `tauri-plugin-updater` assinado para consulta direta à API de Releases do Gitea e autoatualização com verificação de SHA-256.
|
Arquitetura de atualizador unificada com o Sabiá: migração do `tauri-plugin-updater` assinado para consulta direta à API de Releases do Gitea e autoatualização com verificação de SHA-256.
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "1.3.3"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
@@ -429,7 +429,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.3.3"
|
version = "1.5.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.3"
|
version = "1.5.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.3"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -1373,7 +1373,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-gui"
|
name = "guara-gui"
|
||||||
version = "1.3.3"
|
version = "1.5.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.3"
|
version = "1.5.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]
|
||||||
|
|||||||
@@ -22,14 +22,41 @@ async fn rpc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
async fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
let eng = state.0.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let status = eng.update_check().map_err(|e| e.to_string())?;
|
||||||
serde_json::to_value(status).map_err(|e| e.to_string())
|
serde_json::to_value(status).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
async 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();
|
||||||
|
|
||||||
|
// Blocking download/install must not run on the UI thread, or the window freezes.
|
||||||
|
let eng = state.0.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
eng.update_apply_desktop(&exe, move |phase| {
|
||||||
|
let (name, pct) = match phase.split_once(':') {
|
||||||
|
Some((n, p)) => (n, p.parse::<u8>().ok()),
|
||||||
|
None => (phase, None),
|
||||||
|
};
|
||||||
|
let _ = app.emit(
|
||||||
|
"update-progress",
|
||||||
|
serde_json::json!({ "phase": name, "percent": pct }),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.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.
|
||||||
@@ -247,6 +274,7 @@ fn main() {
|
|||||||
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
use tauri::{Listener, Manager};
|
||||||
// When launched elevated by the unprivileged instance, terminate the old process.
|
// When launched elevated by the unprivileged instance, terminate the old process.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
|
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
|
||||||
@@ -264,15 +292,42 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading raw disks requires root: request elevation as soon as the app launches,
|
// The window starts hidden (tauri.conf.json: "visible": false) so the
|
||||||
// without waiting for the user to look for a button. If authorization fails,
|
// unprivileged instance never flashes on screen before being replaced.
|
||||||
// the window stays open and the UI displays a warning.
|
// Reading raw disks requires root: request elevation as soon as the app
|
||||||
|
// launches, without waiting for the user to look for a button, and only
|
||||||
|
// show a window once we know which one it is — the elevated relaunch's,
|
||||||
|
// or this same one if elevation turned out not to be needed/possible.
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
{
|
||||||
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
||||||
|
use tauri::Emitter;
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
let handle_for_failure = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = do_relaunch_elevated(handle);
|
// do_relaunch_elevated only emits "elevate-failed" itself once the
|
||||||
|
// child process has actually spawned and later exits non-zero. If it
|
||||||
|
// fails earlier (pkexec/osascript missing, exe path unresolved, log
|
||||||
|
// file unwritable), it returns Err directly without emitting anything
|
||||||
|
// — emit it here too, or the hidden window would never reappear.
|
||||||
|
if let Err(e) = do_relaunch_elevated(handle) {
|
||||||
|
let _ = handle_for_failure.emit("elevate-failed", e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
// Whichever path emits it, make sure the window comes back so the
|
||||||
|
// user sees the warning instead of a silently stuck, invisible app.
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
app.handle().listen("elevate-failed", move |_| {
|
||||||
|
let _ = win.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.3",
|
"version": "1.5.0",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"height": 780,
|
"height": 780,
|
||||||
"minWidth": 420,
|
"minWidth": 420,
|
||||||
"minHeight": 560,
|
"minHeight": 560,
|
||||||
"resizable": true
|
"resizable": true,
|
||||||
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -527,3 +542,8 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.update-card .sub { margin: 0; }
|
.update-card .sub { margin: 0; }
|
||||||
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
|
||||||
|
.update-card .upd-bar { margin-top: 10px; }
|
||||||
|
|
||||||
|
.list li.has-del { position: relative; }
|
||||||
|
.list li.has-del .del { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); opacity: 0; }
|
||||||
|
.list li.has-del:hover .del, .list li.has-del .del:focus-visible { opacity: 1; }
|
||||||
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ settings
|
// ------------------------------------------------------------------ settings
|
||||||
// (declared before use: `S` is defined further below, so the object lives here)
|
// (declared before use: `S` is defined further below, so the object lives here)
|
||||||
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
|
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true, session_retention_days: 0 };
|
||||||
async function saveSettings(patch) {
|
async function saveSettings(patch) {
|
||||||
Object.assign(SETTINGS, patch);
|
Object.assign(SETTINGS, patch);
|
||||||
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
|
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
|
||||||
@@ -201,11 +201,36 @@ function renderSide() {
|
|||||||
const rl = $('#recentList');
|
const rl = $('#recentList');
|
||||||
rl.dataset.empty = 'Nada salvo ainda';
|
rl.dataset.empty = 'Nada salvo ainda';
|
||||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||||
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
<li class="has-del"><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
|
||||||
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
|
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button><button class="icon-btn small del" data-del="${i}" title="Apagar esta sessão" aria-label="Apagar esta sessão"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg></button></li>`).join('');
|
||||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||||
|
rl.querySelectorAll('.del').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); deleteRecent(S.recent[+b.dataset.del]); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDlg({ title, text, ok }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const d = $('#dlg');
|
||||||
|
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text}</p><menu><button class="btn" value="cancel">Cancelar</button><button class="btn danger" value="ok">${esc(ok)}</button></menu></form>`;
|
||||||
|
d.onclose = () => resolve(d.returnValue === 'ok');
|
||||||
|
d.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecent(r) {
|
||||||
|
if (!r) return;
|
||||||
|
const name = (r.target || '').split(/[\\/]/).pop() || 'Sessão';
|
||||||
|
if (!await confirmDlg({ title: 'Apagar sessão salva', text: `A sessão de <code>${esc(name)}</code> será apagada de vez. Os arquivos do disco não são afetados.`, ok: 'Apagar' })) return;
|
||||||
|
try { await rpc('session_delete', { path: r.path }); toast('Sessão apagada'); } catch (e) { toast(e.message); }
|
||||||
|
loadRecent();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#clearRecentBtn')?.addEventListener('click', async () => {
|
||||||
|
if (!(S.recent || []).length) { toast('Nada para apagar'); return; }
|
||||||
|
if (!await confirmDlg({ title: 'Apagar todas as sessões salvas', text: 'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.', ok: 'Apagar tudo' })) return;
|
||||||
|
try { const r = await rpc('sessions_prune', { all: true }); toast(`Sessões apagadas: ${r.removed}`); } catch (e) { toast(e.message); }
|
||||||
|
loadRecent();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadRecent() {
|
async function loadRecent() {
|
||||||
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
|
||||||
renderSide();
|
renderSide();
|
||||||
@@ -590,7 +615,9 @@ function paintStatus() {
|
|||||||
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
|
||||||
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
|
||||||
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
|
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
|
||||||
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
const stopping = running && st.stopping;
|
||||||
|
const buttons = stopping ? '<button class="btn sm" disabled>Pausando…</button>'
|
||||||
|
: running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
|
||||||
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
|
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
|
||||||
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
|
||||||
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
|
||||||
@@ -651,8 +678,8 @@ async function chooseSource() {
|
|||||||
async function onProgressClick(e) {
|
async function onProgressClick(e) {
|
||||||
const act = e.target.closest('[data-act]')?.dataset.act;
|
const act = e.target.closest('[data-act]')?.dataset.act;
|
||||||
if (!act) return;
|
if (!act) return;
|
||||||
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
|
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); tick(); }
|
||||||
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
|
else if (act === 'stop') { await rpc('scan_stop', { job: S.job }); tick(); }
|
||||||
else if (act === 'resume') {
|
else if (act === 'resume') {
|
||||||
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
|
||||||
catch (err) { toast(err.message); chooseSource(); }
|
catch (err) { toast(err.message); chooseSource(); }
|
||||||
@@ -687,19 +714,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;
|
||||||
@@ -747,17 +780,23 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
||||||
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
||||||
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
||||||
|
<label class="fld"><span>Apagar sessões salvas sozinhas após</span><select class="select" name="retention"><option value="0">Nunca</option><option value="7">7 dias</option><option value="30">30 dias</option><option value="90">90 dias</option></select></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>` : ''}
|
${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('[name=retention]').value = String([0, 7, 30, 90].includes(SETTINGS.session_retention_days) ? SETTINGS.session_retention_days : 0);
|
||||||
|
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, session_retention_days: +d.querySelector('[name=retention]').value };
|
||||||
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
patch.ui_lang = currentLang();
|
patch.ui_lang = currentLang();
|
||||||
saveSettings(patch);
|
saveSettings(patch).then(() => rpc('sessions_prune', {}).then(loadRecent).catch(() => {}));
|
||||||
EX?.relang?.();
|
EX?.relang?.();
|
||||||
toast('Salvo');
|
toast('Salvo');
|
||||||
};
|
};
|
||||||
@@ -768,6 +807,54 @@ $('#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, pct) => {
|
||||||
|
const card = $('#updateCard');
|
||||||
|
if (card) {
|
||||||
|
let bar = card.querySelector('.upd-bar');
|
||||||
|
if (!bar) { bar = document.createElement('div'); bar.className = 'bar upd-bar'; bar.innerHTML = '<i></i>'; card.appendChild(bar); }
|
||||||
|
bar.classList.toggle('indet', pct == null);
|
||||||
|
bar.firstChild.style.width = pct == null ? '35%' : `${pct}%`;
|
||||||
|
}
|
||||||
|
if (pct != null) txt = `${txt} ${pct}%`;
|
||||||
|
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;
|
||||||
|
const pct = event.payload?.percent;
|
||||||
|
if (phase === 'downloading') setPhase('Baixando a atualização…', pct);
|
||||||
|
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;
|
||||||
|
$('#updateCard .upd-bar')?.remove();
|
||||||
|
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 +888,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
|
||||||
@@ -864,6 +953,7 @@ function watchClose() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadDevices();
|
await loadDevices();
|
||||||
|
try { await rpc('sessions_prune', {}); } catch { /* retention is best-effort */ }
|
||||||
await loadRecent();
|
await loadRecent();
|
||||||
renderHome();
|
renderHome();
|
||||||
})();
|
})();
|
||||||
@@ -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,46 @@ 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}',
|
||||||
|
'Apagar esta sessão': 'Delete this session',
|
||||||
|
'Apagar todas as sessões salvas sozinhas': 'Delete all autosaved sessions',
|
||||||
|
'Apagar sessão salva': 'Delete saved session',
|
||||||
|
'Apagar': 'Delete',
|
||||||
|
'Apagar tudo': 'Delete all',
|
||||||
|
'Apagar todas as sessões salvas': 'Delete all saved sessions',
|
||||||
|
'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.': 'All autosaved sessions will be permanently deleted. Sessions currently open are kept.',
|
||||||
|
'Sessão apagada': 'Session deleted',
|
||||||
|
'Nada para apagar': 'Nothing to delete',
|
||||||
|
'Apagar sessões salvas sozinhas após': 'Delete autosaved sessions after',
|
||||||
|
'Nunca': 'Never',
|
||||||
|
'7 dias': '7 days',
|
||||||
|
'30 dias': '30 days',
|
||||||
|
'90 dias': '90 days',
|
||||||
|
'será apagada de vez. Os arquivos do disco não são afetados.': 'will be permanently deleted. Files on the disk are not affected.',
|
||||||
|
'Sessões apagadas: {n}': 'Sessions deleted: {n}',
|
||||||
};
|
};
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
|
||||||
<div class="side-title">Sessões</div>
|
<div class="side-title">Sessões</div>
|
||||||
<ul id="sessionList" class="list"></ul>
|
<ul id="sessionList" class="list"></ul>
|
||||||
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
|
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small> <button class="icon-btn small" id="clearRecentBtn" title="Apagar todas as sessões salvas sozinhas" aria-label="Apagar todas as sessões salvas sozinhas"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M5 7h14M10 7V5h4v2m-6 0l1 12h6l1-12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||||
<ul id="recentList" class="list"></ul>
|
<ul id="recentList" class="list"></ul>
|
||||||
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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!();
|
||||||
|
|||||||
@@ -64,7 +64,19 @@ pub fn carve_range(
|
|||||||
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
let scan_len = got.min(CHUNK).min((end - pos) as usize);
|
||||||
ev.chunk(pos, &buf[..scan_len]);
|
ev.chunk(pos, &buf[..scan_len]);
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
|
let mut since_check = 0u32;
|
||||||
while i < scan_len {
|
while i < scan_len {
|
||||||
|
// Pause/stop must take effect within a fraction of a second even on slow media,
|
||||||
|
// not only at 8 MiB chunk boundaries. The chunk is reprocessed on resume (hits at
|
||||||
|
// offsets already found are skipped by the caller).
|
||||||
|
since_check += 1;
|
||||||
|
if since_check >= 2048 {
|
||||||
|
since_check = 0;
|
||||||
|
if cancelled() {
|
||||||
|
*next = pos;
|
||||||
|
return CarveEnd::Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
let abs = pos + i as u64;
|
let abs = pos + i as u64;
|
||||||
if abs < skip_until {
|
if abs < skip_until {
|
||||||
i += SECTOR as usize;
|
i += SECTOR as usize;
|
||||||
@@ -78,6 +90,10 @@ pub fn carve_range(
|
|||||||
let allowed = cfg.categories.is_empty()
|
let allowed = cfg.categories.is_empty()
|
||||||
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
|| cfg.categories.contains(&category_for_ext(hit.ext));
|
||||||
if hit.len >= MIN_LEN && allowed {
|
if hit.len >= MIN_LEN && allowed {
|
||||||
|
if cancelled() {
|
||||||
|
*next = pos;
|
||||||
|
return CarveEnd::Cancelled;
|
||||||
|
}
|
||||||
let action = ev.hit(abs, &hit);
|
let action = ev.hit(abs, &hit);
|
||||||
match action {
|
match action {
|
||||||
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
|
HitAction::Skip if hit.confidence != crate::types::Confidence::Low => {
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,12 @@ pub fn run(
|
|||||||
return Outcome::Interrupted(cp);
|
return Outcome::Interrupted(cp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handler.rebuild_pending();
|
if !handler.rebuild_pending(cancelled) {
|
||||||
|
// Paused/stopped during reassembly: the truncated candidates were already reported,
|
||||||
|
// and on resume (all ranges done) the scan finishes without redoing them.
|
||||||
|
cp.next_id = next_id.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
return Outcome::Interrupted(cp);
|
||||||
|
}
|
||||||
Outcome::Completed
|
Outcome::Completed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +311,13 @@ impl Handler<'_> {
|
|||||||
self.sink.replace(f);
|
self.sink.replace(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_pending(&mut self) {
|
/// Reassembles queued fragmented files. Returns `false` if cancelled part-way.
|
||||||
|
fn rebuild_pending(&mut self, cancelled: &(dyn Fn() -> bool + Sync)) -> bool {
|
||||||
let pend = std::mem::take(&mut self.pending);
|
let pend = std::mem::take(&mut self.pending);
|
||||||
for p in pend {
|
for p in pend {
|
||||||
|
if cancelled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let off = p.found.first_offset().unwrap_or(0);
|
let off = p.found.first_offset().unwrap_or(0);
|
||||||
if p.mp4 {
|
if p.mp4 {
|
||||||
self.rebuild_mp4(p, off);
|
self.rebuild_mp4(p, off);
|
||||||
@@ -339,6 +348,7 @@ impl Handler<'_> {
|
|||||||
self.sink.replace(f);
|
self.sink.replace(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ impl Engine {
|
|||||||
"error": s.error,
|
"error": s.error,
|
||||||
"report": s.report,
|
"report": s.report,
|
||||||
"elapsed": elapsed,
|
"elapsed": elapsed,
|
||||||
|
"stopping": s.status == Status::Running && j.cancelled(),
|
||||||
"source": j.source().is_some(),
|
"source": j.source().is_some(),
|
||||||
"source_path": j.src_path.lock().unwrap().clone(),
|
"source_path": j.src_path.lock().unwrap().clone(),
|
||||||
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
"session_file": j.session_file.lock().unwrap().as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||||
@@ -359,6 +360,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 +471,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 +479,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 +493,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 +545,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,
|
||||||
@@ -1079,11 +1099,78 @@ impl Engine {
|
|||||||
"só sessões automáticas podem ser apagadas por aqui",
|
"só sessões automáticas podem ser apagadas por aqui",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let running = self.jobs.lock().unwrap().values().any(|j| {
|
||||||
|
j.session_file.lock().unwrap().as_deref() == Some(p)
|
||||||
|
&& j.st.lock().unwrap().status == Status::Running
|
||||||
|
});
|
||||||
|
if running {
|
||||||
|
return Err(Error::msg(
|
||||||
|
"esta varredura ainda está em andamento; pause ou pare antes de apagar a sessão",
|
||||||
|
));
|
||||||
|
}
|
||||||
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
let _ = std::fs::remove_file(p.with_extension("meta.json"));
|
||||||
std::fs::remove_file(p)?;
|
std::fs::remove_file(p)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` if an open job (scanning, paused, or loaded) still autosaves to this file.
|
||||||
|
fn session_in_use(&self, p: &Path) -> bool {
|
||||||
|
self.jobs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.any(|j| j.session_file.lock().unwrap().as_deref() == Some(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes autosaved sessions older than `days` (`None` = the configured retention;
|
||||||
|
/// `Some(0)` = all of them). Sessions held by open jobs are always kept.
|
||||||
|
/// Returns how many were removed.
|
||||||
|
pub fn sessions_prune(&self, days: Option<u32>) -> usize {
|
||||||
|
let days = match days {
|
||||||
|
Some(d) => d,
|
||||||
|
None => match crate::settings::load().session_retention_days {
|
||||||
|
0 => return 0, // retention disabled: keep everything
|
||||||
|
d => d,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let dir = sessions_dir();
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cutoff = now - days as i64 * 86_400;
|
||||||
|
let mut removed = 0;
|
||||||
|
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let path = e.path();
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.ends_with(".json") || name.ends_with(".meta.json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let saved_at = std::fs::read(path.with_extension("meta.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
|
||||||
|
.and_then(|v| v["saved_at"].as_i64())
|
||||||
|
.or_else(|| {
|
||||||
|
e.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
})
|
||||||
|
.unwrap_or(now);
|
||||||
|
if saved_at <= cutoff && !self.session_in_use(&path) {
|
||||||
|
let _ = std::fs::remove_file(path.with_extension("meta.json"));
|
||||||
|
if std::fs::remove_file(&path).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- RPC
|
// ---------------------------------------------------------------- RPC
|
||||||
|
|
||||||
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
pub fn rpc(&self, method: &str, p: Value) -> Result<Value> {
|
||||||
@@ -1232,6 +1319,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 +1374,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
|
||||||
@@ -1295,6 +1410,10 @@ impl Engine {
|
|||||||
self.session_delete(&s("path")?)?;
|
self.session_delete(&s("path")?)?;
|
||||||
json!({})
|
json!({})
|
||||||
}
|
}
|
||||||
|
"sessions_prune" => {
|
||||||
|
let all = p.get("all").and_then(|v| v.as_bool()) == Some(true);
|
||||||
|
json!({ "removed": self.sessions_prune(if all { Some(0) } else { None }) })
|
||||||
|
}
|
||||||
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
"session_load" => json!({ "job": self.load_session(&s("path")?)? }),
|
||||||
"defaults" => {
|
"defaults" => {
|
||||||
let home = crate::owner::user_home().to_string_lossy().to_string();
|
let home = crate::owner::user_home().to_string_lossy().to_string();
|
||||||
@@ -1323,6 +1442,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;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ pub struct Settings {
|
|||||||
/// without sending any telemetry).
|
/// without sending any telemetry).
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
pub updates: bool,
|
pub updates: bool,
|
||||||
|
/// Autosaved sessions older than this many days are deleted on startup (`0` = keep forever).
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_retention_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto() -> String {
|
fn auto() -> String {
|
||||||
@@ -46,6 +49,7 @@ impl Default for Settings {
|
|||||||
notify: true,
|
notify: true,
|
||||||
maps: false,
|
maps: false,
|
||||||
updates: true,
|
updates: true,
|
||||||
|
session_retention_days: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -29,6 +29,8 @@ struct Release {
|
|||||||
struct Asset {
|
struct Asset {
|
||||||
name: String,
|
name: String,
|
||||||
browser_download_url: String,
|
browser_download_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -52,13 +54,25 @@ 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<()> {
|
/// Downloads `url` to `dest`, polling the file size to report `percent` (0-100) through
|
||||||
let status = Command::new("curl")
|
/// `on_pct` while curl runs (no stderr parsing; `total` comes from the release asset size).
|
||||||
|
fn curl_download(url: &str, dest: &Path, total: u64, on_pct: &dyn Fn(u8)) -> Result<()> {
|
||||||
|
let mut child = Command::new("curl")
|
||||||
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
|
||||||
.arg(dest)
|
.arg(dest)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
.status()
|
.spawn()
|
||||||
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
|
||||||
|
let status = loop {
|
||||||
|
if let Some(st) = child.try_wait().map_err(|e| Error::msg(e.to_string()))? {
|
||||||
|
break st;
|
||||||
|
}
|
||||||
|
let got = fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
|
||||||
|
if let Some(pct) = got.saturating_mul(100).checked_div(total) {
|
||||||
|
on_pct(pct.min(100) as u8);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
};
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(Error::msg(format!("curl {url}: download falhou")));
|
return Err(Error::msg(format!("curl {url}: download falhou")));
|
||||||
}
|
}
|
||||||
@@ -145,7 +159,7 @@ pub fn apply(current_exe: &PathBuf) -> Result<String> {
|
|||||||
let tmp_dir = std::env::temp_dir().join("guara-update");
|
let tmp_dir = std::env::temp_dir().join("guara-update");
|
||||||
fs::create_dir_all(&tmp_dir)?;
|
fs::create_dir_all(&tmp_dir)?;
|
||||||
let archive_path = tmp_dir.join(&asset.name);
|
let archive_path = tmp_dir.join(&asset.name);
|
||||||
curl_download(&asset.browser_download_url, &archive_path)?;
|
curl_download(&asset.browser_download_url, &archive_path, 0, &|_| {})?;
|
||||||
|
|
||||||
if let Some(checksum_asset) = checksum_asset {
|
if let Some(checksum_asset) = checksum_asset {
|
||||||
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
let expected = curl_text(&checksum_asset.browser_download_url)?;
|
||||||
@@ -223,6 +237,278 @@ 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,
|
||||||
|
asset.size,
|
||||||
|
&|pct| on_progress(&format!("downloading:{pct}")),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
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 +520,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}");
|
||||||
|
}
|
||||||
@@ -143,6 +143,7 @@ fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
|
|||||||
notify: false,
|
notify: false,
|
||||||
maps: true,
|
maps: true,
|
||||||
updates: false,
|
updates: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let s = settings::load();
|
let s = settings::load();
|
||||||
|
|||||||
|
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.
|
||||||
@@ -27,9 +37,13 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
|
|||||||
|
|
||||||
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles).
|
||||||
|
|
||||||
|
## Saved sessions
|
||||||
|
|
||||||
|
Sessions are autosaved and listed under **Recent** in the sidebar. Hover one and click the ✕ to delete it, or use the trash button in the section title to delete them all; sessions open in the app are kept. **Settings > Delete autosaved sessions after** removes old ones on startup (never by default; 7, 30 or 90 days).
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
|
When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. Clicking **Update now** shows a progress bar with the download percentage, then the verify, install and restart steps; the app stays responsive meanwhile. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
|
||||||
|
|
||||||
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -27,9 +37,13 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar
|
|||||||
|
|
||||||
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap).
|
||||||
|
|
||||||
|
## Sessões salvas
|
||||||
|
|
||||||
|
As sessões são salvas sozinhas e listadas em **Recentes** na barra lateral. Passe o mouse sobre uma e clique no ✕ para apagá-la, ou use o botão de lixeira no título da seção para apagar todas; sessões abertas no app são mantidas. **Configurações > Apagar sessões salvas sozinhas após** remove as antigas ao abrir (nunca, por padrão; 7, 30 ou 90 dias).
|
||||||
|
|
||||||
## Atualizações
|
## Atualizações
|
||||||
|
|
||||||
Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
|
Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. Ao clicar em **Atualizar agora**, aparece uma barra de progresso com a porcentagem do download e depois as etapas de verificação, instalação e reinício; o app continua responsivo. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
|
||||||
|
|
||||||
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||