Versão 0.5.0: bancos SQLite, saúde do disco, notificações, retomada, mapa GPS, inglês

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 20:01:31 -03:00
1 parent 4bb2397e3b
commit bbcb42ae6e
27 files changed
+2681 -41

No files matched your search

+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"]
}
+2 -2
View File
@@ -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": {
+30
View File
@@ -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
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
View File
@@ -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);
+366
View File
@@ -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.',
};
+121
View File
@@ -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();
}
+1 -1
View File
@@ -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
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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); }