Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9657ea856 | ||
|
|
bbcb42ae6e |
No files matched your search
@@ -2,6 +2,20 @@
|
||||
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
|
||||
|
||||
## [0.5.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
- **Visualizador de bancos de dados:** arquivos SQLite (`.db`, `.sqlite`, `.sqlite3`, `.db3`, `.sdb`, `.sqlitedb`, `.storedata`) abrem na pré-visualização com esquema, tabelas paginadas, blobs e datas reconhecidas. Leitor próprio, só leitura, tolerante a arquivos truncados ou corrompidos. Também **recupera linhas apagadas** a partir do espaço livre das páginas.
|
||||
- **Saúde do disco (SMART):** antes de varrer, o Guará consulta o `smartctl` (quando instalado) e o contador de erros do kernel. Se o disco parece com defeito, recomenda criar a imagem primeiro e pede confirmação antes da varredura profunda.
|
||||
- **Notificação do sistema** quando uma varredura longa ou uma recuperação termina, ou pausa por desconexão.
|
||||
- **Retomar recuperação interrompida:** um diário na pasta de destino faz o Guará pular o que já foi copiado e conferido; o resultado mostra quantos foram pulados.
|
||||
- **Mapa das fotos com GPS** no painel de detalhes (opcional, usa a internet) e botão para abrir no OpenStreetMap.
|
||||
- **Interface em inglês**, com escolha de idioma (Auto, Português, English) na nova janela de Configurações, que também controla notificações e mapas online.
|
||||
- **Fechar a janela durante a varredura** pergunta se você quer pausar e sair, sair sem pausar ou continuar.
|
||||
|
||||
### Notas
|
||||
- Mensagens de erro vindas do núcleo e o terminal continuam em português.
|
||||
|
||||
## [0.4.0] - 2026-10-18
|
||||
|
||||
### Adicionado
|
||||
|
||||
Generated
+2
-2
@@ -317,7 +317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"ctrlc",
|
||||
@@ -329,7 +329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "guara-core"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/guara-core", "crates/guara-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -116,3 +116,10 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver
|
||||
## Licença
|
||||
|
||||
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
|
||||
|
||||
## Novidades da 0.5.0
|
||||
|
||||
- Visualizador de bancos SQLite com recuperação de linhas apagadas.
|
||||
- Aviso de saúde do disco (SMART) antes da varredura profunda.
|
||||
- Notificações do sistema, retomada de recuperação interrompida e mapa de fotos com GPS (opcional).
|
||||
- Interface em inglês e janela de Configurações.
|
||||
@@ -3,7 +3,7 @@
|
||||
[package]
|
||||
name = "guara-gui"
|
||||
description = "Guará: interface gráfica de recuperação de arquivos"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/guara"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Permissões da janela principal",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "dialog:default"]
|
||||
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Guara",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"identifier": "dev.guara.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||
"csp": "default-src 'self'; img-src 'self' data: blob: guara: http://guara.localhost https://tile.openstreetmap.org; media-src 'self' blob: guara: http://guara.localhost; connect-src 'self' ipc: http://ipc.localhost guara: http://guara.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
@@ -464,3 +464,33 @@ dialog.lightbox[open] { display: grid; }
|
||||
.cand { display: grid; gap: 4px; margin: 6px 0 12px; max-height: 240px; overflow: auto; }
|
||||
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
|
||||
.cand .s { white-space: normal; }
|
||||
|
||||
/* ------------------------------------------------------------ visualizador de bancos de dados */
|
||||
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
|
||||
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
|
||||
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
|
||||
.viewer.db-large { display: block; overflow: hidden; }
|
||||
.db-wrap { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: 100%; min-height: 0; width: 100%; text-align: left; }
|
||||
.db-tables { overflow: auto; padding: 10px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
|
||||
.db-t { display: flex; justify-content: space-between; gap: 8px; border: 0; background: transparent; cursor: pointer; padding: 7px 10px; border-radius: 9px; color: var(--text); font-size: 13.5px; text-align: left; }
|
||||
.db-t:hover { background: color-mix(in srgb, var(--text) 7%, transparent); } .db-t[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.db-t small { color: var(--text-3); font-weight: 400; }
|
||||
.db-meta { margin-top: 12px; display: grid; gap: 2px; font-size: 12.5px; color: var(--text-2); } .db-meta b { margin-top: 6px; color: var(--text-3); font-size: 11.5px; }
|
||||
.db-main { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; min-height: 0; }
|
||||
.db-bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
||||
.db-bar .input { flex: 0 1 220px; }
|
||||
.db-grid { overflow: auto; }
|
||||
.db-foot { display: flex; justify-content: space-between; align-items: center; padding: 8px 14px; border-top: 1px solid var(--line); }
|
||||
.db-table { border-collapse: collapse; font-size: 12.5px; min-width: 100%; }
|
||||
.db-table th, .db-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }
|
||||
.db-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; color: var(--text-3); font-weight: 600; text-align: left; }
|
||||
.db-table tbody th { color: var(--text-3); font-weight: 400; text-align: right; }
|
||||
.db-table tr.del td { background: var(--warn-soft); } .db-table tr.del th { color: var(--warn); }
|
||||
.db-null { color: var(--text-3); font-style: italic; } .db-blob { color: var(--accent); }
|
||||
.db-ts { margin-left: 6px; font-style: normal; font-size: 11px; color: var(--accent); background: var(--accent-soft); padding: 0 5px; border-radius: 6px; }
|
||||
@media (max-width: 860px) { .db-wrap { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } .db-tables { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } }
|
||||
|
||||
.xp-map { position: relative; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 10px; margin-top: 8px; background: var(--surface-2, #e6eeee); }
|
||||
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
|
||||
.xp-tiles img { width: 100%; height: 100%; display: block; }
|
||||
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
|
||||
+117
-13
@@ -2,6 +2,7 @@
|
||||
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
|
||||
|
||||
import { createExplorer } from './explorer.js';
|
||||
import { setLang, LOC } from './i18n.js';
|
||||
|
||||
const tauri = window.__TAURI__;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -47,9 +48,17 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
||||
const t = b.dataset.themePick;
|
||||
applyTheme(t);
|
||||
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
|
||||
try { await rpc('settings_set', { theme: t }); } catch { /* segue só com o armazenamento local */ }
|
||||
saveSettings({ theme: t });
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------ configurações
|
||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
||||
const SETTINGS = { theme: 'auto', lang: 'auto', notify: true, maps: false };
|
||||
async function saveSettings(patch) {
|
||||
Object.assign(SETTINGS, patch);
|
||||
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ utilidades
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -64,7 +73,7 @@ const dur = (s) => {
|
||||
s = Math.round(s);
|
||||
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}min ${s % 60}s` : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}min`;
|
||||
};
|
||||
const date = (t) => (t ? new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
|
||||
const date = (t) => (t ? new Date(t * 1000).toLocaleString(LOC(), { dateStyle: 'medium', timeStyle: 'short' }) : 'sem data');
|
||||
const CATS = [
|
||||
['image', 'Fotos'], ['document', 'Documentos'], ['video', 'Vídeos'], ['audio', 'Áudio'],
|
||||
['archive', 'Compactados'], ['database', 'Bancos de dados'], ['other', 'Outros'],
|
||||
@@ -192,7 +201,7 @@ function renderSide() {
|
||||
rl.dataset.empty = 'Nada salvo ainda';
|
||||
rl.innerHTML = (S.recent || []).map((r, i) => `
|
||||
<li><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('pt-BR')} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString('pt-BR', { 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></li>`).join('');
|
||||
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
|
||||
}
|
||||
|
||||
@@ -270,9 +279,36 @@ async function openDevice(d) {
|
||||
main.innerHTML = `<div class="page"><p class="sub">Lendo ${esc(d.name)}…</p></div>`;
|
||||
main.scrollTop = 0;
|
||||
let layout = null, err = null;
|
||||
S.health = null;
|
||||
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
|
||||
S.layout = layout;
|
||||
renderDevice(err);
|
||||
// saúde (SMART) em segundo plano: não atrasa a tela
|
||||
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
|
||||
}
|
||||
|
||||
function paintHealth() {
|
||||
const el = $('#healthArea');
|
||||
const h = S.health;
|
||||
if (!el || !h || !h.available) { if (el) el.innerHTML = ''; return; }
|
||||
const bad = h.status === 'failing' || h.status === 'warning';
|
||||
const rows = (h.details || []).filter(([k]) => bad || ['Modelo', 'Horas ligado', 'Temperatura'].includes(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
|
||||
el.innerHTML = `<div class="notice ${h.status === 'failing' ? 'bad' : h.status === 'warning' ? '' : 'info'}">${warnSvg}<div><b>${esc(h.summary)}</b>
|
||||
${rows ? `<dl class="kv" style="margin-top:6px">${rows}</dl>` : ''}${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
|
||||
${bad ? '<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div>' : ''}</div></div>`;
|
||||
$('#healthImg')?.addEventListener('click', startImage);
|
||||
}
|
||||
|
||||
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
|
||||
function confirmRisky() {
|
||||
return new Promise((resolve) => {
|
||||
const h = S.health;
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Este disco parece estar com defeito</h2><p>${esc(h.summary)}</p>${(h.advice || []).map((a) => `<p class="sub">${esc(a)}</p>`).join('')}
|
||||
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn" value="scan">Varrer mesmo assim</button><button class="btn primary" value="image">Criar a imagem primeiro</button></menu></form>`;
|
||||
d.onclose = () => resolve(d.returnValue === 'image' ? 'image' : d.returnValue === 'scan' ? 'scan' : null);
|
||||
d.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function fsClass(p) {
|
||||
@@ -309,6 +345,7 @@ function renderDevice(err) {
|
||||
main.innerHTML = `<div class="page">
|
||||
<div class="dev-head"><div><h1>${esc(d.name)}</h1><div class="sub">${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · <span class="mono">${esc(d.path)}</span></div></div></div>
|
||||
${notes.map(([k, t]) => `<div class="notice ${k === 'bad' ? 'bad' : ''}">${warnSvg}<div>${t}</div></div>`).join('')}
|
||||
<div id="healthArea"></div>
|
||||
${l ? `<div class="section"><h2>Mapa do disco</h2>${diskMap(l)}
|
||||
<div class="legend">${l.partitions.map((p) => `<div><span class="dot ${fsClass(p)}"></span><b>Partição ${p.index + 1}</b> ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''}
|
||||
${p.lost ? '<span class="pill accent">encontrada por varredura</span>' : ''}${p.encrypted ? `<span class="pill warn">cifrada (${esc(p.encrypted)}) · só detecção</span>` : ''}</div>`).join('')}
|
||||
@@ -345,12 +382,19 @@ function renderDevice(err) {
|
||||
$('#goDeep')?.addEventListener('click', () => startScan(opts('deep')));
|
||||
$('#goQuick')?.addEventListener('click', () => startScan(opts('quick')));
|
||||
$('#goImage')?.addEventListener('click', startImage);
|
||||
paintHealth();
|
||||
$('#goSearch')?.addEventListener('click', startSearch);
|
||||
$('#srchTerms')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') startSearch(); });
|
||||
$('#goHash')?.addEventListener('click', startHash);
|
||||
}
|
||||
|
||||
async function startScan(options) {
|
||||
if (S.health && (S.health.status === 'failing' || S.health.status === 'warning') && options.mode === 'deep' && !S.healthAck?.[S.device.path]) {
|
||||
const c = await confirmRisky();
|
||||
if (c === 'image') return startImage();
|
||||
if (!c) return;
|
||||
(S.healthAck ??= {})[S.device.path] = true;
|
||||
}
|
||||
try {
|
||||
const { job } = await rpc('scan_start', { path: S.device.path, options });
|
||||
const s = { job, target: S.device.path, label: S.device.name };
|
||||
@@ -409,9 +453,9 @@ function openSearch(job, terms) {
|
||||
const run = st.status === 'running';
|
||||
const p = st.progress || {};
|
||||
const pct = p.total ? Math.floor((p.done / p.total) * 100) : 0;
|
||||
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString('pt-BR')} ocorrência${res.total === 1 ? '' : 's'}`;
|
||||
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString('pt-BR')} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString('pt-BR')}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
|
||||
$('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} ocorrência${res.total === 1 ? '' : 's'}`;
|
||||
setHTML($('#shProg'), run ? `<div class="bar"><i style="width:${pct}%"></i></div><div class="sub" style="margin-top:6px">${pct}% · ${res.total.toLocaleString(LOC())} até agora</div><button class="btn sm danger" data-stop="1" style="margin-top:8px">Parar</button>` : st.status === 'error' ? `<div class="notice bad">${warnSvg}<div>${esc(st.error)}</div></div>` : '');
|
||||
setHTML($('#hits'), res.items.length ? `<div class="hit head"><span>Posição</span><span>Partição</span><span>Codificação</span><span>Contexto</span></div>` + res.items.map((h) => `<div class="hit"><span class="mono" title="${h.offset} bytes (0x${h.offset.toString(16)})">${h.offset.toLocaleString(LOC())}</span><span>${h.partition ? '#' + h.partition : 'fora'}</span><span>${esc(h.encoding)}</span><span class="ctx">${esc(h.context).replace(termRe, (m) => `<mark>${m}</mark>`)}</span></div>`).join('') : (run ? '' : '<div class="empty"><h3>Nada encontrado</h3><p>Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).</p></div>'));
|
||||
return run;
|
||||
};
|
||||
$('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); };
|
||||
@@ -462,7 +506,7 @@ function renderScan() {
|
||||
</div>`;
|
||||
$('#backBtn').onclick = () => (S.device ? openDevice(S.device) : renderHome());
|
||||
$('#progressArea').onclick = onProgressClick;
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent) });
|
||||
EX = createExplorer({ host: $('#xpHost'), job: S.job, rpc, esc, human, fileUrl, thumbUrl, onRecover: recover, toast, noInlineMedia: !!tauri && /Linux/i.test(navigator.userAgent), getSettings: () => SETTINGS });
|
||||
}
|
||||
|
||||
function paintStatus() {
|
||||
@@ -475,7 +519,7 @@ function paintStatus() {
|
||||
$('#scanSub').innerHTML = `<span class="mono">${esc(st.target)}</span>`;
|
||||
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 enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}</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>'
|
||||
: 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>' : '';
|
||||
@@ -485,14 +529,14 @@ function paintStatus() {
|
||||
<div class="row spread"><div><b>${esc(p.phase || 'Iniciando')}</b>${pct != null ? ` <span class="sub">· ${pct}%</span>` : ''}</div><div class="row">${buttons}</div></div>
|
||||
<div class="bar ${running && pct == null ? 'indet' : ''}"><i style="width:${pct ?? (running ? 35 : 100)}%"></i></div>
|
||||
<div class="stats compact">
|
||||
<div class="stat"><b>${st.found.toLocaleString('pt-BR')}</b><span>arquivos</span></div>
|
||||
<div class="stat"><b>${st.found.toLocaleString(LOC())}</b><span>arquivos</span></div>
|
||||
<div class="stat"><b>${dur(st.elapsed)}</b><span>decorrido${eta ? ' · faltam ~' + eta : ''}</span></div>
|
||||
<div class="stat"><b style="color:var(--ok)">${st.confidence.high}</b><span>alta</span></div>
|
||||
<div class="stat"><b style="color:var(--warn)">${st.confidence.medium}</b><span>média</span></div>
|
||||
<div class="stat"><b style="color:var(--bad)">${st.confidence.low}</b><span>baixa</span></div>
|
||||
</div></div>`);
|
||||
} else {
|
||||
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString('pt-BR')}</b> arquivos em ${dur(st.elapsed)}</span>
|
||||
setHTML($('#progressArea'), `<div class="done-bar"><span><b>${st.found.toLocaleString(LOC())}</b> arquivos em ${dur(st.elapsed)}</span>
|
||||
<span class="sub">confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}</span>${enr}<span class="grow"></span>${tools}</div>`);
|
||||
}
|
||||
setHTML($('#warnArea'), (st.warnings || []).map((w) => `<div class="notice">${warnSvg}<div>${esc(w)}</div></div>`).join('')
|
||||
@@ -553,7 +597,7 @@ async function onProgressClick(e) {
|
||||
else if (act === 'relink') { chooseSource();
|
||||
} else if (act === 'csv') {
|
||||
const path = await ask({ title: 'Exportar para CSV', text: 'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-achados.csv', ok: 'Exportar' });
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString('pt-BR')} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
if (path) { try { const r = await rpc('export_csv', { job: S.job, path, query: EX?.query() }); toast(`${r.rows.toLocaleString(LOC())} linhas gravadas`); } catch (err) { toast(err.message); } }
|
||||
} else if (act === 'report') {
|
||||
const f = await askForm({ title: 'Relatório de recuperação', text: 'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.', ok: 'Gerar relatório', fields: [
|
||||
{ name: 'path', label: 'Salvar em', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'guara-relatorio.html' },
|
||||
@@ -575,7 +619,7 @@ async function onProgressClick(e) {
|
||||
async function recover(sel) {
|
||||
const f = await askForm({
|
||||
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.',
|
||||
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>',
|
||||
ok: 'Recuperar',
|
||||
fields: [
|
||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
||||
@@ -590,6 +634,7 @@ async function recover(sel) {
|
||||
const r = st.report || {};
|
||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||
return `<p><b>${ok}</b> arquivo(s) salvos em <code>${esc(dest)}</code> (${human(r.total_bytes)}).</p>
|
||||
${r.skipped ? `<p class="sub">${r.skipped} já estavam recuperados e conferidos (pulados).</p>` : ''}
|
||||
${bad ? `<div class="notice">${warnSvg}<div>${bad} arquivo(s) não puderam ser gravados.</div></div>` : ''}
|
||||
<p class="sub">O arquivo <code>guara-manifesto.json</code> lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.</p>`;
|
||||
});
|
||||
@@ -625,13 +670,72 @@ function watchAux(job, title, doneHtml) {
|
||||
|
||||
async function openJobFromList(s) { openJob(s); }
|
||||
|
||||
// ------------------------------------------------------------------ configurações (janela)
|
||||
$('#settingsBtn')?.addEventListener('click', () => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
|
||||
<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="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>
|
||||
<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.onclose = () => {
|
||||
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 };
|
||||
saveSettings(patch);
|
||||
setLang(patch.lang);
|
||||
toast('Salvo');
|
||||
};
|
||||
d.showModal();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ fechar com varredura em andamento
|
||||
function askClose() {
|
||||
return new Promise((resolve) => {
|
||||
const d = $('#dlg');
|
||||
d.innerHTML = `<form method="dialog"><h2>Há uma varredura em andamento</h2><p>A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.</p>
|
||||
<menu><button class="btn" value="stay">Continuar varrendo</button><button class="btn" value="quit">Sair sem pausar</button><button class="btn primary" value="pause">Pausar e sair</button></menu></form>`;
|
||||
d.onclose = () => resolve(d.returnValue);
|
||||
d.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
function watchClose() {
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (S.status?.status === 'running' && !tauri) { e.preventDefault(); e.returnValue = ''; }
|
||||
});
|
||||
const win = tauri?.window?.getCurrentWindow?.();
|
||||
if (!win?.onCloseRequested) return;
|
||||
win.onCloseRequested(async (ev) => {
|
||||
if (S.status?.status !== 'running') return;
|
||||
ev.preventDefault();
|
||||
const c = await askClose();
|
||||
if (c === 'stay' || !c) return;
|
||||
if (c === 'pause') {
|
||||
try {
|
||||
await rpc('scan_pause', { job: S.job });
|
||||
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
|
||||
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
|
||||
} catch { /* sai mesmo assim */ }
|
||||
}
|
||||
win.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ início do app
|
||||
(async function init() {
|
||||
try {
|
||||
const v = await rpc('version');
|
||||
$('#versionTag').textContent = `v${v.version}`;
|
||||
S.defaults = await rpc('defaults');
|
||||
try { const st = await rpc('settings_get'); if (st?.theme) { applyTheme(st.theme); try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ } } } catch { /* usa o local */ }
|
||||
try {
|
||||
const st = await rpc('settings_get');
|
||||
Object.assign(SETTINGS, st);
|
||||
applyTheme(st.theme);
|
||||
try { localStorage.setItem('guara-theme', st.theme); } catch { /* */ }
|
||||
} catch { /* usa o local */ }
|
||||
setLang(SETTINGS.lang);
|
||||
watchClose();
|
||||
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||
} catch (e) {
|
||||
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||
|
||||
+36
-13
@@ -4,6 +4,7 @@
|
||||
// visualização rápida em tela cheia.
|
||||
|
||||
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
|
||||
import { LOC } from './i18n.js';
|
||||
|
||||
const CAT = {
|
||||
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
|
||||
@@ -32,6 +33,10 @@ const I = {
|
||||
export function createExplorer(deps) {
|
||||
const { host, job, rpc, esc, human, fileUrl, thumbUrl, onRecover, toast } = deps;
|
||||
const inlineMedia = !deps.noInlineMedia;
|
||||
const dbApi = (it) => ({
|
||||
info: () => rpc('db_info', { job, id: it.id }),
|
||||
rows: (table, offset, limit, deleted) => rpc('db_rows', { job, id: it.id, table, offset, limit, deleted }),
|
||||
});
|
||||
const openExternal = (it) => {
|
||||
toast('Preparando para abrir no player do sistema…');
|
||||
rpc('open_external', { job, id: it.id }).then(() => toast('Aberto no player do sistema')).catch((err) => toast(err.message));
|
||||
@@ -141,11 +146,11 @@ export function createExplorer(deps) {
|
||||
const dayLabel = (k) => {
|
||||
if (k === 'Sem data') return k;
|
||||
return groupBy === 'day'
|
||||
? new Date(k + 'T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: new Date(k + '-01T00:00:00Z').toLocaleDateString('pt-BR', { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||
? new Date(k + 'T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
|
||||
};
|
||||
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString('pt-BR', { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString('pt-BR', { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
|
||||
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
|
||||
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
|
||||
|
||||
function geometry() {
|
||||
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
||||
@@ -383,11 +388,11 @@ export function createExplorer(deps) {
|
||||
function paintActions() {
|
||||
const n = selCount();
|
||||
const html = n
|
||||
? `<span class="info"><b>${n.toLocaleString('pt-BR')}</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>
|
||||
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString('pt-BR')}</button>` : ''}
|
||||
${!E.allFiltered && E.total > n ? `<button class="btn sm" data-a="all">Selecionar todos os ${E.total.toLocaleString(LOC())}</button>` : ''}
|
||||
<button class="btn primary" data-a="rec">Recuperar ${n === 1 ? 'selecionado' : 'selecionados'}</button>`
|
||||
: `<span class="info">${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''}</span>
|
||||
: `<span class="info">${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''}</span>
|
||||
${E.total ? '<button class="btn sm" data-a="all">Selecionar tudo</button><button class="btn primary" data-a="recall">Recuperar tudo desta vista</button>' : ''}`;
|
||||
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
|
||||
}
|
||||
@@ -405,7 +410,7 @@ export function createExplorer(deps) {
|
||||
if (!f) return;
|
||||
const F = E.filters;
|
||||
const node = (label, count, attrs, active, depth = 0, extra = '') =>
|
||||
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString('pt-BR')}</small></button>`;
|
||||
`<button class="xp-node${active ? ' on' : ''}" ${attrs} style="--d:${depth}" aria-pressed="${!!active}">${extra}<span class="l">${label}</span><small>${count.toLocaleString(LOC())}</small></button>`;
|
||||
const sec = (title, body) => (body ? `<div class="xp-sec"><h4>${title}</h4>${body}</div>` : '');
|
||||
|
||||
const cats = Object.keys(CAT).filter((k) => f.categories[k]).map((k) => node(`${CAT[k][1]} ${CAT[k][0]}`, f.categories[k], `data-f="category" data-v="${k}"`, F.category === k)).join('');
|
||||
@@ -489,7 +494,7 @@ export function createExplorer(deps) {
|
||||
if (F.undated) parts.push('Sem data');
|
||||
if (F.folder) parts.push(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : F.folder.split('/').join(' › '));
|
||||
if (F.camera) parts.push(F.camera);
|
||||
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString('pt-BR')}</small>`;
|
||||
$('#xpCrumb').innerHTML = `<b>${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}</b><small>${E.total.toLocaleString(LOC())}</small>`;
|
||||
}
|
||||
|
||||
function paintChips() {
|
||||
@@ -558,7 +563,7 @@ export function createExplorer(deps) {
|
||||
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||
if (n > 1) {
|
||||
const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0);
|
||||
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString('pt-BR')} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
|
||||
insp.innerHTML = `${close}<div class="xp-multi"><b>${n.toLocaleString(LOC())} itens selecionados</b>${sizeSum != null ? `<span class="sub">${human(sizeSum)} no total</span>` : ''}
|
||||
<div class="row"><button class="btn primary" data-i="rec">Recuperar selecionados</button><button class="btn" data-i="clear">Limpar seleção</button></div></div>`;
|
||||
return;
|
||||
}
|
||||
@@ -575,7 +580,7 @@ export function createExplorer(deps) {
|
||||
<dl class="kv"><dt>Pasta</dt><dd>${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}</dd>${timesHtml(it)}${it.camera ? `<dt>Câmera</dt><dd>${esc(it.camera)}</dd>` : ''}${it.px ? `<dt>Resolução</dt><dd>${it.px[0]} × ${it.px[1]} px</dd>` : ''}</dl>
|
||||
<div class="xp-meta" id="xpMeta"></div>
|
||||
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
|
||||
// clicar na miniatura abre o modal grande
|
||||
$('#xpPv').addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button, a, .pv-pdfbar')) return;
|
||||
@@ -587,9 +592,27 @@ export function createExplorer(deps) {
|
||||
const rows = (m.fields || []).filter(([k]) => !['Dimensões'].includes(k));
|
||||
if (!rows.length) return;
|
||||
$('#xpMeta').innerHTML = `<h5>Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}</h5><dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`
|
||||
+ (m.gps ? `<div class="sub" style="font-size:12.5px">Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}</div>` : '');
|
||||
+ (m.gps ? gpsHtml(m.gps) : '');
|
||||
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
|
||||
}).catch(() => {});
|
||||
}
|
||||
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
|
||||
function gpsHtml([lat, lon]) {
|
||||
let map = '';
|
||||
if (deps.getSettings?.().maps) {
|
||||
const z = 15, n = 2 ** z;
|
||||
const fx = (lon + 180) / 360 * n;
|
||||
const fy = (1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n;
|
||||
const tx = Math.floor(fx), ty = Math.floor(fy);
|
||||
let imgs = '';
|
||||
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) imgs += `<img src="https://tile.openstreetmap.org/${z}/${tx + dx}/${ty + dy}.png" alt="" loading="lazy" draggable="false">`;
|
||||
const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100;
|
||||
map = `<div class="xp-map"><div class="xp-tiles">${imgs}</div><span class="xp-pin" style="left:${px}%;top:${py}%"></span></div>`;
|
||||
}
|
||||
return `<div class="sub" style="font-size:12.5px;margin-top:8px">Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}</div>${map}
|
||||
<div class="row" style="margin-top:6px"><button class="btn sm" id="xpOsm">Abrir no OpenStreetMap</button></div>
|
||||
${map ? '<div class="sub" style="font-size:11px;margin-top:4px">© colaboradores do OpenStreetMap</div>' : ''}`;
|
||||
}
|
||||
insp.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('[data-i]')?.dataset.i;
|
||||
if (a === 'full') openLightbox(idxOf(E.curId));
|
||||
@@ -612,7 +635,7 @@ export function createExplorer(deps) {
|
||||
<div class="lb-body"><div class="viewer large" id="lbHost"></div></div>
|
||||
<div class="lb-foot"><span class="sub">${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}</span><button class="btn primary sm" id="lbRec">Recuperar este arquivo</button></div>`;
|
||||
stopLb?.();
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, onExternal: () => openExternal(it) });
|
||||
stopLb = renderPreview(d.querySelector('#lbHost'), it, { url: fileUrl(job, it.id), large: true, inlineMedia, db: dbApi(it), onExternal: () => openExternal(it) });
|
||||
const go = (n) => { const t = i + n; if (t >= 0 && t < E.items.length) { setCursor(t, { reveal: true }); openLightbox(t); } };
|
||||
d.querySelector('#lbPrev').onclick = () => go(-1);
|
||||
d.querySelector('#lbNext').onclick = () => go(1);
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// Dicionário português -> inglês da interface. {n} marca um valor variável.
|
||||
export const EN = {
|
||||
// barra lateral e estrutura
|
||||
'Recuperação de arquivos': 'File recovery',
|
||||
'Dispositivos': 'Devices',
|
||||
'Dispositivos e sessões': 'Devices and sessions',
|
||||
'Atualizar lista': 'Refresh list',
|
||||
'Lista atualizada': 'List refreshed',
|
||||
'Abrir arquivo de imagem…': 'Open disk image file…',
|
||||
'Abrir arquivo de imagem': 'Open disk image file',
|
||||
'Abrir sessão salva…': 'Open saved session…',
|
||||
'Abrir sessão salva': 'Open saved session',
|
||||
'Sessões': 'Sessions',
|
||||
'Sessão': 'Session',
|
||||
'Sessão:': 'Session:',
|
||||
'Recentes': 'Recent',
|
||||
'(salvas sozinhas)': '(saved automatically)',
|
||||
'Nenhuma ainda': 'None yet',
|
||||
'Nada salvo ainda': 'Nothing saved yet',
|
||||
'Nenhum disco encontrado': 'No disks found',
|
||||
'Somente leitura na origem': 'Read-only on the source',
|
||||
'Abrir menu': 'Open menu',
|
||||
'Tema': 'Theme',
|
||||
'Auto': 'Auto',
|
||||
'Claro': 'Light',
|
||||
'Escuro': 'Dark',
|
||||
'Configurações': 'Settings',
|
||||
'· requer admin': '· needs admin',
|
||||
'requer admin': 'needs admin',
|
||||
'{n} · requer admin': '{n} · needs admin',
|
||||
// início
|
||||
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
|
||||
'Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
|
||||
'Uma varredura, tudo junto': 'One scan, everything together',
|
||||
'Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.': 'File system metadata and signature search in the same pass, without duplicates.',
|
||||
'Confiança por arquivo': 'Confidence per file',
|
||||
'Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.': 'Each finding shows the chance of opening correctly: high, medium or low.',
|
||||
'Origem protegida': 'Protected source',
|
||||
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
|
||||
'Nenhum disco apareceu.': 'No disks showed up.',
|
||||
// dispositivo
|
||||
'Mapa do disco': 'Disk map',
|
||||
'Como procurar': 'How to search',
|
||||
'Recomendado': 'Recommended',
|
||||
'Varredura profunda': 'Deep scan',
|
||||
'Varredura rápida': 'Quick scan',
|
||||
'Lê os metadados e examina todo o espaço livre, partições danificadas e áreas não particionadas por assinatura. Encontra mais, leva mais tempo.': 'Reads the metadata and examines all free space, damaged partitions and unpartitioned areas by signature. Finds more, takes longer.',
|
||||
'Procurar tudo': 'Search everything',
|
||||
'Só os registros do sistema de arquivos. Em segundos mostra arquivos apagados recentemente, com nome e pasta.': 'Only the file system records. In seconds it shows recently deleted files, with name and folder.',
|
||||
'Criar imagem do disco': 'Create disk image',
|
||||
'Copia o disco inteiro para um arquivo, tolerando setores ruins. Ideal para discos com defeito: você trabalha na cópia.': 'Copies the whole disk to a file, tolerating bad sectors. Ideal for failing disks: you work on the copy.',
|
||||
'Criar imagem…': 'Create image…',
|
||||
'Ferramentas de perícia': 'Forensic tools',
|
||||
'Pesquisar texto no disco': 'Search text on the disk',
|
||||
'Procura palavras em todo o disco, inclusive em espaço livre e fora de partições (UTF-8 e UTF-16). Útil quando não há estrutura de arquivo.': 'Searches words across the whole disk, including free space and outside partitions (UTF-8 and UTF-16). Useful when there is no file structure.',
|
||||
'termos separados por vírgula': 'terms separated by commas',
|
||||
'Termos a pesquisar': 'Terms to search',
|
||||
'Pesquisar': 'Search',
|
||||
'Hashes da origem': 'Source hashes',
|
||||
'Calcula MD5, SHA-1 e SHA-256 do disco inteiro para registrar a integridade e a cadeia de custódia no relatório.': 'Computes MD5, SHA-1 and SHA-256 of the whole disk to record integrity and chain of custody in the report.',
|
||||
'Calcular hashes': 'Compute hashes',
|
||||
'Opções avançadas': 'Advanced options',
|
||||
'Incluir áreas ainda em uso': 'Include areas still in use',
|
||||
'Também procura cópias antigas dentro de arquivos existentes. Bem mais lento.': 'Also looks for old copies inside existing files. Much slower.',
|
||||
'Sem nenhuma categoria marcada, todos os tipos são procurados.': 'With no category checked, all types are searched.',
|
||||
'Este disco exige permissão de administrador.': 'This disk requires administrator permission.',
|
||||
'Reiniciar como administrador': 'Restart as administrator',
|
||||
'Feche e rode de novo com': 'Close and run again with',
|
||||
'ou como administrador (Windows).': 'or as administrator (Windows).',
|
||||
'Espaço não particionado': 'Unpartitioned space',
|
||||
'Não particionado': 'Unpartitioned',
|
||||
'encontrada por varredura': 'found by scanning',
|
||||
'Desconhecido': 'Unknown',
|
||||
'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
|
||||
// saúde do disco
|
||||
'Saúde do disco': 'Disk health',
|
||||
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
|
||||
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
|
||||
'Nenhum sinal de defeito encontrado pelo SMART.': 'No signs of failure found by SMART.',
|
||||
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
|
||||
'Crie a imagem do disco primeiro (botão "Criar imagem" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.': 'Create the disk image first ("Create image" button or `guara image`) and scan the copy. Every extra read can make a failing disk worse.',
|
||||
'Criar a imagem primeiro': 'Create the image first',
|
||||
'Varrer mesmo assim': 'Scan anyway',
|
||||
'Cancelar': 'Cancel',
|
||||
'Este disco parece estar com defeito': 'This disk seems to be failing',
|
||||
// varredura
|
||||
'‹ Dispositivos': '‹ Devices',
|
||||
'Procurando…': 'Searching…',
|
||||
'Varredura concluída': 'Scan complete',
|
||||
'Varredura pausada': 'Scan paused',
|
||||
'Varredura interrompida': 'Scan stopped',
|
||||
'Algo deu errado': 'Something went wrong',
|
||||
'Iniciando': 'Starting',
|
||||
'Pausar': 'Pause',
|
||||
'Parar': 'Stop',
|
||||
'Continuar': 'Continue',
|
||||
'Pausando…': 'Pausing…',
|
||||
'Reconectar o disco': 'Reconnect the disk',
|
||||
'Escolher a origem…': 'Choose the source…',
|
||||
'Escolher a origem': 'Choose the source',
|
||||
'Salvar sessão…': 'Save session…',
|
||||
'Exportar CSV': 'Export CSV',
|
||||
'Relatório': 'Report',
|
||||
'arquivos encontrados': 'files found',
|
||||
'arquivos': 'files',
|
||||
'decorrido': 'elapsed',
|
||||
'confiança alta': 'high confidence',
|
||||
'média': 'medium',
|
||||
'baixa': 'low',
|
||||
'alta': 'high',
|
||||
'· faltam ~{n}': '· ~{n} left',
|
||||
'· {n}%': '· {n}%',
|
||||
'{n} arquivos em {n}': '{n} files in {n}',
|
||||
'confiança alta {n} · média {n} · baixa {n}': 'high confidence {n} · medium {n} · low {n}',
|
||||
'Lendo datas e metadados das fotos… {n}/{n}': 'Reading dates and metadata from photos… {n}/{n}',
|
||||
'Lendo tabela de partições': 'Reading partition table',
|
||||
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
|
||||
'Varredura profunda por assinatura': 'Deep signature scan',
|
||||
// explorador
|
||||
'Navegação': 'Navigation',
|
||||
'Filtros': 'Filters',
|
||||
'Todos os arquivos': 'All files',
|
||||
'Tudo': 'All',
|
||||
'Tipos': 'Types',
|
||||
'Datas': 'Dates',
|
||||
'Pastas': 'Folders',
|
||||
'Câmeras': 'Cameras',
|
||||
'Confiança': 'Confidence',
|
||||
'Outros': 'Other',
|
||||
'Fotos': 'Photos',
|
||||
'Documentos': 'Documents',
|
||||
'Vídeos': 'Videos',
|
||||
'Áudio': 'Audio',
|
||||
'Compactados': 'Archives',
|
||||
'Bancos de dados': 'Databases',
|
||||
'Raiz': 'Root',
|
||||
'/ (raiz)': '/ (root)',
|
||||
'Por assinatura': 'By signature',
|
||||
'Sem data': 'No date',
|
||||
'sem data': 'no date',
|
||||
'Confiança {n}': 'Confidence {n}',
|
||||
'Com localização (GPS)': 'With location (GPS)',
|
||||
'Com GPS': 'With GPS',
|
||||
'Ocultar duplicadas': 'Hide duplicates',
|
||||
'Sem duplicadas': 'Without duplicates',
|
||||
'Ocultar arquivos pequenos (menos de 30 KB)': 'Hide small files (under 30 KB)',
|
||||
'Sem arquivos pequenos': 'Without small files',
|
||||
'Mais de 1 megapixel': 'Over 1 megapixel',
|
||||
'Mais de 1 MP': 'Over 1 MP',
|
||||
'Limpar filtros': 'Clear filters',
|
||||
'Buscar por nome, pasta ou câmera': 'Search by name, folder or camera',
|
||||
'Buscar': 'Search',
|
||||
'Ordenar': 'Sort',
|
||||
'Data (mais recentes)': 'Date (newest)',
|
||||
'Data (mais antigas)': 'Date (oldest)',
|
||||
'Nome': 'Name',
|
||||
'Tamanho': 'Size',
|
||||
'Modo de exibição': 'View mode',
|
||||
'Grade': 'Grid',
|
||||
'Lista': 'List',
|
||||
'Grade de miniaturas': 'Thumbnail grid',
|
||||
'Tamanho das miniaturas': 'Thumbnail size',
|
||||
'Arquivos encontrados': 'Files found',
|
||||
'Detalhes': 'Details',
|
||||
'janeiro': 'January', 'fevereiro': 'February', 'março': 'March', 'abril': 'April', 'maio': 'May', 'junho': 'June',
|
||||
'julho': 'July', 'agosto': 'August', 'setembro': 'September', 'outubro': 'October', 'novembro': 'November', 'dezembro': 'December',
|
||||
'{n} item': '{n} item',
|
||||
'{n} itens': '{n} items',
|
||||
'{n} itens nesta vista': '{n} items in this view',
|
||||
'{n} item nesta vista': '{n} item in this view',
|
||||
'{n} itens nesta vista (mostrando {n})': '{n} items in this view (showing {n})',
|
||||
'{n} selecionado': '{n} selected',
|
||||
'{n} selecionados': '{n} selected',
|
||||
'{n} selecionado (todos desta vista)': '{n} selected (all in this view)',
|
||||
'{n} selecionados (todos desta vista)': '{n} selected (all in this view)',
|
||||
'{n} itens selecionados': '{n} items selected',
|
||||
'{n} no total': '{n} in total',
|
||||
'Selecionar grupo': 'Select group',
|
||||
'Selecionar este grupo': 'Select this group',
|
||||
'Selecionar tudo': 'Select all',
|
||||
'Selecionar todos os {n}': 'Select all {n}',
|
||||
'Limpar seleção': 'Clear selection',
|
||||
'Recuperar tudo desta vista': 'Recover everything in this view',
|
||||
'Recuperar selecionado': 'Recover selected',
|
||||
'Recuperar selecionados': 'Recover selected',
|
||||
'Recuperar este arquivo': 'Recover this file',
|
||||
'Tela cheia': 'Full screen',
|
||||
'Fechar': 'Close',
|
||||
'Anterior': 'Previous',
|
||||
'Próximo': 'Next',
|
||||
'← → para navegar · Esc para fechar': '← → to navigate · Esc to close',
|
||||
'Selecione um arquivo para pré-visualizar.': 'Select a file to preview.',
|
||||
'Selecione um arquivo para pré-visualizar': 'Select a file to preview',
|
||||
'Setas navegam, Espaço abre em tela cheia.': 'Arrows navigate, Space opens full screen.',
|
||||
'Pasta': 'Folder',
|
||||
'Modificado': 'Modified',
|
||||
'Criado': 'Created',
|
||||
'Acessado': 'Accessed',
|
||||
'Tirada em': 'Taken on',
|
||||
'Câmera': 'Camera',
|
||||
'Resolução': 'Resolution',
|
||||
'Metadados': 'Metadata',
|
||||
'Metadados · {n}': 'Metadata · {n}',
|
||||
'Coordenadas:': 'Coordinates:',
|
||||
'Aviso': 'Warning',
|
||||
'Encontrado por assinatura': 'Found by signature',
|
||||
'Assinatura de formato': 'Format signature',
|
||||
'Origem': 'Source',
|
||||
'Origem desconectada: sem miniatura': 'Source disconnected: no thumbnail',
|
||||
'Não foi possível gerar a miniatura (arquivo possivelmente danificado)': 'Could not generate the thumbnail (file possibly damaged)',
|
||||
'Tem localização GPS': 'Has GPS location',
|
||||
'Confiança {n}': 'Confidence {n}',
|
||||
'Nada por aqui': 'Nothing here',
|
||||
// pré-visualização
|
||||
'Imagem': 'Image',
|
||||
'Vídeo': 'Video',
|
||||
'PDF': 'PDF',
|
||||
'Texto': 'Text',
|
||||
'Documento (texto extraído)': 'Document (extracted text)',
|
||||
'Conteúdo do arquivo compactado': 'Archive contents',
|
||||
'Dados brutos (hexadecimal)': 'Raw data (hexadecimal)',
|
||||
'Banco de dados SQLite': 'SQLite database',
|
||||
'Banco SQLite': 'SQLite database',
|
||||
'Arquivo vazio.': 'Empty file.',
|
||||
'Não foi possível pré-visualizar.': 'Could not preview.',
|
||||
'O arquivo pode estar danificado, incompleto ou parcialmente sobrescrito, o que também indica que a chance de recuperação é menor.': 'The file may be damaged, incomplete or partially overwritten, which also means the chance of recovery is lower.',
|
||||
'A imagem não pôde ser decodificada.': 'The image could not be decoded.',
|
||||
'Este arquivo não parece ser texto.': 'This file does not look like text.',
|
||||
'Mostrando o início ({n} de {n}).': 'Showing the beginning ({n} of {n}).',
|
||||
'Mostrando os primeiros {n} de {n}.': 'Showing the first {n} of {n}.',
|
||||
'{n} × {n} px. Clique para alternar entre ajustar e tamanho real.': '{n} × {n} px. Click to toggle between fit and actual size.',
|
||||
'Texto extraído do documento. A formatação, as imagens e os gráficos originais não são exibidos.': 'Text extracted from the document. The original formatting, images and charts are not shown.',
|
||||
'{n} item(ns) no arquivo compactado.': '{n} item(s) in the archive.',
|
||||
'Sem texto.': 'No text.',
|
||||
'Página {n} de {n}': 'Page {n} of {n}',
|
||||
'Página anterior': 'Previous page',
|
||||
'Próxima página': 'Next page',
|
||||
'Reproduzir em tela grande': 'Play full size',
|
||||
'Abrir no player do sistema': 'Open in the system player',
|
||||
'Preparando para abrir no player do sistema…': 'Preparing to open in the system player…',
|
||||
'Aberto no player do sistema': 'Opened in the system player',
|
||||
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
|
||||
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
|
||||
'pasta': 'folder',
|
||||
// banco de dados
|
||||
'Abrir o visualizador': 'Open the viewer',
|
||||
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
|
||||
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
|
||||
'Tabelas': 'Tables',
|
||||
'Views': 'Views',
|
||||
'Índices': 'Indexes',
|
||||
'Filtrar esta página': 'Filter this page',
|
||||
'Filtrar': 'Filter',
|
||||
'Registros apagados (prováveis)': 'Deleted records (likely)',
|
||||
'Ver esquema': 'View schema',
|
||||
'Ver dados': 'View data',
|
||||
'apagado': 'deleted',
|
||||
'Nenhuma linha.': 'No rows.',
|
||||
'Nenhuma tabela.': 'No tables.',
|
||||
'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.': 'No deleted records recognized in this table. That does not guarantee there are none: the content may have been overwritten.',
|
||||
'{n} registro(s) provável(is) apagado(s)': '{n} likely deleted record(s)',
|
||||
'{n} a {n} de {n}': '{n} to {n} of {n}',
|
||||
'‹ Anterior': '‹ Previous',
|
||||
'Próximas ›': 'Next ›',
|
||||
// recuperar
|
||||
'Recuperar para…': 'Recover to…',
|
||||
'Recuperar': 'Recover',
|
||||
'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.': 'Choose a folder on <b>another disk</b>. Guará never writes to the source disk, so it does not overwrite what can still be saved.',
|
||||
'Pasta de destino': 'Destination folder',
|
||||
'Escolher…': 'Choose…',
|
||||
'Organizar os arquivos': 'Organize the files',
|
||||
'Pastas originais (e por tipo quando não há pasta)': 'Original folders (and by type when there is no folder)',
|
||||
'Por tipo (Fotos, Documentos, Vídeos...)': 'By type (Photos, Documents, Videos...)',
|
||||
'Por data (ano e mês)': 'By date (year and month)',
|
||||
'Tudo na mesma pasta': 'Everything in the same folder',
|
||||
'Recuperando arquivos': 'Recovering files',
|
||||
'Pronto': 'Done',
|
||||
'Interrompido': 'Stopped',
|
||||
'Salvar sessão': 'Save session',
|
||||
'Guarda os resultados para recuperar depois, sem varrer de novo.': 'Saves the results so you can recover later without scanning again.',
|
||||
'Sessão salva': 'Session saved',
|
||||
'Salvar': 'Save',
|
||||
'Exportar para CSV': 'Export to CSV',
|
||||
'Exportar': 'Export',
|
||||
'Grava os arquivos da vista atual (com os filtros aplicados). O arquivo abre direto no Excel.': 'Writes the files in the current view (with filters applied). The file opens directly in Excel.',
|
||||
'{n} linhas gravadas': '{n} rows written',
|
||||
'Relatório de recuperação': 'Recovery report',
|
||||
'Gerar relatório': 'Generate report',
|
||||
'Gera um relatório HTML pronto para imprimir, com a origem examinada, os achados e os hashes dos arquivos recuperados.': 'Generates a print-ready HTML report with the examined source, the findings and the hashes of recovered files.',
|
||||
'Salvar em': 'Save to',
|
||||
'Número do caso': 'Case number',
|
||||
'Responsável pelo exame': 'Examiner',
|
||||
'Organização': 'Organization',
|
||||
'Observações': 'Notes',
|
||||
'Relatório gravado': 'Report saved',
|
||||
'lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.': 'lists the MD5, SHA-1 and SHA-256 of each file so you can verify integrity.',
|
||||
'O arquivo': 'The file',
|
||||
'Não foi possível recuperar': 'Could not recover',
|
||||
'Não foi possível iniciar': 'Could not start',
|
||||
'Entendi': 'Got it',
|
||||
'{n} arquivo(s) salvos em {n} ({n}).': '{n} file(s) saved to {n} ({n}).',
|
||||
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
|
||||
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
|
||||
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
|
||||
// imagem, hash, busca
|
||||
'Onde salvar o arquivo de imagem? Use <b>outro disco</b> com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use <b>another disk</b> with enough space and a name that does not exist yet.',
|
||||
'Criando imagem do disco': 'Creating disk image',
|
||||
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
|
||||
'Calculando hashes da origem': 'Computing source hashes',
|
||||
'Eles serão incluídos nos relatórios gerados nesta sessão.': 'They will be included in reports generated in this session.',
|
||||
'Digite pelo menos um termo.': 'Type at least one term.',
|
||||
'Posição': 'Position',
|
||||
'Partição': 'Partition',
|
||||
'Codificação': 'Encoding',
|
||||
'Contexto': 'Context',
|
||||
'fora': 'outside',
|
||||
'Nada encontrado': 'Nothing found',
|
||||
'Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).': 'These terms do not appear anywhere on the disk (neither in UTF-8 nor UTF-16).',
|
||||
'{n} ocorrência': '{n} occurrence',
|
||||
'{n} ocorrências': '{n} occurrences',
|
||||
'{n} ocorrência{n}': '{n} occurrence{n}',
|
||||
'Termos: {n}': 'Terms: {n}',
|
||||
'{n}% · {n} até agora': '{n}% · {n} so far',
|
||||
// sessões e origem
|
||||
'Abrir arquivo de imagem': 'Open disk image file',
|
||||
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
|
||||
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
|
||||
'Abrir': 'Open',
|
||||
'Abrir sessão salva': 'Open saved session',
|
||||
'Não foi possível abrir a sessão': 'Could not open the session',
|
||||
'Disco encontrado como {n}': 'Disk found as {n}',
|
||||
'Disco reconectado': 'Disk reconnected',
|
||||
'Origem reconectada': 'Source reconnected',
|
||||
'Não foi possível usar essa origem': 'Could not use this source',
|
||||
'Esta sessão foi feita sobre': 'This session was made on',
|
||||
'Escolha o disco que voltou ou uma imagem dele.': 'Choose the disk that came back or an image of it.',
|
||||
'Nenhum disco encontrado. Informe o caminho abaixo.': 'No disks found. Enter the path below.',
|
||||
'Ou o caminho de um dispositivo ou imagem': 'Or the path to a device or image',
|
||||
'Usar mesmo assim, mesmo que o conteúdo inicial seja diferente (por exemplo, uma cópia editada)': 'Use it anyway, even if the initial content differs (for example, an edited copy)',
|
||||
'Usar esta origem': 'Use this source',
|
||||
'mesmo disco': 'same disk',
|
||||
'tamanho igual, conteúdo diferente': 'same size, different content',
|
||||
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
|
||||
// configurações e fechamento
|
||||
'Idioma': 'Language',
|
||||
'Notificações': 'Notifications',
|
||||
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
|
||||
'Mapas online': 'Online maps',
|
||||
'Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)': 'Show maps for photos with GPS (uses the internet: downloads OpenStreetMap tiles)',
|
||||
'Salvo': 'Saved',
|
||||
'Mostrar mapa': 'Show map',
|
||||
'Mostrar mapa (usa a internet)': 'Show map (uses the internet)',
|
||||
'Abrir no OpenStreetMap': 'Open in OpenStreetMap',
|
||||
'Há uma varredura em andamento': 'A scan is in progress',
|
||||
'Pausar e sair': 'Pause and quit',
|
||||
'Sair sem pausar': 'Quit without pausing',
|
||||
'Continuar varrendo': 'Keep scanning',
|
||||
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
|
||||
// erros comuns vindos do núcleo
|
||||
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
|
||||
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
|
||||
'Autorização cancelada.': 'Authorization canceled.',
|
||||
'Origem desconectada': 'Source disconnected',
|
||||
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
|
||||
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
|
||||
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
|
||||
// continua observando a página para traduzir o que for desenhado depois.
|
||||
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
|
||||
|
||||
import { EN } from './i18n-en.js';
|
||||
|
||||
let lang = 'pt';
|
||||
export const isEn = () => lang === 'en';
|
||||
export const LOC = () => (lang === 'en' ? 'en-US' : 'pt-BR');
|
||||
|
||||
export function resolveLang(pref) {
|
||||
if (pref === 'pt' || pref === 'en') return pref;
|
||||
return (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en';
|
||||
}
|
||||
|
||||
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
|
||||
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
|
||||
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
|
||||
|
||||
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
|
||||
const patterns = Object.entries(EN)
|
||||
.filter(([k]) => k.includes('{n}'))
|
||||
.map(([k, v]) => {
|
||||
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
|
||||
})
|
||||
// padrões mais específicos (mais texto fixo) primeiro
|
||||
.sort((a, b) => b.re.source.length - a.re.source.length);
|
||||
|
||||
const cache = new Map();
|
||||
export function tr(text) {
|
||||
if (!isEn()) return text;
|
||||
const lead = text.match(/^\s*/)[0];
|
||||
const trail = text.match(/\s*$/)[0];
|
||||
const core = text.trim();
|
||||
if (!core) return text;
|
||||
if (cache.has(core)) return lead + cache.get(core) + trail;
|
||||
let out = exact.get(core);
|
||||
if (out === undefined) {
|
||||
for (const p of patterns) {
|
||||
const m = core.match(p.re);
|
||||
if (m) {
|
||||
let i = 0;
|
||||
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
|
||||
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out === undefined) out = core;
|
||||
if (cache.size > 5000) cache.clear();
|
||||
cache.set(core, out);
|
||||
return lead + out + trail;
|
||||
}
|
||||
|
||||
const orig = new WeakMap(); // nó -> texto original em português
|
||||
let observer = null;
|
||||
|
||||
function translateNode(n) {
|
||||
if (n.nodeType === 3) {
|
||||
if (n.parentElement?.closest(SKIP)) return;
|
||||
const o = orig.get(n) ?? n.nodeValue;
|
||||
const t = tr(o);
|
||||
if (t !== n.nodeValue) {
|
||||
if (!orig.has(n)) orig.set(n, o);
|
||||
n.nodeValue = t;
|
||||
}
|
||||
} else if (n.nodeType === 1) {
|
||||
if (n.matches?.(SKIP) && n.tagName !== 'INPUT') return;
|
||||
for (const a of ATTRS) {
|
||||
if (n.hasAttribute?.(a)) {
|
||||
const key = `i18n-${a}`;
|
||||
const o = n[key] ?? n.getAttribute(a);
|
||||
const t = tr(o);
|
||||
if (t !== n.getAttribute(a)) { n[key] = o; n.setAttribute(a, t); }
|
||||
}
|
||||
}
|
||||
for (const c of n.childNodes) translateNode(c);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreAll(root) {
|
||||
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let n;
|
||||
while ((n = w.nextNode())) { if (orig.has(n)) { n.nodeValue = orig.get(n); orig.delete(n); } }
|
||||
root.querySelectorAll('*').forEach((el) => {
|
||||
for (const a of ATTRS) { const key = `i18n-${a}`; if (el[key] !== undefined) { el.setAttribute(a, el[key]); delete el[key]; } }
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
translateNode(document.body);
|
||||
document.title = tr(document.title);
|
||||
if (observer) return;
|
||||
observer = new MutationObserver((muts) => {
|
||||
observer.disconnect();
|
||||
for (const m of muts) {
|
||||
if (m.type === 'childList') m.addedNodes.forEach(translateNode);
|
||||
else if (m.type === 'characterData') { orig.delete(m.target); translateNode(m.target); }
|
||||
else if (m.type === 'attributes') { delete m.target[`i18n-${m.attributeName}`]; translateNode(m.target); }
|
||||
}
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ATTRS });
|
||||
}
|
||||
|
||||
function stop() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
restoreAll(document.body);
|
||||
}
|
||||
|
||||
export function setLang(pref) {
|
||||
const next = resolveLang(pref);
|
||||
const changed = next !== lang;
|
||||
lang = next;
|
||||
document.documentElement.lang = lang === 'en' ? 'en' : 'pt-BR';
|
||||
if (lang === 'en') start();
|
||||
else if (changed || observer) stop();
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
<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>
|
||||
</nav>
|
||||
<footer class="side-foot"><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
<footer class="side-foot"><button class="icon-btn small" id="settingsBtn" title="Configurações" aria-label="Configurações"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button><div class="theme-switch" role="group" aria-label="Tema"><button data-theme-pick="auto" aria-pressed="true">Auto</button><button data-theme-pick="light" aria-pressed="false">Claro</button><button data-theme-pick="dark" aria-pressed="false">Escuro</button></div><span id="versionTag"></span><span>Somente leitura na origem</span></footer>
|
||||
</aside>
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<main class="main" id="main" tabindex="-1"></main>
|
||||
|
||||
+91
-2
@@ -1,3 +1,4 @@
|
||||
import { LOC } from './i18n.js';
|
||||
// Pré-visualização de arquivos recuperados.
|
||||
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
|
||||
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
|
||||
@@ -9,6 +10,7 @@ const AUD = new Set(['mp3', 'wav', 'ogg', 'opus', 'm4a', 'aac', 'flac']);
|
||||
const TXT = new Set(['txt', 'md', 'csv', 'tsv', 'json', 'xml', 'html', 'htm', 'log', 'ini', 'yml', 'yaml', 'js', 'css', 'c', 'h', 'cpp', 'py', 'rs', 'sh', 'bat', 'sql', 'toml', 'cfg', 'conf', 'srt', 'tex', 'svg']);
|
||||
const OFFICE = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
|
||||
const ZIPS = new Set(['zip', 'jar', 'apk', 'epub']);
|
||||
const DBS = new Set(['db', 'sqlite', 'sqlite3', 'db3', 'sdb', 'sqlitedb', 'storedata']);
|
||||
|
||||
export function kindOf(item) {
|
||||
const e = (item.ext || '').toLowerCase();
|
||||
@@ -16,6 +18,7 @@ export function kindOf(item) {
|
||||
if (VID.has(e)) return 'video';
|
||||
if (AUD.has(e)) return 'audio';
|
||||
if (e === 'pdf') return 'pdf';
|
||||
if (DBS.has(e)) return 'database';
|
||||
if (OFFICE.has(e)) return 'office';
|
||||
if (ZIPS.has(e)) return 'zip';
|
||||
if (TXT.has(e)) return 'text';
|
||||
@@ -24,7 +27,7 @@ export function kindOf(item) {
|
||||
|
||||
export const KIND_LABEL = {
|
||||
image: 'Imagem', video: 'Vídeo', audio: 'Áudio', pdf: 'PDF', office: 'Documento (texto extraído)',
|
||||
zip: 'Conteúdo do arquivo compactado', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||
zip: 'Conteúdo do arquivo compactado', database: 'Banco de dados SQLite', text: 'Texto', hex: 'Dados brutos (hexadecimal)',
|
||||
};
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
@@ -272,6 +275,91 @@ async function zip(host, item, url, state) {
|
||||
note(host, `${ents.length} item(ns) no arquivo compactado.`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ bancos de dados (SQLite)
|
||||
|
||||
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
|
||||
function tsHint(v) {
|
||||
if (!Number.isInteger(v)) return null;
|
||||
let ms = null, kind = '';
|
||||
if (v > 9.4e8 && v < 4.2e9) { ms = v * 1000; kind = 'Unix (s)'; }
|
||||
else if (v > 9.4e11 && v < 4.2e12) { ms = v; kind = 'Unix (ms)'; }
|
||||
else if (v > 9.4e14 && v < 4.2e15) { ms = v / 1000; kind = 'Unix (µs)'; }
|
||||
else if (v > 1.2e16 && v < 1.4e16) { ms = v / 1000 - 11644473600000; kind = 'Chrome/WebKit'; }
|
||||
else if (v > 3e8 && v < 9.4e8) { ms = (v + 978307200) * 1000; kind = 'macOS/CoreData'; }
|
||||
if (ms == null) return null;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : { kind, text: d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' };
|
||||
}
|
||||
|
||||
function cellHtml(c) {
|
||||
switch (c.t) {
|
||||
case 'null': return '<span class="db-null">NULL</span>';
|
||||
case 'int': { const h = tsHint(c.v); return h ? `<span title="${esc(h.kind)}: ${esc(h.text)}">${c.v}<i class="db-ts">${esc(h.text.slice(0, 10))}</i></span>` : String(c.v); }
|
||||
case 'real': return String(c.v);
|
||||
case 'text': return `<span title="${esc(c.v.slice(0, 600))}">${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}</span>`;
|
||||
default: return `<span class="db-blob" title="${esc(c.v.head)}">BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function database(host, item, state, large, db, onOpen) {
|
||||
if (!db) throw new Error('visualizador indisponível');
|
||||
const info = await db.info();
|
||||
if (state.dead) return;
|
||||
const tables = info.tables;
|
||||
const warn = (info.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
if (!large) {
|
||||
const total = tables.reduce((a, t) => a + (t.rows || 0), 0);
|
||||
host.innerHTML = `<div class="db-sum"><b>Banco SQLite</b><span class="sub">${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)}</span>
|
||||
<ul>${tables.slice(0, 10).map((t) => `<li><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></li>`).join('')}</ul>
|
||||
${tables.length > 10 ? `<span class="sub">e mais ${tables.length - 10}…</span>` : ''}${warn}
|
||||
<button class="btn primary sm" data-pv="open">Abrir o visualizador</button></div>`;
|
||||
host.onclick = (e) => { if (e.target.closest('[data-pv]')) onOpen?.(); };
|
||||
return;
|
||||
}
|
||||
const S = { table: tables.find((t) => t.rows > 0)?.name || tables[0]?.name, offset: 0, limit: 100, deleted: false, q: '', schema: false };
|
||||
host.classList.add('db-large');
|
||||
host.innerHTML = `<div class="db-wrap"><nav class="db-tables" aria-label="Tabelas"></nav><section class="db-main"><div class="db-bar"></div><div class="db-grid"></div><div class="db-foot"></div></section></div>`;
|
||||
const $ = (sel) => host.querySelector(sel);
|
||||
const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot');
|
||||
nav.innerHTML = tables.map((t) => `<button class="db-t" data-t="${esc(t.name)}"><span>${esc(t.name)}</span><small>${(t.rows || 0).toLocaleString(LOC())}</small></button>`).join('')
|
||||
+ (info.indexes.length || info.views.length ? `<div class="db-meta">${info.views.length ? `<b>Views</b>${info.views.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}${info.indexes.length ? `<b>Índices</b>${info.indexes.map((v) => `<span>${esc(v)}</span>`).join('')}` : ''}</div>` : '')
|
||||
+ warn;
|
||||
|
||||
async function load() {
|
||||
if (state.dead) return;
|
||||
nav.querySelectorAll('.db-t').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.t === S.table)));
|
||||
const t = tables.find((x) => x.name === S.table);
|
||||
if (!t) { grid.innerHTML = '<div class="pv-note">Nenhuma tabela.</div>'; return; }
|
||||
bar.innerHTML = `<b>${esc(t.name)}</b><span class="sub">${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas</span>
|
||||
<input class="input" id="dbq" type="search" placeholder="Filtrar esta página" value="${esc(S.q)}" aria-label="Filtrar">
|
||||
<label class="check" style="padding:0"><input type="checkbox" id="dbdel" ${S.deleted ? 'checked' : ''}><span>Registros apagados (prováveis)</span></label>
|
||||
<button class="btn sm" id="dbsch">${S.schema ? 'Ver dados' : 'Ver esquema'}</button>`;
|
||||
if (S.schema) { grid.innerHTML = `<pre class="pv-text">${esc(t.sql)}</pre>`; foot.innerHTML = ''; wire(); return; }
|
||||
grid.innerHTML = '<div class="pv-loading"><i></i></div>';
|
||||
let page;
|
||||
try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `<div class="pv-note bad">${esc(e.message || e)}</div>`; wire(); return; }
|
||||
if (state.dead) return;
|
||||
const q = S.q.toLowerCase();
|
||||
const rows = q ? page.rows.filter((r) => r.cells.some((c) => String(c.t === 'blob' ? '' : c.v).toLowerCase().includes(q))) : page.rows;
|
||||
const notes = (page.warnings || []).map((w) => `<div class="pv-note bad">${esc(w)}</div>`).join('');
|
||||
grid.innerHTML = notes + (rows.length ? `<table class="db-table"><thead><tr><th>#</th>${page.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r, i) => `<tr class="${r.deleted ? 'del' : ''}"><th>${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}</th>${r.cells.map((c) => `<td>${cellHtml(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`
|
||||
: `<div class="pv-note">${S.deleted ? 'Nenhum registro apagado reconhecido nesta tabela. Isso não garante que não existam: o conteúdo pode ter sido sobrescrito.' : 'Nenhuma linha.'}</div>`);
|
||||
const total = S.deleted ? page.total : (t.rows || page.total);
|
||||
foot.innerHTML = `<span class="sub">${S.deleted ? `${total.toLocaleString(LOC())} registro(s) provável(is) apagado(s)` : `${total ? S.offset + 1 : 0} a ${Math.min(S.offset + S.limit, total)} de ${total.toLocaleString(LOC())}`}</span>
|
||||
<span class="row"><button class="btn sm" id="dbprev" ${S.offset <= 0 ? 'disabled' : ''}>‹ Anterior</button><button class="btn sm" id="dbnext" ${S.offset + S.limit >= total ? 'disabled' : ''}>Próximas ›</button></span>`;
|
||||
wire();
|
||||
}
|
||||
function wire() {
|
||||
$('#dbq')?.addEventListener('input', (e) => { S.q = e.target.value; clearTimeout(S.t); S.t = setTimeout(load, 200); });
|
||||
$('#dbdel')?.addEventListener('change', (e) => { S.deleted = e.target.checked; S.offset = 0; load(); });
|
||||
$('#dbsch')?.addEventListener('click', () => { S.schema = !S.schema; load(); });
|
||||
$('#dbprev')?.addEventListener('click', () => { S.offset = Math.max(0, S.offset - S.limit); load(); });
|
||||
$('#dbnext')?.addEventListener('click', () => { S.offset += S.limit; load(); });
|
||||
}
|
||||
nav.addEventListener('click', (e) => { const b = e.target.closest('.db-t'); if (b) { S.table = b.dataset.t; S.offset = 0; S.q = ''; S.deleted = false; S.schema = false; load(); } });
|
||||
load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ API
|
||||
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
|
||||
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
|
||||
@@ -291,7 +379,7 @@ function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal })
|
||||
void canInline;
|
||||
}
|
||||
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null }) {
|
||||
export function renderPreview(host, item, { url, large = false, thumb = false, inlineMedia = true, onOpen = null, onExternal = null, db = null }) {
|
||||
const state = { dead: false, cleanup: [] };
|
||||
host.dataset.url = url;
|
||||
host.classList.remove('zoomable', 'actual');
|
||||
@@ -310,6 +398,7 @@ export function renderPreview(host, item, { url, large = false, thumb = false, i
|
||||
else if (kind === 'video') media(host, item, url, 'video', state);
|
||||
else if (kind === 'audio') media(host, item, url, 'audio', state);
|
||||
else if (kind === 'pdf') await pdf(host, item, url, state, large);
|
||||
else if (kind === 'database') await database(host, item, state, large, db, onOpen);
|
||||
else if (kind === 'office') await office(host, item, url, state);
|
||||
else if (kind === 'zip') await zip(host, item, url, state);
|
||||
else if (kind === 'text') { host.innerHTML = ''; await text(host, item, url, large); }
|
||||
|
||||
@@ -10,6 +10,8 @@ const INDEX: &str = include_str!("../../../apps/gui/ui/index.html");
|
||||
const CSS: &str = include_str!("../../../apps/gui/ui/app.css");
|
||||
const JS: &str = include_str!("../../../apps/gui/ui/app.js");
|
||||
const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
|
||||
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
|
||||
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
|
||||
const LOGO: &str = include_str!("../../../apps/gui/ui/guara-logo.svg");
|
||||
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
|
||||
const ICON: &str = include_str!("../../../apps/gui/ui/guara.svg");
|
||||
@@ -86,6 +88,16 @@ fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
|
||||
Response::from_string(PREVIEW_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/i18n-en.js") => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
|
||||
);
|
||||
}
|
||||
(Method::Get, "/guara-logo.svg") => {
|
||||
let _ = req.respond(Response::from_string(LOGO).with_header(ct("image/svg+xml")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Saúde do disco antes de varrer: lê o SMART (via `smartctl`, quando instalado) e
|
||||
//! os contadores de erro do kernel. Um disco com defeito deve ser copiado para uma
|
||||
//! imagem primeiro: cada leitura extra pode piorar o estado dele.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct Health {
|
||||
/// Foi possível avaliar? (imagens de disco e dispositivos sem SMART ficam sem avaliação)
|
||||
pub available: bool,
|
||||
/// "ok", "warning", "failing" ou "unknown".
|
||||
pub status: String,
|
||||
pub summary: String,
|
||||
pub details: Vec<(String, String)>,
|
||||
pub advice: Vec<String>,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
fn unknown(msg: &str) -> Self {
|
||||
Health {
|
||||
available: false,
|
||||
status: "unknown".into(),
|
||||
summary: msg.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_ADVICE: &str = "Crie a imagem do disco primeiro (botão \"Criar imagem\" ou `guara image`) e varra a cópia. Cada leitura extra pode piorar um disco com defeito.";
|
||||
|
||||
/// Avalia o dispositivo `path`. Para arquivos comuns (imagens) devolve "sem avaliação".
|
||||
pub fn check(path: &str) -> Health {
|
||||
#[cfg(unix)]
|
||||
let is_dev = {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.file_type().is_block_device() || m.file_type().is_char_device())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let is_dev = path.starts_with(r"\\.\");
|
||||
if !is_dev {
|
||||
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
|
||||
}
|
||||
let mut h = match run_smartctl(path) {
|
||||
Some(v) => parse_smartctl(&v),
|
||||
None => Health::unknown("O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco."),
|
||||
};
|
||||
kernel_errors(path, &mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn smartctl_name(path: &str) -> String {
|
||||
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
|
||||
if let Some(n) = path
|
||||
.strip_prefix(r"\\.\PhysicalDrive")
|
||||
.and_then(|n| n.parse::<u8>().ok())
|
||||
{
|
||||
return format!("/dev/sd{}", (b'a' + n.min(25)) as char);
|
||||
}
|
||||
// macOS: /dev/rdiskN -> /dev/diskN
|
||||
if let Some(rest) = path.strip_prefix("/dev/rdisk") {
|
||||
return format!("/dev/disk{rest}");
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn run_smartctl(path: &str) -> Option<Value> {
|
||||
let dev = smartctl_name(path);
|
||||
let out = Command::new("smartctl")
|
||||
.args(["--json=o", "-H", "-A", "-i", "-l", "error", &dev])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
// smartctl usa bits do código de saída para avisos; o JSON vale mesmo assim
|
||||
let v: Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
// sem permissão ou dispositivo não suportado
|
||||
if v.pointer("/smartctl/messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.is_some_and(|m| m.iter().any(|x| x["severity"] == "error"))
|
||||
&& v.get("smart_status").is_none()
|
||||
&& v.get("ata_smart_attributes").is_none()
|
||||
&& v.get("nvme_smart_health_information_log").is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn attr(v: &Value, id: u64) -> Option<i64> {
|
||||
v.pointer("/ata_smart_attributes/table")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|a| a["id"] == id)?
|
||||
.pointer("/raw/value")?
|
||||
.as_i64()
|
||||
}
|
||||
|
||||
/// Interpreta a saída JSON do `smartctl` (testável sem disco).
|
||||
pub fn parse_smartctl(v: &Value) -> Health {
|
||||
let mut h = Health {
|
||||
available: true,
|
||||
status: "ok".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut bad = 0u32; // 2 = falhando, 1 = atenção
|
||||
let mut note = |h: &mut Health, sev: u32, k: &str, val: String| {
|
||||
bad = bad.max(sev);
|
||||
h.details.push((k.to_string(), val));
|
||||
};
|
||||
if let Some(m) = v["model_name"].as_str() {
|
||||
h.details.push(("Modelo".into(), m.to_string()));
|
||||
}
|
||||
if let Some(s) = v["serial_number"].as_str() {
|
||||
h.details.push(("Nº de série".into(), s.to_string()));
|
||||
}
|
||||
if let Some(hrs) = v.pointer("/power_on_time/hours").and_then(|x| x.as_i64()) {
|
||||
h.details.push(("Horas ligado".into(), hrs.to_string()));
|
||||
}
|
||||
if let Some(t) = v.pointer("/temperature/current").and_then(|x| x.as_i64()) {
|
||||
if t >= 60 {
|
||||
note(&mut h, 1, "Temperatura", format!("{t} °C (alta)"));
|
||||
} else {
|
||||
h.details.push(("Temperatura".into(), format!("{t} °C")));
|
||||
}
|
||||
}
|
||||
if v.pointer("/smart_status/passed") == Some(&Value::Bool(false)) {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Autoavaliação SMART",
|
||||
"REPROVADO: o próprio disco prevê falha".into(),
|
||||
);
|
||||
} else if v.pointer("/smart_status/passed") == Some(&Value::Bool(true)) {
|
||||
h.details
|
||||
.push(("Autoavaliação SMART".into(), "aprovado".into()));
|
||||
}
|
||||
// ATA (HD e SSD SATA)
|
||||
if let Some(n) = attr(v, 5) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
if n >= 50 { 2 } else { 1 },
|
||||
"Setores realocados",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 197) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores pendentes de realocação", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 198) {
|
||||
if n > 0 {
|
||||
note(&mut h, 2, "Setores incorrigíveis offline", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 187) {
|
||||
if n > 0 {
|
||||
note(&mut h, 1, "Erros incorrigíveis reportados", n.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(n) = attr(v, 199) {
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros de CRC na interface (cabo ou porta)",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(n) = v
|
||||
.pointer("/ata_smart_error_log/summary/count")
|
||||
.and_then(|x| x.as_i64())
|
||||
{
|
||||
if n > 0 {
|
||||
note(
|
||||
&mut h,
|
||||
1,
|
||||
"Erros registrados no log do disco",
|
||||
n.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// NVMe
|
||||
if let Some(n) = v.get("nvme_smart_health_information_log") {
|
||||
if n["critical_warning"].as_i64().unwrap_or(0) != 0 {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Aviso crítico NVMe",
|
||||
n["critical_warning"].to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(u) = n["percentage_used"].as_i64() {
|
||||
if u >= 100 {
|
||||
note(&mut h, 2, "Vida útil usada", format!("{u}%"));
|
||||
} else if u >= 90 {
|
||||
note(&mut h, 1, "Vida útil usada", format!("{u}%"));
|
||||
} else {
|
||||
h.details.push(("Vida útil usada".into(), format!("{u}%")));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n["media_errors"].as_i64() {
|
||||
if m > 0 {
|
||||
note(&mut h, 2, "Erros de mídia", m.to_string());
|
||||
}
|
||||
}
|
||||
if let (Some(s), Some(t)) = (
|
||||
n["available_spare"].as_i64(),
|
||||
n["available_spare_threshold"].as_i64(),
|
||||
) {
|
||||
if s < t {
|
||||
note(
|
||||
&mut h,
|
||||
2,
|
||||
"Reserva de blocos abaixo do limite",
|
||||
format!("{s}% (limite {t}%)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match bad {
|
||||
2 => {
|
||||
h.status = "failing".into();
|
||||
h.summary = "Este disco mostra sinais sérios de defeito.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
1 => {
|
||||
h.status = "warning".into();
|
||||
h.summary = "Este disco mostra sinais de desgaste ou erros.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
_ => h.summary = "Nenhum sinal de defeito encontrado pelo SMART.".into(),
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
|
||||
fn kernel_errors(path: &str, h: &mut Health) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let name = path.trim_start_matches("/dev/");
|
||||
let base = name.trim_end_matches(|c: char| c.is_ascii_digit());
|
||||
for n in [name, base] {
|
||||
if let Ok(s) = std::fs::read_to_string(format!("/sys/block/{n}/device/ioerr_cnt")) {
|
||||
if let Ok(c) = u64::from_str_radix(s.trim().trim_start_matches("0x"), 16) {
|
||||
if c > 0 {
|
||||
h.details.push((
|
||||
"Erros de leitura/gravação vistos pelo sistema".into(),
|
||||
c.to_string(),
|
||||
));
|
||||
if h.status == "ok" || h.status == "unknown" {
|
||||
h.available = true;
|
||||
h.status = "warning".into();
|
||||
h.summary = "O sistema já registrou erros de E/S neste disco.".into();
|
||||
h.advice.push(IMAGE_ADVICE.into());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = (path, h);
|
||||
}
|
||||
@@ -10,9 +10,11 @@ pub mod explorer;
|
||||
pub mod external;
|
||||
pub mod fs;
|
||||
pub mod hashing;
|
||||
pub mod health;
|
||||
pub mod http;
|
||||
pub mod image;
|
||||
pub mod meta;
|
||||
pub mod notify;
|
||||
pub mod owner;
|
||||
pub mod partition;
|
||||
pub mod reconnect;
|
||||
@@ -24,6 +26,7 @@ pub mod service;
|
||||
pub mod settings;
|
||||
pub mod signatures;
|
||||
pub mod source;
|
||||
pub mod sqlite;
|
||||
pub mod thumb;
|
||||
pub mod types;
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Notificações do sistema (varredura concluída, disco desconectado...), usando os
|
||||
//! comandos nativos de cada sistema, sem dependências extras.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Envia uma notificação sem bloquear. Falhas são ignoradas (é só um aviso).
|
||||
pub fn send(title: &str, body: &str) {
|
||||
let (title, body) = (title.to_string(), body.to_string());
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut c) = command(&title, &body) {
|
||||
if let Ok(mut child) = c
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn command(title: &str, body: &str) -> Option<Command> {
|
||||
// Como root, notifica na sessão do usuário real.
|
||||
let mut c = if let Some(u) = crate::owner::invoking_user() {
|
||||
let mut c = Command::new("runuser");
|
||||
c.args(["-u", &u.name, "--", "env"]);
|
||||
for k in ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("XDG_RUNTIME_DIR=/run/user/{}", u.uid));
|
||||
c.arg(format!(
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{}/bus",
|
||||
u.uid
|
||||
));
|
||||
c.arg("notify-send");
|
||||
c
|
||||
} else {
|
||||
Command::new("notify-send")
|
||||
};
|
||||
c.args(["-a", "Guará", "-i", "drive-harddisk", title, body]);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn command(title: &str, body: &str) -> Option<Command> {
|
||||
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let mut c = Command::new("osascript");
|
||||
c.args([
|
||||
"-e",
|
||||
&format!(
|
||||
"display notification \"{}\" with title \"{}\"",
|
||||
esc(body),
|
||||
esc(title)
|
||||
),
|
||||
]);
|
||||
Some(c)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn command(title: &str, body: &str) -> Option<Command> {
|
||||
let esc = |s: &str| s.replace('\'', "''");
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(6000, '{}', '{}', [System.Windows.Forms.ToolTipIcon]::None); Start-Sleep -Seconds 7; $n.Dispose()",
|
||||
esc(title),
|
||||
esc(body)
|
||||
);
|
||||
let mut c = Command::new("powershell");
|
||||
c.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]);
|
||||
Some(c)
|
||||
}
|
||||
@@ -33,6 +33,9 @@ pub struct RecoverReport {
|
||||
pub failed: Vec<(u64, String)>,
|
||||
pub total_bytes: u64,
|
||||
pub cancelled: bool,
|
||||
/// Arquivos já recuperados e conferidos numa execução anterior (pulados ao retomar).
|
||||
#[serde(default)]
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// Recusa destinos que ficam no mesmo dispositivo da origem.
|
||||
@@ -124,18 +127,98 @@ pub fn recover_with(
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
recover_resumable(src, files, dest, layout, "", false, cancelled, progress)
|
||||
}
|
||||
|
||||
/// Uma linha do diário de recuperação (`guara-diario.jsonl`): cada arquivo gravado e conferido.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct JournalEntry {
|
||||
source: String,
|
||||
id: u64,
|
||||
name: String,
|
||||
file: RecoveredFile,
|
||||
}
|
||||
|
||||
pub const JOURNAL: &str = "guara-diario.jsonl";
|
||||
|
||||
fn sha256_of_file(p: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(p).ok()?;
|
||||
let mut h = MultiHasher::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = f.read(&mut buf).ok()?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
}
|
||||
Some(h.finalize().sha256)
|
||||
}
|
||||
|
||||
/// Como `recover_with`, mas mantém um diário e, com `resume`, pula o que já foi
|
||||
/// recuperado (e conferido por SHA-256) numa execução interrompida.
|
||||
/// `source_key` identifica a origem para que o diário de outro disco não seja reaproveitado.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn recover_resumable(
|
||||
src: &dyn Source,
|
||||
files: &[&Found],
|
||||
dest: &Path,
|
||||
layout: RecoverLayout,
|
||||
source_key: &str,
|
||||
resume: bool,
|
||||
cancelled: &(dyn Fn() -> bool + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize, u64),
|
||||
) -> Result<RecoverReport> {
|
||||
use std::io::Write;
|
||||
crate::owner::create_dir_all_owned(dest)?;
|
||||
let mut report = RecoverReport {
|
||||
destination: dest.display().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut used: HashSet<PathBuf> = HashSet::new();
|
||||
let journal_path = dest.join(JOURNAL);
|
||||
let mut prior: std::collections::HashMap<u64, JournalEntry> = std::collections::HashMap::new();
|
||||
if resume {
|
||||
if let Ok(text) = std::fs::read_to_string(&journal_path) {
|
||||
for line in text.lines() {
|
||||
if let Ok(e) = serde_json::from_str::<JournalEntry>(line) {
|
||||
if e.source == source_key {
|
||||
prior.insert(e.id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut journal = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&journal_path)
|
||||
.ok();
|
||||
crate::owner::give_to_user(&journal_path);
|
||||
for (i, f) in files.iter().enumerate() {
|
||||
if cancelled() {
|
||||
report.cancelled = true;
|
||||
break;
|
||||
}
|
||||
progress(i, files.len(), report.total_bytes);
|
||||
// retomada: já está lá e confere?
|
||||
if let Some(e) = prior.get(&f.id) {
|
||||
let p = dest.join(&e.file.path);
|
||||
if e.name == f.name && e.file.size == f.size && p.is_file() {
|
||||
if std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0) == f.size
|
||||
&& sha256_of_file(&p).as_deref() == Some(e.file.sha256.as_str())
|
||||
{
|
||||
used.insert(p);
|
||||
report.skipped += 1;
|
||||
report.total_bytes += f.size;
|
||||
report.recovered.push(e.file.clone());
|
||||
continue;
|
||||
}
|
||||
// incompleto ou alterado: refaz (é um arquivo nosso)
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
let rel = target_rel(f, layout);
|
||||
let mut path = dest.join(&rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -162,7 +245,7 @@ pub fn recover_with(
|
||||
match write_file(src, f, &path) {
|
||||
Ok(h) => {
|
||||
report.total_bytes += f.size;
|
||||
report.recovered.push(RecoveredFile {
|
||||
let rec = RecoveredFile {
|
||||
id: f.id,
|
||||
path: path
|
||||
.strip_prefix(dest)
|
||||
@@ -176,7 +259,21 @@ pub fn recover_with(
|
||||
confidence: f.confidence,
|
||||
origin: f.origin,
|
||||
partial: f.confidence == Confidence::Low,
|
||||
});
|
||||
};
|
||||
if let Some(j) = journal.as_mut() {
|
||||
let line = JournalEntry {
|
||||
source: source_key.to_string(),
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
file: rec.clone(),
|
||||
};
|
||||
if let Ok(mut l) = serde_json::to_string(&line) {
|
||||
l.push('\n');
|
||||
let _ = j.write_all(l.as_bytes());
|
||||
let _ = j.flush();
|
||||
}
|
||||
}
|
||||
report.recovered.push(rec);
|
||||
}
|
||||
Err(e) => report.failed.push((f.id, e.to_string())),
|
||||
}
|
||||
@@ -347,3 +444,18 @@ pub fn read_found_at(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Um arquivo achado visto como origem de leitura posicional (para leitores de formato).
|
||||
pub struct FoundSource<'a> {
|
||||
pub src: &'a dyn Source,
|
||||
pub found: &'a Found,
|
||||
}
|
||||
|
||||
impl Source for FoundSource<'_> {
|
||||
fn size(&self) -> u64 {
|
||||
self.found.size
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
read_found_at(self.src, self.found, offset, buf)
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,55 @@ impl Engine {
|
||||
Ok(crate::explorer::facets(&s.found))
|
||||
}
|
||||
|
||||
/// Esquema de um banco SQLite achado, com o número de linhas de cada tabela.
|
||||
pub fn db_info(&self, id: &str, file: u64) -> Result<Value> {
|
||||
let (f, src) = self.open_file(id, file)?;
|
||||
let fs = recover::FoundSource {
|
||||
src: src.as_ref(),
|
||||
found: &f,
|
||||
};
|
||||
let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?;
|
||||
let schema = db.schema();
|
||||
let counts: Vec<u64> = schema.tables.iter().map(|t| db.count_rows(t)).collect();
|
||||
let mut v = serde_json::to_value(&schema)?;
|
||||
for (t, c) in v["tables"].as_array_mut().unwrap().iter_mut().zip(counts) {
|
||||
t["rows"] = json!(c);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Uma página de linhas de uma tabela (ou os registros apagados prováveis dela).
|
||||
pub fn db_rows(
|
||||
&self,
|
||||
id: &str,
|
||||
file: u64,
|
||||
table: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
deleted: bool,
|
||||
) -> Result<Value> {
|
||||
let (f, src) = self.open_file(id, file)?;
|
||||
let fs = recover::FoundSource {
|
||||
src: src.as_ref(),
|
||||
found: &f,
|
||||
};
|
||||
let db = crate::sqlite::Db::open(&fs).map_err(Error::msg)?;
|
||||
let page = if deleted {
|
||||
let mut p = db.deleted_rows(table).map_err(Error::msg)?;
|
||||
p.rows = p
|
||||
.rows
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit.clamp(1, 1000))
|
||||
.collect();
|
||||
p
|
||||
} else {
|
||||
db.rows(table, offset, limit.clamp(1, 1000))
|
||||
.map_err(Error::msg)?
|
||||
};
|
||||
Ok(serde_json::to_value(&page)?)
|
||||
}
|
||||
|
||||
/// Metadados detalhados (EXIF, PDF, Office, vídeo…) de um arquivo.
|
||||
pub fn metadata(&self, id: &str, file: u64) -> Result<crate::meta::Meta> {
|
||||
let (f, src) = self.open_file(id, file)?;
|
||||
@@ -407,6 +456,12 @@ impl Engine {
|
||||
);
|
||||
let j2 = job.clone();
|
||||
let parent = sj.clone();
|
||||
let source_key = sj
|
||||
.fingerprint
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| sj.target.clone());
|
||||
std::thread::spawn(move || {
|
||||
let cancelled = || j2.cancelled();
|
||||
let mut prog = |done: usize, total: usize, bytes: u64| {
|
||||
@@ -418,11 +473,13 @@ impl Engine {
|
||||
};
|
||||
};
|
||||
let refs: Vec<&Found> = files.iter().collect();
|
||||
let res = recover::recover_with(
|
||||
let res = recover::recover_resumable(
|
||||
src.as_ref(),
|
||||
&refs,
|
||||
&dest_path,
|
||||
layout,
|
||||
&source_key,
|
||||
true,
|
||||
&cancelled,
|
||||
&mut prog,
|
||||
);
|
||||
@@ -431,6 +488,19 @@ impl Engine {
|
||||
Ok(rep) => {
|
||||
s.report = serde_json::to_value(&rep).ok();
|
||||
parent.st.lock().unwrap().recoveries.push(rep.clone());
|
||||
if !rep.cancelled
|
||||
&& s.started.elapsed().as_secs() >= 8
|
||||
&& crate::settings::load().notify
|
||||
{
|
||||
crate::notify::send(
|
||||
"Guará: recuperação concluída",
|
||||
&format!(
|
||||
"{} arquivo(s) salvos em {}",
|
||||
rep.recovered.len(),
|
||||
rep.destination
|
||||
),
|
||||
);
|
||||
}
|
||||
s.status = if rep.cancelled {
|
||||
Status::Stopped
|
||||
} else {
|
||||
@@ -907,6 +977,28 @@ impl Engine {
|
||||
}
|
||||
"status" => self.status(&s("job")?)?,
|
||||
"facets" => self.facets(&s("job")?)?,
|
||||
"health" => json!(crate::health::check(&s("path")?)),
|
||||
"open_url" => {
|
||||
let u = s("url")?;
|
||||
// só mapas do OpenStreetMap: evita abrir endereços arbitrários a partir de metadados
|
||||
if !u.starts_with("https://www.openstreetmap.org/") {
|
||||
return Err(Error::msg("endereço não permitido"));
|
||||
}
|
||||
crate::external::open_path(Path::new(&u))?;
|
||||
json!({})
|
||||
}
|
||||
"db_info" => self.db_info(
|
||||
&s("job")?,
|
||||
p.get("id").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
)?,
|
||||
"db_rows" => self.db_rows(
|
||||
&s("job")?,
|
||||
p.get("id").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
&s("table")?,
|
||||
p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
|
||||
p.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize,
|
||||
p.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
)?,
|
||||
"open_external" => {
|
||||
let id = p
|
||||
.get("id")
|
||||
@@ -1100,6 +1192,16 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
Outcome::Completed => {
|
||||
s.status = Status::Done;
|
||||
s.checkpoint = None;
|
||||
if s.started.elapsed().as_secs() >= 20 && crate::settings::load().notify {
|
||||
crate::notify::send(
|
||||
"Guará: varredura concluída",
|
||||
&format!(
|
||||
"{} arquivo(s) encontrados em {}.",
|
||||
s.found.len(),
|
||||
job.target
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Outcome::Interrupted(cp) => {
|
||||
if job.stop_requested.load(Ordering::Relaxed) {
|
||||
@@ -1107,6 +1209,9 @@ fn spawn_scan(job: Arc<Job>, cp: Option<Checkpoint>) {
|
||||
} else {
|
||||
s.status = Status::Paused;
|
||||
if cp.io_lost {
|
||||
if crate::settings::load().notify {
|
||||
crate::notify::send("Guará: disco desconectado?", "A varredura foi pausada sem perder nada. Reconecte o disco e continue.");
|
||||
}
|
||||
s.warnings.push("A leitura do dispositivo falhou repetidamente: ele provavelmente foi desconectado. A varredura foi pausada sem perder nada. Reconecte o disco e clique em Continuar.".into());
|
||||
}
|
||||
s.checkpoint = Some(cp);
|
||||
|
||||
@@ -8,13 +8,34 @@ use std::path::PathBuf;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
/// "auto", "light" ou "dark".
|
||||
#[serde(default = "auto")]
|
||||
pub theme: String,
|
||||
/// Idioma da interface: "auto", "pt" ou "en".
|
||||
#[serde(default = "auto")]
|
||||
pub lang: String,
|
||||
/// Notificação do sistema quando uma tarefa longa termina.
|
||||
#[serde(default = "yes")]
|
||||
pub notify: bool,
|
||||
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
||||
#[serde(default)]
|
||||
pub maps: bool,
|
||||
}
|
||||
|
||||
fn auto() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "auto".into(),
|
||||
theme: auto(),
|
||||
lang: auto(),
|
||||
notify: true,
|
||||
maps: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
//! Leitor de bancos SQLite feito para arquivos recuperados.
|
||||
//!
|
||||
//! Lê as páginas B-tree diretamente (sem a biblioteca do SQLite), então funciona
|
||||
//! em bancos danificados ou truncados, para os quais a biblioteca oficial desiste.
|
||||
//! Também procura **registros apagados** nas áreas livres das páginas, onde o
|
||||
//! SQLite deixa o conteúdo antigo até que outro dado o sobrescreva.
|
||||
|
||||
use crate::source::Source;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAGIC: &[u8; 16] = b"SQLite format 3\0";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "t", content = "v", rename_all = "lowercase")]
|
||||
pub enum Cell {
|
||||
Null,
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Text(String),
|
||||
/// Tamanho e os primeiros bytes (hex) para pré-visualizar; `mime` se reconhecido.
|
||||
Blob {
|
||||
len: usize,
|
||||
head: String,
|
||||
mime: Option<&'static str>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbColumn {
|
||||
pub name: String,
|
||||
pub decl_type: String,
|
||||
pub pk: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbTable {
|
||||
pub name: String,
|
||||
pub rootpage: u32,
|
||||
pub sql: String,
|
||||
pub columns: Vec<DbColumn>,
|
||||
pub without_rowid: bool,
|
||||
pub virtual_table: bool,
|
||||
/// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver.
|
||||
#[serde(skip)]
|
||||
pub rowid_alias: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbSchema {
|
||||
pub page_size: u32,
|
||||
pub pages: u32,
|
||||
pub file_pages: u64,
|
||||
pub encoding: String,
|
||||
pub tables: Vec<DbTable>,
|
||||
pub indexes: Vec<String>,
|
||||
pub views: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbRow {
|
||||
pub rowid: Option<i64>,
|
||||
pub cells: Vec<Cell>,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct RowsPage {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<DbRow>,
|
||||
pub total: u64,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Db<'a> {
|
||||
src: &'a dyn Source,
|
||||
ps: usize,
|
||||
usable: usize,
|
||||
pages: u32,
|
||||
enc: u32,
|
||||
}
|
||||
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
// ------------------------------------------------------------------ utilidades
|
||||
|
||||
fn varint(b: &[u8]) -> Option<(i64, usize)> {
|
||||
let mut v: u64 = 0;
|
||||
for i in 0..9 {
|
||||
let c = *b.get(i)?;
|
||||
if i == 8 {
|
||||
return Some((((v << 8) | c as u64) as i64, 9));
|
||||
}
|
||||
v = (v << 7) | (c & 0x7F) as u64;
|
||||
if c & 0x80 == 0 {
|
||||
return Some((v as i64, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> Option<usize> {
|
||||
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]) as usize)
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*b.get(o)?,
|
||||
*b.get(o + 1)?,
|
||||
*b.get(o + 2)?,
|
||||
*b.get(o + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
fn serial_len(t: i64) -> Option<usize> {
|
||||
Some(match t {
|
||||
0 | 8 | 9 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
3 => 3,
|
||||
4 => 4,
|
||||
5 => 6,
|
||||
6 | 7 => 8,
|
||||
10 | 11 => return None,
|
||||
n if n >= 12 => ((n - 12) / 2) as usize,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn blob_mime(b: &[u8]) -> Option<&'static str> {
|
||||
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
Some("image/jpeg")
|
||||
} else if b.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
Some("image/png")
|
||||
} else if b.starts_with(b"GIF8") {
|
||||
Some("image/gif")
|
||||
} else if b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
|
||||
Some("image/webp")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_text(b: &[u8], enc: u32) -> String {
|
||||
match enc {
|
||||
2 => String::from_utf16_lossy(
|
||||
&b.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
3 => String::from_utf16_lossy(
|
||||
&b.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
_ => String::from_utf8_lossy(b).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodifica um registro (cabeçalho de tipos + corpo).
|
||||
fn decode_record(p: &[u8], enc: u32) -> Option<Vec<Cell>> {
|
||||
let (hlen, n) = varint(p)?;
|
||||
let hlen = hlen as usize;
|
||||
if hlen < n || hlen > p.len() || hlen > 4096 {
|
||||
return None;
|
||||
}
|
||||
let mut types = vec![];
|
||||
let mut o = n;
|
||||
while o < hlen {
|
||||
let (t, m) = varint(&p[o..hlen])?;
|
||||
types.push(t);
|
||||
o += m;
|
||||
}
|
||||
let mut cells = Vec::with_capacity(types.len());
|
||||
let mut o = hlen;
|
||||
for t in types {
|
||||
let l = serial_len(t)?;
|
||||
let b = p.get(o..o + l)?;
|
||||
o += l;
|
||||
cells.push(match t {
|
||||
0 => Cell::Null,
|
||||
1..=6 => {
|
||||
let mut v: i64 = if b[0] & 0x80 != 0 { -1 } else { 0 };
|
||||
for &x in b {
|
||||
v = (v << 8) | x as i64;
|
||||
}
|
||||
Cell::Int(v)
|
||||
}
|
||||
7 => Cell::Real(f64::from_be_bytes(b.try_into().ok()?)),
|
||||
8 => Cell::Int(0),
|
||||
9 => Cell::Int(1),
|
||||
n if n % 2 == 1 => Cell::Text(decode_text(b, enc)),
|
||||
_ => Cell::Blob {
|
||||
len: b.len(),
|
||||
head: b.iter().take(24).map(|x| format!("{x:02x}")).collect(),
|
||||
mime: blob_mime(b),
|
||||
},
|
||||
});
|
||||
}
|
||||
Some(cells)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ banco
|
||||
|
||||
impl<'a> Db<'a> {
|
||||
pub fn open(src: &'a dyn Source) -> Res<Self> {
|
||||
let mut h = [0u8; 100];
|
||||
let n = src.read_at(0, &mut h).map_err(|e| e.to_string())?;
|
||||
if n < 100 || &h[0..16] != MAGIC {
|
||||
return Err("Não é um banco SQLite (cabeçalho ausente). Se o arquivo é um banco, ele pode estar criptografado (SQLCipher) ou ter o início sobrescrito.".into());
|
||||
}
|
||||
let ps = match u16::from_be_bytes([h[16], h[17]]) {
|
||||
1 => 65536usize,
|
||||
v if (512..=32768).contains(&v) && v.is_power_of_two() => v as usize,
|
||||
_ => return Err("Tamanho de página inválido no cabeçalho do banco.".into()),
|
||||
};
|
||||
let reserved = h[20] as usize;
|
||||
let mut pages = u32::from_be_bytes([h[28], h[29], h[30], h[31]]);
|
||||
let file_pages = (src.size() / ps as u64) as u32;
|
||||
if pages == 0 || pages > file_pages.max(1) * 4 + 1 {
|
||||
pages = file_pages;
|
||||
}
|
||||
let enc = u32::from_be_bytes([h[56], h[57], h[58], h[59]]);
|
||||
Ok(Self {
|
||||
src,
|
||||
ps,
|
||||
usable: ps - reserved,
|
||||
pages: pages.max(1),
|
||||
enc: if (1..=3).contains(&enc) { enc } else { 1 },
|
||||
})
|
||||
}
|
||||
|
||||
fn page(&self, n: u32) -> Res<Vec<u8>> {
|
||||
if n == 0 {
|
||||
return Err("página 0 inválida".into());
|
||||
}
|
||||
let off = (n as u64 - 1) * self.ps as u64;
|
||||
if off + self.ps as u64 > self.src.size() {
|
||||
return Err(format!("página {n} fora do arquivo (banco truncado)"));
|
||||
}
|
||||
let mut b = vec![0u8; self.ps];
|
||||
let mut got = 0;
|
||||
while got < b.len() {
|
||||
match self.src.read_at(off + got as u64, &mut b[got..]) {
|
||||
Ok(0) => return Err(format!("página {n} incompleta")),
|
||||
Ok(k) => got += k,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn enc_name(&self) -> &'static str {
|
||||
match self.enc {
|
||||
2 => "UTF-16 LE",
|
||||
3 => "UTF-16 BE",
|
||||
_ => "UTF-8",
|
||||
}
|
||||
}
|
||||
|
||||
/// Lê o payload completo de uma célula (segue as páginas de overflow).
|
||||
fn payload(
|
||||
&self,
|
||||
page: &[u8],
|
||||
cell_off: usize,
|
||||
p: usize,
|
||||
index: bool,
|
||||
hdr: usize,
|
||||
) -> Option<Vec<u8>> {
|
||||
let u = self.usable;
|
||||
let x = if index {
|
||||
((u - 12) * 64 / 255).saturating_sub(23)
|
||||
} else {
|
||||
u - 35
|
||||
};
|
||||
let m = ((u - 12) * 32 / 255).saturating_sub(23);
|
||||
let local = if p <= x {
|
||||
p
|
||||
} else {
|
||||
let k = m + (p - m) % (u - 4);
|
||||
if k <= x {
|
||||
k
|
||||
} else {
|
||||
m
|
||||
}
|
||||
};
|
||||
let start = cell_off + hdr;
|
||||
let mut out = page.get(start..start + local)?.to_vec();
|
||||
if p > local {
|
||||
let mut next = be32(page, start + local)?;
|
||||
let mut guard = 0;
|
||||
while out.len() < p && next != 0 && guard < 100_000 {
|
||||
guard += 1;
|
||||
let ov = self.page(next).ok()?;
|
||||
next = be32(&ov, 0)?;
|
||||
let take = (p - out.len()).min(u - 4);
|
||||
out.extend_from_slice(ov.get(4..4 + take)?);
|
||||
}
|
||||
if out.len() < p {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Percorre as folhas de uma árvore de tabela chamando `f(rowid, payload)`.
|
||||
/// `f` devolve false para parar. Devolve os avisos de corrupção encontrados.
|
||||
fn walk_table(&self, root: u32, f: &mut dyn FnMut(i64, &[u8]) -> bool) -> Vec<String> {
|
||||
let mut warnings = vec![];
|
||||
let mut stack = vec![(root, 0u32)];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some((pn, depth)) = stack.pop() {
|
||||
if depth > 40 || !seen.insert(pn) {
|
||||
continue;
|
||||
}
|
||||
let pg = match self.page(pn) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
if warnings.len() < 5 {
|
||||
warnings.push(e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let hb = if pn == 1 { 100 } else { 0 };
|
||||
let Some(&ty) = pg.get(hb) else { continue };
|
||||
let ncell = be16(&pg, hb + 3).unwrap_or(0);
|
||||
match ty {
|
||||
0x05 => {
|
||||
let mut kids = vec![];
|
||||
for i in 0..ncell {
|
||||
let Some(co) = be16(&pg, hb + 12 + i * 2) else {
|
||||
break;
|
||||
};
|
||||
if let Some(c) = be32(&pg, co) {
|
||||
kids.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(r) = be32(&pg, hb + 8) {
|
||||
kids.push(r);
|
||||
}
|
||||
for k in kids.into_iter().rev() {
|
||||
stack.push((k, depth + 1));
|
||||
}
|
||||
}
|
||||
0x0D => {
|
||||
for i in 0..ncell {
|
||||
let Some(co) = be16(&pg, hb + 8 + i * 2) else {
|
||||
break;
|
||||
};
|
||||
let Some(tail) = pg.get(co..) else { continue };
|
||||
let Some((plen, a)) = varint(tail) else {
|
||||
continue;
|
||||
};
|
||||
let Some((rowid, b)) = varint(&tail[a..]) else {
|
||||
continue;
|
||||
};
|
||||
if plen < 0 || plen as usize > 1 << 28 {
|
||||
continue;
|
||||
}
|
||||
if let Some(pl) = self.payload(&pg, co, plen as usize, false, a + b) {
|
||||
if !f(rowid, &pl) {
|
||||
return warnings;
|
||||
}
|
||||
} else if warnings.len() < 5 {
|
||||
warnings.push(format!("célula ilegível na página {pn}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if warnings.len() < 5 {
|
||||
warnings.push(format!("página {pn} de tipo inesperado (0x{ty:02x})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
fn leaf_pages(&self, root: u32) -> Vec<u32> {
|
||||
let mut leaves = vec![];
|
||||
let mut stack = vec![(root, 0u32)];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some((pn, depth)) = stack.pop() {
|
||||
if depth > 40 || !seen.insert(pn) {
|
||||
continue;
|
||||
}
|
||||
let Ok(pg) = self.page(pn) else { continue };
|
||||
let hb = if pn == 1 { 100 } else { 0 };
|
||||
match pg.get(hb) {
|
||||
Some(0x05) => {
|
||||
let n = be16(&pg, hb + 3).unwrap_or(0);
|
||||
for i in 0..n {
|
||||
if let Some(c) = be16(&pg, hb + 12 + i * 2).and_then(|co| be32(&pg, co)) {
|
||||
stack.push((c, depth + 1));
|
||||
}
|
||||
}
|
||||
if let Some(r) = be32(&pg, hb + 8) {
|
||||
stack.push((r, depth + 1));
|
||||
}
|
||||
}
|
||||
Some(0x0D) => leaves.push(pn),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
leaves
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- esquema
|
||||
|
||||
pub fn schema(&self) -> DbSchema {
|
||||
let mut s = DbSchema {
|
||||
page_size: self.ps as u32,
|
||||
pages: self.pages,
|
||||
file_pages: self.src.size() / self.ps as u64,
|
||||
encoding: self.enc_name().into(),
|
||||
tables: vec![],
|
||||
indexes: vec![],
|
||||
views: vec![],
|
||||
warnings: vec![],
|
||||
};
|
||||
if s.file_pages < self.pages as u64 {
|
||||
s.warnings.push(format!(
|
||||
"Arquivo truncado: o cabeçalho indica {} páginas, mas só há {}. Parte dos dados pode estar faltando.",
|
||||
self.pages, s.file_pages
|
||||
));
|
||||
}
|
||||
let mut rows: Vec<Vec<Cell>> = vec![];
|
||||
let w = self.walk_table(1, &mut |_, p| {
|
||||
if let Some(c) = decode_record(p, self.enc) {
|
||||
rows.push(c);
|
||||
}
|
||||
true
|
||||
});
|
||||
s.warnings.extend(w);
|
||||
let text = |c: &Cell| {
|
||||
if let Cell::Text(t) = c {
|
||||
t.clone()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
for r in rows {
|
||||
if r.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let (ty, name, rootpage, sql) = (
|
||||
text(&r[0]),
|
||||
text(&r[1]),
|
||||
if let Cell::Int(i) = r[3] { i as u32 } else { 0 },
|
||||
text(&r[4]),
|
||||
);
|
||||
match ty.as_str() {
|
||||
"table" => {
|
||||
let (columns, rowid_alias, without_rowid) = parse_create(&sql);
|
||||
s.tables.push(DbTable {
|
||||
virtual_table: sql.to_uppercase().starts_with("CREATE VIRTUAL"),
|
||||
name,
|
||||
rootpage,
|
||||
sql,
|
||||
columns,
|
||||
without_rowid,
|
||||
rowid_alias,
|
||||
});
|
||||
}
|
||||
"index" => s.indexes.push(name),
|
||||
"view" => s.views.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if s.tables.is_empty() {
|
||||
s.warnings.push(
|
||||
"Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn table(&self, name: &str) -> Res<DbTable> {
|
||||
self.schema()
|
||||
.tables
|
||||
.into_iter()
|
||||
.find(|t| t.name == name)
|
||||
.ok_or_else(|| format!("tabela {name} não encontrada"))
|
||||
}
|
||||
|
||||
/// Conta as linhas da tabela (sem decodificar o conteúdo).
|
||||
pub fn count_rows(&self, t: &DbTable) -> u64 {
|
||||
if t.rootpage == 0 || t.virtual_table || t.without_rowid {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0u64;
|
||||
for leaf in self.leaf_pages(t.rootpage) {
|
||||
if let Ok(pg) = self.page(leaf) {
|
||||
n += be16(&pg, if leaf == 1 { 103 } else { 3 }).unwrap_or(0) as u64;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- linhas
|
||||
|
||||
pub fn rows(&self, table: &str, offset: usize, limit: usize) -> Res<RowsPage> {
|
||||
let t = self.table(table)?;
|
||||
let mut page = RowsPage {
|
||||
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if t.virtual_table || t.rootpage == 0 {
|
||||
page.warnings
|
||||
.push("Tabela virtual: o conteúdo não fica armazenado como linhas comuns.".into());
|
||||
return Ok(page);
|
||||
}
|
||||
if t.without_rowid {
|
||||
page.warnings.push(
|
||||
"Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida."
|
||||
.into(),
|
||||
);
|
||||
return Ok(page);
|
||||
}
|
||||
let mut seen = 0usize;
|
||||
let ncols = t.columns.len();
|
||||
page.warnings = self.walk_table(t.rootpage, &mut |rowid, p| {
|
||||
seen += 1;
|
||||
if seen <= offset {
|
||||
return true;
|
||||
}
|
||||
if page.rows.len() >= limit {
|
||||
return false;
|
||||
}
|
||||
if let Some(mut cells) = decode_record(p, self.enc) {
|
||||
if let Some(a) = t.rowid_alias {
|
||||
if a < cells.len() && matches!(cells[a], Cell::Null) {
|
||||
cells[a] = Cell::Int(rowid);
|
||||
}
|
||||
}
|
||||
cells.resize(cells.len().max(ncols), Cell::Null);
|
||||
apply_affinity(&mut cells, &t.columns);
|
||||
page.rows.push(DbRow {
|
||||
rowid: Some(rowid),
|
||||
cells,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
true
|
||||
});
|
||||
page.total = self.count_rows(&t);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- registros apagados
|
||||
|
||||
/// Procura registros apagados nas áreas livres das páginas da tabela.
|
||||
/// São "prováveis": o SQLite não marca o que foi apagado, então validamos pela forma
|
||||
/// do registro (mesmo número de colunas, tipos e textos coerentes).
|
||||
pub fn deleted_rows(&self, table: &str) -> Res<RowsPage> {
|
||||
let t = self.table(table)?;
|
||||
let n = t.columns.len();
|
||||
let mut out = RowsPage {
|
||||
columns: t.columns.iter().map(|c| c.name.clone()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if n < 2 || t.rootpage == 0 || t.virtual_table || t.without_rowid {
|
||||
return Ok(out);
|
||||
}
|
||||
for leaf in self.leaf_pages(t.rootpage) {
|
||||
let Ok(pg) = self.page(leaf) else { continue };
|
||||
let hb = if leaf == 1 { 100 } else { 0 };
|
||||
let ncell = be16(&pg, hb + 3).unwrap_or(0);
|
||||
let content = match be16(&pg, hb + 5) {
|
||||
Some(0) => 65536,
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let ptr_end = hb + 8 + ncell * 2;
|
||||
let mut regions: Vec<(usize, usize)> = vec![];
|
||||
if content > ptr_end {
|
||||
regions.push((ptr_end, content));
|
||||
}
|
||||
// cadeia de blocos livres
|
||||
let mut fb = be16(&pg, hb + 1).unwrap_or(0);
|
||||
let mut guard = 0;
|
||||
while fb != 0 && guard < 2000 {
|
||||
guard += 1;
|
||||
let (Some(next), Some(size)) = (be16(&pg, fb), be16(&pg, fb + 2)) else {
|
||||
break;
|
||||
};
|
||||
if fb + size <= self.usable && size >= 4 {
|
||||
regions.push((fb, fb + size));
|
||||
}
|
||||
fb = next;
|
||||
}
|
||||
for (a, b) in regions {
|
||||
let end = b.min(pg.len()).min(self.usable);
|
||||
if a >= end {
|
||||
continue;
|
||||
}
|
||||
let mut p = a;
|
||||
while p + 3 < end {
|
||||
if let Some((mut cells, used)) = self.try_deleted(&pg[p..end], &t) {
|
||||
apply_affinity(&mut cells, &t.columns);
|
||||
out.rows.push(DbRow {
|
||||
rowid: None,
|
||||
cells,
|
||||
deleted: true,
|
||||
});
|
||||
p += used.max(1);
|
||||
} else {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.rows.len() >= 5000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.total = out.rows.len() as u64;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Tenta ler um registro apagado no início de `b`, em três formas:
|
||||
/// (a) cabeçalho íntegro; (b) tamanho do cabeçalho perdido (o SQLite sobrescreve os 4
|
||||
/// primeiros bytes do bloco livre); (c) como (b), mas sem o primeiro tipo (coluna que é
|
||||
/// alias do rowid, sempre NULL).
|
||||
fn try_deleted(&self, b: &[u8], t: &DbTable) -> Option<(Vec<Cell>, usize)> {
|
||||
let n = t.columns.len();
|
||||
// (a)
|
||||
if let Some(r) = self.record_with_types(b, 1, n, None, t) {
|
||||
return Some(r);
|
||||
}
|
||||
// (b)
|
||||
if let Some(r) = self.record_with_types(b, 0, n, None, t) {
|
||||
return Some(r);
|
||||
}
|
||||
// (c)
|
||||
if t.rowid_alias == Some(0) {
|
||||
return self.record_with_types(b, 0, n - 1, Some(0), t);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Lê `ntypes` tipos seriais a partir de `b[start..]` (com um tipo inicial opcional já
|
||||
/// conhecido) e o corpo logo depois do cabeçalho.
|
||||
fn record_with_types(
|
||||
&self,
|
||||
b: &[u8],
|
||||
start: usize,
|
||||
ntypes: usize,
|
||||
first: Option<i64>,
|
||||
t: &DbTable,
|
||||
) -> Option<(Vec<Cell>, usize)> {
|
||||
let n = t.columns.len();
|
||||
let mut types: Vec<i64> = first.into_iter().collect();
|
||||
let mut o = start;
|
||||
let mut hdr_bytes = 1usize; // o próprio byte do tamanho do cabeçalho
|
||||
for _ in 0..ntypes {
|
||||
let (ty, m) = varint(b.get(o..)?)?;
|
||||
types.push(ty);
|
||||
o += m;
|
||||
hdr_bytes += m;
|
||||
}
|
||||
if first.is_some() {
|
||||
hdr_bytes += 1;
|
||||
}
|
||||
if start == 1 {
|
||||
// forma (a): o primeiro byte declara o tamanho do cabeçalho e precisa bater
|
||||
if *b.first()? as usize != o {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if types.len() != n {
|
||||
return None;
|
||||
}
|
||||
for (ty, col) in types.iter().zip(&t.columns) {
|
||||
if !type_ok(&col.decl_type, *ty) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let body: usize = types
|
||||
.iter()
|
||||
.map(|&x| serial_len(x))
|
||||
.sum::<Option<usize>>()?;
|
||||
if body == 0 || o + body > b.len() {
|
||||
return None;
|
||||
}
|
||||
// monta um registro canônico para reaproveitar o decodificador
|
||||
let mut rec = vec![];
|
||||
let mut hdr = vec![];
|
||||
for &ty in &types {
|
||||
put_varint(&mut hdr, ty);
|
||||
}
|
||||
put_varint(&mut rec, (hdr.len() + 1) as i64);
|
||||
rec.extend(hdr);
|
||||
rec.extend_from_slice(&b[o..o + body]);
|
||||
let cells = decode_record(&rec, self.enc)?;
|
||||
let mut texts = 0;
|
||||
for c in &cells {
|
||||
if let Cell::Text(s) = c {
|
||||
if s.contains('\u{FFFD}')
|
||||
|| s.chars()
|
||||
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
texts += 1;
|
||||
}
|
||||
}
|
||||
let non_null = cells.iter().filter(|c| !matches!(c, Cell::Null)).count();
|
||||
if non_null < 2 || (texts == 0 && n < 3) {
|
||||
return None;
|
||||
}
|
||||
let _ = hdr_bytes;
|
||||
Some((cells, o + body))
|
||||
}
|
||||
}
|
||||
|
||||
/// O tipo serial é compatível com o tipo declarado da coluna (afinidade do SQLite)?
|
||||
fn type_ok(decl: &str, ty: i64) -> bool {
|
||||
if ty == 10 || ty == 11 || ty < 0 {
|
||||
return false;
|
||||
}
|
||||
if ty == 0 {
|
||||
return true;
|
||||
}
|
||||
let d = decl.to_uppercase();
|
||||
let is_text = ty >= 13 && ty % 2 == 1;
|
||||
let is_blob = ty >= 12 && ty % 2 == 0;
|
||||
if d.contains("INT") {
|
||||
ty <= 9 || is_text
|
||||
} else if d.contains("CHAR") || d.contains("CLOB") || d.contains("TEXT") {
|
||||
is_text || is_blob || ty <= 9
|
||||
} else if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
|
||||
ty <= 9
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn put_varint(out: &mut Vec<u8>, v: i64) {
|
||||
let v = v as u64;
|
||||
if v <= 0x7F {
|
||||
out.push(v as u8);
|
||||
return;
|
||||
}
|
||||
let mut tmp = vec![];
|
||||
let mut x = v;
|
||||
while x > 0 {
|
||||
tmp.push((x & 0x7F) as u8);
|
||||
x >>= 7;
|
||||
}
|
||||
tmp.reverse();
|
||||
let last = tmp.len() - 1;
|
||||
for (i, b) in tmp.iter().enumerate() {
|
||||
out.push(if i == last { *b } else { *b | 0x80 });
|
||||
}
|
||||
}
|
||||
|
||||
/// O SQLite guarda REAIS inteiros como inteiros; devolve-os como REAL quando a coluna é REAL.
|
||||
fn apply_affinity(cells: &mut [Cell], cols: &[DbColumn]) {
|
||||
for (c, col) in cells.iter_mut().zip(cols) {
|
||||
let d = col.decl_type.to_uppercase();
|
||||
if d.contains("REAL") || d.contains("FLOA") || d.contains("DOUB") {
|
||||
if let Cell::Int(i) = c {
|
||||
*c = Cell::Real(*i as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CREATE TABLE
|
||||
|
||||
fn unquote(s: &str) -> String {
|
||||
let t = s.trim();
|
||||
let b = t.as_bytes();
|
||||
if b.len() >= 2
|
||||
&& matches!(
|
||||
(b[0], b[b.len() - 1]),
|
||||
(b'"', b'"') | (b'`', b'`') | (b'[', b']') | (b'\'', b'\'')
|
||||
)
|
||||
{
|
||||
t[1..t.len() - 1].to_string()
|
||||
} else {
|
||||
t.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrai colunas, alias de rowid e WITHOUT ROWID de um `CREATE TABLE`.
|
||||
fn parse_create(sql: &str) -> (Vec<DbColumn>, Option<usize>, bool) {
|
||||
let upper = sql.to_uppercase();
|
||||
let without_rowid = upper.trim_end().ends_with("WITHOUT ROWID");
|
||||
let Some(open) = sql.find('(') else {
|
||||
return (vec![], None, without_rowid);
|
||||
};
|
||||
// fecha o parêntese correspondente
|
||||
let mut depth = 0;
|
||||
let mut close = sql.len();
|
||||
let mut in_q: Option<char> = None;
|
||||
for (i, ch) in sql.char_indices().skip_while(|(i, _)| *i < open) {
|
||||
match (in_q, ch) {
|
||||
(Some(q), c) if c == q => in_q = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'' | '`') => in_q = Some(ch),
|
||||
(None, '(') => depth += 1,
|
||||
(None, ')') => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let body = &sql[open + 1..close.min(sql.len())];
|
||||
// separa por vírgulas de nível 0
|
||||
let mut parts = vec![];
|
||||
let (mut d, mut start, mut q) = (0, 0, None::<char>);
|
||||
for (i, ch) in body.char_indices() {
|
||||
match (q, ch) {
|
||||
(Some(c), x) if c == x => q = None,
|
||||
(Some(_), _) => {}
|
||||
(None, '"' | '\'' | '`') => q = Some(ch),
|
||||
(None, '(') => d += 1,
|
||||
(None, ')') => d -= 1,
|
||||
(None, ',') if d == 0 => {
|
||||
parts.push(&body[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.push(&body[start..]);
|
||||
let mut cols = vec![];
|
||||
let mut alias = None;
|
||||
for part in parts {
|
||||
let p = part.trim();
|
||||
let up = p.to_uppercase();
|
||||
if ["PRIMARY", "UNIQUE", "CHECK", "FOREIGN", "CONSTRAINT"]
|
||||
.iter()
|
||||
.any(|k| up.starts_with(k))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (name, rest) = if p.starts_with(['"', '`', '[']) {
|
||||
let close = match p.as_bytes()[0] {
|
||||
b'[' => ']',
|
||||
b'`' => '`',
|
||||
_ => '"',
|
||||
};
|
||||
let end = p[1..].find(close).map(|e| e + 2).unwrap_or(p.len());
|
||||
(unquote(&p[..end]), p[end..].trim())
|
||||
} else {
|
||||
match p.split_once(char::is_whitespace) {
|
||||
Some((a, b)) => (a.to_string(), b.trim()),
|
||||
None => (p.to_string(), ""),
|
||||
}
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ru = rest.to_uppercase();
|
||||
let decl: String = rest
|
||||
.split_whitespace()
|
||||
.take_while(|w| {
|
||||
![
|
||||
"PRIMARY",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"UNIQUE",
|
||||
"DEFAULT",
|
||||
"CHECK",
|
||||
"REFERENCES",
|
||||
"COLLATE",
|
||||
"CONSTRAINT",
|
||||
"GENERATED",
|
||||
"AS",
|
||||
]
|
||||
.contains(&w.to_uppercase().as_str())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let pk = ru.contains("PRIMARY KEY");
|
||||
if pk && decl.eq_ignore_ascii_case("INTEGER") && !ru.contains("DESC") {
|
||||
alias = Some(cols.len());
|
||||
}
|
||||
cols.push(DbColumn {
|
||||
name,
|
||||
decl_type: decl,
|
||||
pk,
|
||||
});
|
||||
}
|
||||
(cols, alias, without_rowid)
|
||||
}
|
||||
|
||||
/// O arquivo começa com o cabeçalho de um banco SQLite?
|
||||
pub fn is_sqlite(head: &[u8]) -> bool {
|
||||
head.len() >= 16 && &head[0..16] == MAGIC
|
||||
}
|
||||
@@ -111,7 +111,9 @@ pub fn hashes_in(dir: &Path) -> HashMap<String, PathBuf> {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, m);
|
||||
} else if p.file_name().unwrap() != "guara-manifesto.json" {
|
||||
} else if !["guara-manifesto.json", "guara-diario.jsonl"]
|
||||
.contains(&p.file_name().unwrap().to_str().unwrap())
|
||||
{
|
||||
m.insert(sha256_file(&p), p);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -300,6 +300,7 @@ fn configuracoes_de_tema_persistem() {
|
||||
assert_eq!(settings::load().theme, "auto");
|
||||
settings::save(&settings::Settings {
|
||||
theme: "dark".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(settings::load().theme, "dark");
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Saúde do disco, notificações, configurações e retomada da recuperação.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
use guara_core::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ------------------------------------------------------------------ saúde (SMART)
|
||||
|
||||
#[test]
|
||||
fn smart_reprovado_e_setores_ruins_viram_alerta_serio() {
|
||||
let v = json!({
|
||||
"model_name": "WDC WD5000", "serial_number": "ABC123",
|
||||
"power_on_time": {"hours": 43000}, "temperature": {"current": 38},
|
||||
"smart_status": {"passed": false},
|
||||
"ata_smart_attributes": {"table": [
|
||||
{"id": 5, "raw": {"value": 120}}, {"id": 197, "raw": {"value": 3}}, {"id": 199, "raw": {"value": 0}}
|
||||
]}
|
||||
});
|
||||
let h = health::parse_smartctl(&v);
|
||||
assert!(h.available);
|
||||
assert_eq!(h.status, "failing");
|
||||
assert!(h
|
||||
.details
|
||||
.iter()
|
||||
.any(|(k, v)| k == "Setores realocados" && v == "120"));
|
||||
assert!(h
|
||||
.details
|
||||
.iter()
|
||||
.any(|(k, _)| k == "Setores pendentes de realocação"));
|
||||
assert!(
|
||||
h.advice.iter().any(|a| a.contains("imagem")),
|
||||
"deve recomendar a imagem primeiro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_saudavel_nvme_desgastado_e_nvme_critico() {
|
||||
let ok = json!({"smart_status": {"passed": true}, "ata_smart_attributes": {"table": [{"id": 5, "raw": {"value": 0}}, {"id": 197, "raw": {"value": 0}}]}});
|
||||
let h = health::parse_smartctl(&ok);
|
||||
assert_eq!(h.status, "ok");
|
||||
assert!(h.advice.is_empty());
|
||||
|
||||
let worn = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 93, "media_errors": 0, "available_spare": 80, "available_spare_threshold": 10}});
|
||||
assert_eq!(health::parse_smartctl(&worn).status, "warning");
|
||||
|
||||
let crit = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {"critical_warning": 4, "percentage_used": 10, "media_errors": 0}});
|
||||
assert_eq!(health::parse_smartctl(&crit).status, "failing");
|
||||
|
||||
let media = json!({"nvme_smart_health_information_log": {"critical_warning": 0, "percentage_used": 10, "media_errors": 7}});
|
||||
assert_eq!(health::parse_smartctl(&media).status, "failing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imagem_de_disco_nao_tem_smart() {
|
||||
let f = tempfile::NamedTempFile::new().unwrap();
|
||||
let h = health::check(f.path().to_str().unwrap());
|
||||
assert!(!h.available);
|
||||
assert_eq!(h.status, "unknown");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ configurações
|
||||
|
||||
#[test]
|
||||
fn configuracoes_antigas_ganham_os_valores_padrao_novos() {
|
||||
init();
|
||||
// arquivo gravado pela v0.3: só tinha o tema
|
||||
std::fs::create_dir_all(settings::config_dir()).unwrap();
|
||||
std::fs::write(
|
||||
settings::config_dir().join("settings.json"),
|
||||
br#"{"theme":"dark"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = settings::load();
|
||||
assert_eq!(
|
||||
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
|
||||
("dark", "auto", true, false)
|
||||
);
|
||||
settings::save(&settings::Settings {
|
||||
theme: "light".into(),
|
||||
lang: "en".into(),
|
||||
notify: false,
|
||||
maps: true,
|
||||
})
|
||||
.unwrap();
|
||||
let s = settings::load();
|
||||
assert_eq!(
|
||||
(s.theme.as_str(), s.lang.as_str(), s.notify, s.maps),
|
||||
("light", "en", false, true)
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ notificações
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn notificacao_chama_o_notify_send() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = tempfile::tempdir().unwrap();
|
||||
let marker = bin.path().join("recebido");
|
||||
let script = bin.path().join("notify-send");
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\nprintf '%s|' \"$@\" > '{}'\n", marker.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let old = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var("PATH", format!("{}:{old}", bin.path().display()));
|
||||
notify::send("Guará: varredura concluída", "7 arquivos");
|
||||
for _ in 0..100 {
|
||||
if marker.exists() && !std::fs::read(&marker).unwrap().is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
std::env::set_var("PATH", old);
|
||||
let got = String::from_utf8(std::fs::read(&marker).unwrap()).unwrap();
|
||||
assert!(
|
||||
got.contains("Guará: varredura concluída") && got.contains("7 arquivos"),
|
||||
"{got}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ retomar recuperação
|
||||
|
||||
#[test]
|
||||
fn recuperacao_interrompida_retoma_pulando_o_que_ja_foi_conferido() {
|
||||
let Some(dir) = fixtures() else { return };
|
||||
let img = dir.join("fat32.img");
|
||||
let res = scan_path(
|
||||
&img,
|
||||
&ScanOptions {
|
||||
mode: ScanMode::Quick,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(res.found.len(), 7);
|
||||
let src = source::open_path(&img).unwrap();
|
||||
let files: Vec<&Found> = res.found.iter().collect();
|
||||
let out = tempfile::tempdir().unwrap();
|
||||
let key = "disco-de-teste";
|
||||
|
||||
// 1ª execução: "cai a energia" depois de 3 arquivos
|
||||
let n = std::sync::atomic::AtomicUsize::new(0);
|
||||
let cancel = || n.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 3;
|
||||
let r1 = recover::recover_resumable(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Folders,
|
||||
key,
|
||||
true,
|
||||
&cancel,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r1.cancelled);
|
||||
assert_eq!(r1.recovered.len(), 3);
|
||||
let journal = std::fs::read_to_string(out.path().join(recover::JOURNAL)).unwrap();
|
||||
assert_eq!(journal.lines().count(), 3);
|
||||
|
||||
// 2ª execução: retoma, pula os 3 e faz os outros 4
|
||||
let r2 = recover::recover_resumable(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Folders,
|
||||
key,
|
||||
true,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r2.cancelled);
|
||||
assert_eq!((r2.skipped, r2.recovered.len()), (3, 7));
|
||||
let got = hashes_in(out.path());
|
||||
for (h, p) in expected(&dir, "fat32") {
|
||||
assert!(got.contains_key(&h), "faltou {p}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(out.path().join(recover::JOURNAL))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.count(),
|
||||
7
|
||||
);
|
||||
|
||||
// um arquivo é danificado entre as execuções: é refeito, os outros 6 são pulados
|
||||
let victim = out.path().join(&r2.recovered[0].path);
|
||||
std::fs::write(&victim, b"lixo").unwrap();
|
||||
let r3 = recover::recover_resumable(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Folders,
|
||||
key,
|
||||
true,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r3.skipped, r3.recovered.len(), r3.failed.len()), (6, 7, 0));
|
||||
assert!(
|
||||
hashes_in(out.path()).contains_key(&r2.recovered[0].sha256),
|
||||
"o arquivo danificado deveria ter sido refeito"
|
||||
);
|
||||
|
||||
// outro disco (outra chave) não reaproveita o diário
|
||||
let r4 = recover::recover_resumable(
|
||||
src.as_ref(),
|
||||
&files,
|
||||
out.path(),
|
||||
recover::RecoverLayout::Folders,
|
||||
"outro-disco",
|
||||
true,
|
||||
&|| false,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r4.skipped, 0);
|
||||
let _ = json!({});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Leitor de SQLite: esquema, linhas (com overflow e árvores de vários níveis),
|
||||
//! registros apagados e robustez contra bancos danificados.
|
||||
|
||||
use guara_core::source::MemSource;
|
||||
use guara_core::sqlite::{is_sqlite, Cell, Db};
|
||||
|
||||
const DB: &[u8] = include_bytes!("data/exemplo.sqlite");
|
||||
|
||||
fn text(c: &Cell) -> &str {
|
||||
match c {
|
||||
Cell::Text(t) => t,
|
||||
other => panic!("esperava texto, veio {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esquema_com_tabelas_indices_e_views() {
|
||||
let src = MemSource(DB.to_vec());
|
||||
assert!(is_sqlite(DB));
|
||||
let db = Db::open(&src).unwrap();
|
||||
let s = db.schema();
|
||||
assert_eq!(s.page_size, 1024);
|
||||
assert_eq!(s.encoding, "UTF-8");
|
||||
let names: Vec<_> = s.tables.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(names, ["usuarios", "notas", "sem_rowid"]);
|
||||
let u = &s.tables[0];
|
||||
assert_eq!(
|
||||
u.columns
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["id", "nome", "email", "idade", "saldo", "foto"]
|
||||
);
|
||||
assert_eq!(u.columns[0].decl_type, "INTEGER");
|
||||
assert!(u.columns[0].pk && u.rowid_alias == Some(0));
|
||||
assert!(s.tables[2].without_rowid);
|
||||
assert_eq!(s.indexes, ["idx_usuarios_email"]);
|
||||
assert_eq!(s.views, ["maiores"]);
|
||||
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
|
||||
assert_eq!(db.count_rows(u), 493); // 500 menos 7 apagados
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linhas_em_arvore_de_varios_niveis_e_paginacao() {
|
||||
let src = MemSource(DB.to_vec());
|
||||
let db = Db::open(&src).unwrap();
|
||||
let p = db.rows("usuarios", 0, 5).unwrap();
|
||||
assert_eq!(p.total, 493);
|
||||
assert_eq!(p.rows.len(), 5);
|
||||
// o alias do rowid (INTEGER PRIMARY KEY) vem preenchido
|
||||
assert!(matches!(p.rows[0].cells[0], Cell::Int(1)));
|
||||
assert_eq!(text(&p.rows[0].cells[1]), "Pessoa 0001");
|
||||
assert_eq!(text(&p.rows[0].cells[2]), "pessoa1@exemplo.com");
|
||||
assert!(matches!(p.rows[0].cells[3], Cell::Int(19)));
|
||||
assert!(matches!(p.rows[1].cells[4], Cell::Real(r) if (r - 3.0).abs() < 1e-9));
|
||||
assert!(matches!(&p.rows[2].cells[5], Cell::Blob { len: 3, .. }));
|
||||
// pula os apagados 101..103: a linha de índice 100 é o id 104
|
||||
let q = db.rows("usuarios", 100, 2).unwrap();
|
||||
assert!(matches!(q.rows[0].cells[0], Cell::Int(104)));
|
||||
// última linha
|
||||
let last = db.rows("usuarios", 492, 10).unwrap();
|
||||
assert_eq!(last.rows.len(), 1);
|
||||
assert_eq!(text(&last.rows[0].cells[1]), "Pessoa 0500");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texto_longo_com_overflow_e_acentos() {
|
||||
let src = MemSource(DB.to_vec());
|
||||
let db = Db::open(&src).unwrap();
|
||||
let n = db.rows("notas", 0, 10).unwrap();
|
||||
assert_eq!(n.rows.len(), 3);
|
||||
assert_eq!(text(&n.rows[0].cells[1]), "texto pequeno");
|
||||
assert_eq!(
|
||||
text(&n.rows[1].cells[1]).len(),
|
||||
"Lorem ipsum dolor sit amet. ".len() * 400
|
||||
);
|
||||
assert!(text(&n.rows[1].cells[1]).ends_with("amet. "));
|
||||
assert_eq!(text(&n.rows[2].cells[1]), "coração, ação, relatório");
|
||||
assert_eq!(text(&n.rows[2].cells[0]), "acentuação");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recupera_registros_apagados_das_areas_livres() {
|
||||
let src = MemSource(DB.to_vec());
|
||||
let db = Db::open(&src).unwrap();
|
||||
let d = db.deleted_rows("usuarios").unwrap();
|
||||
let nomes: Vec<String> = d
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|r| r.cells.get(1))
|
||||
.filter_map(|c| {
|
||||
if let Cell::Text(t) = c {
|
||||
Some(t.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let apagados = [
|
||||
"Pessoa 0101",
|
||||
"Pessoa 0102",
|
||||
"Pessoa 0103",
|
||||
"Pessoa 0250",
|
||||
"Pessoa 0251",
|
||||
"Pessoa 0252",
|
||||
"Pessoa 0253",
|
||||
];
|
||||
let achados = apagados
|
||||
.iter()
|
||||
.filter(|n| nomes.iter().any(|x| x == *n))
|
||||
.count();
|
||||
assert!(
|
||||
achados >= 5,
|
||||
"só {achados} de 7 registros apagados recuperados: {nomes:?}"
|
||||
);
|
||||
assert!(d.rows.iter().all(|r| r.deleted));
|
||||
// a tabela de notas não teve nada apagado
|
||||
assert!(db.deleted_rows("notas").unwrap().rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banco_truncado_e_corrompido_nao_derruba_nem_trava() {
|
||||
// metade do arquivo
|
||||
let half = DB[..DB.len() / 2].to_vec();
|
||||
let src = MemSource(half);
|
||||
let db = Db::open(&src).unwrap();
|
||||
let s = db.schema();
|
||||
assert!(
|
||||
s.warnings.iter().any(|w| w.contains("truncado")),
|
||||
"{:?}",
|
||||
s.warnings
|
||||
);
|
||||
let p = db.rows("usuarios", 0, 1000).unwrap();
|
||||
assert!(!p.rows.is_empty() && p.rows.len() < 493, "{}", p.rows.len());
|
||||
// sem cabeçalho
|
||||
let mut bad = DB.to_vec();
|
||||
bad[0] = b'X';
|
||||
assert!(Db::open(&MemSource(bad)).is_err());
|
||||
// corrupção aleatória (semente fixa): nunca pode dar panic
|
||||
let mut x: u64 = 0x1234_5678_9abc_def0;
|
||||
for _ in 0..60 {
|
||||
let mut d = DB.to_vec();
|
||||
for _ in 0..40 {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
let i = 100 + (x as usize % (d.len() - 100));
|
||||
d[i] = (x >> 24) as u8;
|
||||
}
|
||||
let src = MemSource(d);
|
||||
if let Ok(db) = Db::open(&src) {
|
||||
let s = db.schema();
|
||||
for t in &s.tables {
|
||||
let _ = db.rows(&t.name, 0, 50);
|
||||
let _ = db.deleted_rows(&t.name);
|
||||
let _ = db.count_rows(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user