diff --git a/CHANGELOG.md b/CHANGELOG.md index 107612a..764826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 99a7163..f921203 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index f680b5b..0951116 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 686e513..9aeb394 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index 7e0e333..94f820f 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "guara-gui" description = "Guará: 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" diff --git a/apps/gui/src-tauri/capabilities/default.json b/apps/gui/src-tauri/capabilities/default.json index b954939..92e513c 100644 --- a/apps/gui/src-tauri/capabilities/default.json +++ b/apps/gui/src-tauri/capabilities/default.json @@ -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"] } diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json index 8c7b442..e239672 100644 --- a/apps/gui/src-tauri/tauri.conf.json +++ b/apps/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "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": { diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 40595c7..aa3426e 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -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); } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index d239dda..6cc7fd1 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -2,6 +2,7 @@ // • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/ 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) => `
  • `).join(''); + ${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}`).join(''); rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r]))); } @@ -270,9 +279,36 @@ async function openDevice(d) { main.innerHTML = `

    Lendo ${esc(d.name)}…

    `; 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]) => `
    ${esc(k)}
    ${esc(v)}
    `).join(''); + el.innerHTML = `
    ${warnSvg}
    ${esc(h.summary)} + ${rows ? `
    ${rows}
    ` : ''}${(h.advice || []).map((a) => `
    ${esc(a)}
    `).join('')} + ${bad ? '
    ' : ''}
    `; + $('#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 = `

    Este disco parece estar com defeito

    ${esc(h.summary)}

    ${(h.advice || []).map((a) => `

    ${esc(a)}

    `).join('')} +
    `; + 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 = `

    ${esc(d.name)}

    ${esc(d.kind)}${d.size ? ' · ' + human(d.size) : l ? ' · ' + human(l.size) : ''} · ${esc(d.path)}
    ${notes.map(([k, t]) => `
    ${warnSvg}
    ${t}
    `).join('')} +
    ${l ? `

    Mapa do disco

    ${diskMap(l)}
    ${l.partitions.map((p) => `
    Partição ${p.index + 1} ${esc(FS_NAME[p.fs] || p.fs)} · ${human(p.len)}${p.label ? ' · ' + esc(p.label) : ''} ${p.lost ? 'encontrada por varredura' : ''}${p.encrypted ? `cifrada (${esc(p.encrypted)}) · só detecção` : ''}
    `).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 ? `
    ${pct}% · ${res.total.toLocaleString('pt-BR')} até agora
    ` : st.status === 'error' ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); - setHTML($('#hits'), res.items.length ? `
    PosiçãoPartiçãoCodificaçãoContexto
    ` + res.items.map((h) => `
    ${h.offset.toLocaleString('pt-BR')}${h.partition ? '#' + h.partition : 'fora'}${esc(h.encoding)}${esc(h.context).replace(termRe, (m) => `${m}`)}
    `).join('') : (run ? '' : '

    Nada encontrado

    Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).

    ')); + $('#shTitle').textContent = run ? 'Procurando…' : `${res.total.toLocaleString(LOC())} ocorrência${res.total === 1 ? '' : 's'}`; + setHTML($('#shProg'), run ? `
    ${pct}% · ${res.total.toLocaleString(LOC())} até agora
    ` : st.status === 'error' ? `
    ${warnSvg}
    ${esc(st.error)}
    ` : ''); + setHTML($('#hits'), res.items.length ? `
    PosiçãoPartiçãoCodificaçãoContexto
    ` + res.items.map((h) => `
    ${h.offset.toLocaleString(LOC())}${h.partition ? '#' + h.partition : 'fora'}${esc(h.encoding)}${esc(h.context).replace(termRe, (m) => `${m}`)}
    `).join('') : (run ? '' : '

    Nada encontrado

    Esses termos não aparecem em nenhum ponto do disco (nem em UTF-8 nem em UTF-16).

    ')); return run; }; $('#shProg').onclick = (e) => { if (e.target.dataset.stop) rpc('job_stop', { job }); }; @@ -462,7 +506,7 @@ function renderScan() {
    `; $('#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 = `${esc(st.target)}`; 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 ? `Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString('pt-BR')}/${st.enriching.total.toLocaleString('pt-BR')}` : ''; + const enr = st.enriching ? `Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}` : ''; const buttons = running ? '' : st.status === 'paused' ? `` : ''; const relink = st.source === false ? '' : ''; @@ -485,14 +529,14 @@ function paintStatus() {
    ${esc(p.phase || 'Iniciando')}${pct != null ? ` · ${pct}%` : ''}
    ${buttons}
    -
    ${st.found.toLocaleString('pt-BR')}arquivos
    +
    ${st.found.toLocaleString(LOC())}arquivos
    ${dur(st.elapsed)}decorrido${eta ? ' · faltam ~' + eta : ''}
    ${st.confidence.high}alta
    ${st.confidence.medium}média
    ${st.confidence.low}baixa
    `); } else { - setHTML($('#progressArea'), `
    ${st.found.toLocaleString('pt-BR')} arquivos em ${dur(st.elapsed)} + setHTML($('#progressArea'), `
    ${st.found.toLocaleString(LOC())} arquivos em ${dur(st.elapsed)} confiança alta ${st.confidence.high} · média ${st.confidence.medium} · baixa ${st.confidence.low}${enr}${tools}
    `); } setHTML($('#warnArea'), (st.warnings || []).map((w) => `
    ${warnSvg}
    ${esc(w)}
    `).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 outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.', + text: 'Escolha uma pasta em outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.', 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 `

    ${ok} arquivo(s) salvos em ${esc(dest)} (${human(r.total_bytes)}).

    + ${r.skipped ? `

    ${r.skipped} já estavam recuperados e conferidos (pulados).

    ` : ''} ${bad ? `
    ${warnSvg}
    ${bad} arquivo(s) não puderam ser gravados.
    ` : ''}

    O arquivo guara-manifesto.json lista MD5, SHA-1 e SHA-256 de cada arquivo para você conferir a integridade.

    `; }); @@ -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 = `

    Configurações

    + + + +
    `; + 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 = `

    Há uma varredura em andamento

    A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.

    +
    `; + 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 = `
    ${warnSvg}
    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 guara ui (ele inclui o token).
    `; diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js index 6d576ca..4c1a78d 100644 --- a/apps/gui/ui/explorer.js +++ b/apps/gui/ui/explorer.js @@ -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 - ? `${n.toLocaleString('pt-BR')} selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''} + ? `${n.toLocaleString(LOC())} selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''} - ${!E.allFiltered && E.total > n ? `` : ''} + ${!E.allFiltered && E.total > n ? `` : ''} ` - : `${E.total.toLocaleString('pt-BR')} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString('pt-BR')})` : ''} + : `${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'item' : 'itens'} nesta vista${E.total > E.items.length ? ` (mostrando ${E.items.length.toLocaleString(LOC())})` : ''} ${E.total ? '' : ''}`; 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 = '') => - ``; + ``; const sec = (title, body) => (body ? `

    ${title}

    ${body}
    ` : ''); 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 = `${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}${E.total.toLocaleString('pt-BR')}`; + $('#xpCrumb').innerHTML = `${parts.length ? esc(parts.join(' › ')) : 'Todos os arquivos'}${E.total.toLocaleString(LOC())}`; } function paintChips() { @@ -558,7 +563,7 @@ export function createExplorer(deps) { const close = ``; if (n > 1) { const sizeSum = E.allFiltered ? null : [...E.sel].reduce((a, id) => a + (E.items[idxOf(id)]?.size || 0), 0); - insp.innerHTML = `${close}
    ${n.toLocaleString('pt-BR')} itens selecionados${sizeSum != null ? `${human(sizeSum)} no total` : ''} + insp.innerHTML = `${close}
    ${n.toLocaleString(LOC())} itens selecionados${sizeSum != null ? `${human(sizeSum)} no total` : ''}
    `; return; } @@ -575,7 +580,7 @@ export function createExplorer(deps) {
    Pasta
    ${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}
    ${timesHtml(it)}${it.camera ? `
    Câmera
    ${esc(it.camera)}
    ` : ''}${it.px ? `
    Resolução
    ${it.px[0]} × ${it.px[1]} px
    ` : ''}
    `; - 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 = `
    Metadados${m.kind ? ` · ${esc(m.kind)}` : ''}
    ${rows.map(([k, v]) => `
    ${esc(k)}
    ${esc(v)}
    `).join('')}
    ` - + (m.gps ? `
    Coordenadas: ${m.gps[0].toFixed(5)}, ${m.gps[1].toFixed(5)}
    ` : ''); + + (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 += ``; + const px = (fx - tx + 1) / 3 * 100, py = (fy - ty + 1) / 3 * 100; + map = `
    ${imgs}
    `; + } + return `
    Coordenadas: ${lat.toFixed(5)}, ${lon.toFixed(5)}
    ${map} +
    + ${map ? '
    © colaboradores do OpenStreetMap
    ' : ''}`; + } 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) {
    ${it.note ? esc(it.note) : '← → para navegar · Esc para fechar'}
    `; 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); diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js new file mode 100644 index 0000000..40d4256 --- /dev/null +++ b/apps/gui/ui/i18n-en.js @@ -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 outro disco. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar.': 'Choose a folder on another disk. 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 outro disco com espaço suficiente e um nome que ainda não exista.': 'Where to save the image file? Use another disk 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.', +}; diff --git a/apps/gui/ui/i18n.js b/apps/gui/ui/i18n.js new file mode 100644 index 0000000..99533b1 --- /dev/null +++ b/apps/gui/ui/i18n.js @@ -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(); +} diff --git a/apps/gui/ui/index.html b/apps/gui/ui/index.html index 936478a..cd49cf9 100644 --- a/apps/gui/ui/index.html +++ b/apps/gui/ui/index.html @@ -28,7 +28,7 @@
      -
      Somente leitura na origem
      +
      Somente leitura na origem
      diff --git a/apps/gui/ui/preview.js b/apps/gui/ui/preview.js index cbeca89..cb8dc61 100644 --- a/apps/gui/ui/preview.js +++ b/apps/gui/ui/preview.js @@ -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 'NULL'; + case 'int': { const h = tsHint(c.v); return h ? `${c.v}${esc(h.text.slice(0, 10))}` : String(c.v); } + case 'real': return String(c.v); + case 'text': return `${esc(c.v.length > 240 ? c.v.slice(0, 240) + '…' : c.v)}`; + default: return `BLOB ${c.v.mime ? esc(c.v.mime) + ' ' : ''}${human(c.v.len)}`; + } +} + +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) => `
      ${esc(w)}
      `).join(''); + if (!large) { + const total = tables.reduce((a, t) => a + (t.rows || 0), 0); + host.innerHTML = `
      Banco SQLite${tables.length} tabela(s) · ${total.toLocaleString(LOC())} linhas · páginas de ${human(info.page_size)} +
        ${tables.slice(0, 10).map((t) => `
      • ${esc(t.name)}${(t.rows || 0).toLocaleString(LOC())}
      • `).join('')}
      + ${tables.length > 10 ? `e mais ${tables.length - 10}…` : ''}${warn} +
      `; + 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 = `
      `; + const $ = (sel) => host.querySelector(sel); + const nav = $('.db-tables'), bar = $('.db-bar'), grid = $('.db-grid'), foot = $('.db-foot'); + nav.innerHTML = tables.map((t) => ``).join('') + + (info.indexes.length || info.views.length ? `
      ${info.views.length ? `Views${info.views.map((v) => `${esc(v)}`).join('')}` : ''}${info.indexes.length ? `Índices${info.indexes.map((v) => `${esc(v)}`).join('')}` : ''}
      ` : '') + + 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 = '
      Nenhuma tabela.
      '; return; } + bar.innerHTML = `${esc(t.name)}${(t.rows || 0).toLocaleString(LOC())} linhas · ${t.columns.length} colunas + + + `; + if (S.schema) { grid.innerHTML = `
      ${esc(t.sql)}
      `; foot.innerHTML = ''; wire(); return; } + grid.innerHTML = '
      '; + let page; + try { page = await db.rows(S.table, S.offset, S.limit, S.deleted); } catch (e) { grid.innerHTML = `
      ${esc(e.message || e)}
      `; 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) => `
      ${esc(w)}
      `).join(''); + grid.innerHTML = notes + (rows.length ? `${page.columns.map((c) => ``).join('')}${rows.map((r, i) => `${r.cells.map((c) => ``).join('')}`).join('')}
      #${esc(c)}
      ${r.deleted ? 'apagado' : (r.rowid ?? S.offset + i + 1)}${cellHtml(c)}
      ` + : `
      ${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.'}
      `); + const total = S.deleted ? page.total : (t.rows || page.total); + foot.innerHTML = `${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())}`} + `; + 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); } diff --git a/crates/guara-cli/src/serve.rs b/crates/guara-cli/src/serve.rs index 4377d38..8afa5a7 100644 --- a/crates/guara-cli/src/serve.rs +++ b/crates/guara-cli/src/serve.rs @@ -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"))); } diff --git a/crates/guara-core/src/health.rs b/crates/guara-core/src/health.rs new file mode 100644 index 0000000..3a58c99 --- /dev/null +++ b/crates/guara-core/src/health.rs @@ -0,0 +1,273 @@ +//! 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, +} + +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 { + let p = std::path::Path::new(path); + #[cfg(unix)] + let is_dev = { + use std::os::unix::fs::FileTypeExt; + std::fs::metadata(p) + .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::().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 { + 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 { + 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); +} diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 656bdb5..4e539df 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -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; diff --git a/crates/guara-core/src/notify.rs b/crates/guara-core/src/notify.rs new file mode 100644 index 0000000..33c332c --- /dev/null +++ b/crates/guara-core/src/notify.rs @@ -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 { + // 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 { + 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 { + 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) +} diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index 7a0acf5..ac2cc4c 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -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 { + 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 { + 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 { + 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 = HashSet::new(); + let journal_path = dest.join(JOURNAL); + let mut prior: std::collections::HashMap = 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::(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 { + read_found_at(self.src, self.found, offset, buf) + } +} diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index f3cf57b..fe86a34 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -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 { + 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 = 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 { + 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 { 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, cp: Option) { 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, cp: Option) { } 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); diff --git a/crates/guara-core/src/settings.rs b/crates/guara-core/src/settings.rs index 52edf4e..c9e9b9e 100644 --- a/crates/guara-core/src/settings.rs +++ b/crates/guara-core/src/settings.rs @@ -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, } } } diff --git a/crates/guara-core/src/sqlite.rs b/crates/guara-core/src/sqlite.rs new file mode 100644 index 0000000..92da9f7 --- /dev/null +++ b/crates/guara-core/src/sqlite.rs @@ -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, + pub without_rowid: bool, + pub virtual_table: bool, + /// Coluna `INTEGER PRIMARY KEY` (alias do rowid), se houver. + #[serde(skip)] + pub rowid_alias: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DbSchema { + pub page_size: u32, + pub pages: u32, + pub file_pages: u64, + pub encoding: String, + pub tables: Vec, + pub indexes: Vec, + pub views: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DbRow { + pub rowid: Option, + pub cells: Vec, + pub deleted: bool, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct RowsPage { + pub columns: Vec, + pub rows: Vec, + pub total: u64, + pub warnings: Vec, +} + +pub struct Db<'a> { + src: &'a dyn Source, + ps: usize, + usable: usize, + pages: u32, + enc: u32, +} + +type Res = Result; + +// ------------------------------------------------------------------ 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 { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]) as usize) +} + +fn be32(b: &[u8], o: usize) -> Option { + 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 { + 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::>(), + ), + 3 => String::from_utf16_lossy( + &b.chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect::>(), + ), + _ => String::from_utf8_lossy(b).into_owned(), + } +} + +/// Decodifica um registro (cabeçalho de tipos + corpo). +fn decode_record(p: &[u8], enc: u32) -> Option> { + 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 { + 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> { + 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> { + 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 { + 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 { + 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![]; + 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 { + 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 { + 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 { + 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, 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, + t: &DbTable, + ) -> Option<(Vec, usize)> { + let n = t.columns.len(); + let mut types: Vec = 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::>()?; + 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, 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, Option, 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 = 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::); + 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::>() + .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 +} diff --git a/crates/guara-core/tests/common/mod.rs b/crates/guara-core/tests/common/mod.rs index a45cb7b..823dc33 100644 --- a/crates/guara-core/tests/common/mod.rs +++ b/crates/guara-core/tests/common/mod.rs @@ -111,7 +111,9 @@ pub fn hashes_in(dir: &Path) -> HashMap { 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); } } diff --git a/crates/guara-core/tests/data/exemplo.sqlite b/crates/guara-core/tests/data/exemplo.sqlite new file mode 100644 index 0000000..febbe52 Binary files /dev/null and b/crates/guara-core/tests/data/exemplo.sqlite differ diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index ce911aa..00d8de8 100644 --- a/crates/guara-core/tests/disk.rs +++ b/crates/guara-core/tests/disk.rs @@ -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"); diff --git a/crates/guara-core/tests/extras.rs b/crates/guara-core/tests/extras.rs new file mode 100644 index 0000000..827f97c --- /dev/null +++ b/crates/guara-core/tests/extras.rs @@ -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!({}); +} diff --git a/crates/guara-core/tests/sqlite.rs b/crates/guara-core/tests/sqlite.rs new file mode 100644 index 0000000..6df74f6 --- /dev/null +++ b/crates/guara-core/tests/sqlite.rs @@ -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::>(), + ["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 = 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); + } + } + } +}