// Recovered file explorer: thumbnail grid (virtualized, handles
// tens of thousands of items), navigation by type/date/folder/camera, file-manager
// style selection (Ctrl/Shift/keyboard), inspector panel, and
// fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
archive: ['Compactados', '🗜'], database: ['Bancos de dados', '🗄'], other: ['Outros', '📦'],
};
const CONF = { high: ['alta', 'ok'], medium: ['média', 'warn'], low: ['baixa', 'bad'] };
const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];
const SORTS = [
['date', 'Data (mais recentes)'], ['date_asc', 'Data (mais antigas)'], ['name', 'Nome'], ['size', 'Tamanho'], ['confidence', 'Confiança'],
];
const GAP = 10;
const PAD = 12;
const svg = (d, w = 18) => ``;
const I = {
grid: svg(''),
list: svg(''),
filter: svg(''),
chev: svg('', 14),
x: svg('', 14),
pin: svg('', 14),
warn: svg('', 22),
close: svg('', 20),
};
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));
};
const E = {
filters: { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' },
sort: 'date',
view: 'grid',
tile: 150,
items: [],
total: 0,
sel: new Set(),
allFiltered: false,
anchor: -1,
curId: null,
facets: null,
expanded: new Set(),
rows: [],
height: 0,
rendered: new Map(),
reloadToken: 0,
lastReload: 0,
lastFound: -1,
wasEnriching: false,
status: null,
};
try {
E.view = localStorage.getItem('guara-view') || 'grid';
E.tile = +localStorage.getItem('guara-tile') || 150;
} catch { /* no storage */ }
host.innerHTML = `
`;
const $ = (s) => host.querySelector(s);
const root = $('.xp'), scroll = $('#xpScroll'), sizer = $('#xpSizer'), nav = $('#xpNav'), insp = $('#xpInsp'), actions = $('#xpActions');
$('#xpSort').value = E.sort;
$('#xpSize').value = E.tile;
// ------------------------------------------------------------ data
const toQuery = () => {
const f = E.filters;
const q = {};
if (f.category) q.category = f.category;
if (f.year) q.year = f.year;
if (f.month) q.month = f.month;
if (f.undated) q.undated = true;
if (f.folder) q.folder = f.folder;
if (f.camera) q.camera = f.camera;
if (f.origin) q.origin = f.origin;
if (f.minConf) q.min_confidence = f.minConf;
if (f.gps) q.has_gps = true;
if (f.dedupe) q.dedupe = true;
if (f.bigOnly) q.min_size = 30 * 1024;
if (f.mp1) q.min_mp = 1;
if (f.query) q.query = f.query;
return q;
};
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
async function reload() {
const token = ++E.reloadToken;
E.lastReload = Date.now();
const base = { ...toQuery(), sort: E.sort };
let items = [], total = 0;
for (;;) {
let r;
try { r = await rpc('results', { job, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
if (token !== E.reloadToken) return;
items = items.concat(r.items);
total = r.total;
if (!r.items.length || items.length >= total || items.length >= 30000) break;
if (items.length === 2000) { E.items = items; E.total = total; relayout(); paintAll(); }
}
E.items = items;
E.total = total;
relayout();
paintAll();
}
async function loadFacets() {
try { E.facets = await rpc('facets', { job }); } catch { return; }
paintNav();
}
const reloadAll = () => { reload(); loadFacets(); };
// ------------------------------------------------------------ virtual layout
// Group by day; if there are too few items per day, group by month.
let groupBy = 'day';
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
const dayLabel = (k) => {
if (k === 'Sem data') return k;
return groupBy === 'day'
? 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(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
// Core metadata provides ISO 8601 dates; the active locale determines the order.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
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);
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
const T = E.tile;
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
const cap = tw >= 128 ? 42 : 0;
return { W, cols, tw, cap, rowH: tw + cap + GAP };
}
let G = geometry();
function relayout() {
G = geometry();
const grouped = E.view === 'grid' && (E.sort === 'date' || E.sort === 'date_asc');
if (grouped) {
groupBy = 'day';
const days = new Set(E.items.map((it) => dayKey(it.when)));
if (E.items.length / Math.max(1, days.size) < 2.5) groupBy = 'month';
}
const rows = [];
let y = PAD;
let cur = null, rowItems = [];
const flush = () => {
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
};
E.items.forEach((it, i) => {
if (grouped) {
const k = dayKey(it.when);
if (k !== cur) {
flush();
cur = k;
rows.push({ t: 'head', y, h: 40, key: k, start: i });
y += 40;
}
}
rowItems.push(i);
if (rowItems.length === G.cols) flush();
});
flush();
if (grouped) {
// count per group
for (let r = 0; r < rows.length; r++) {
if (rows[r].t !== 'head') continue;
let n = 0;
for (let i = rows[r].start; i < E.items.length && dayKey(E.items[i].when) === rows[r].key; i++) n++;
rows[r].count = n;
}
}
E.rows = rows;
E.height = y + PAD;
sizer.style.height = `${E.height}px`;
for (const el of E.rendered.values()) el.remove();
E.rendered.clear();
renderWindow();
}
function rowAt(top) {
let lo = 0, hi = E.rows.length - 1, ans = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (E.rows[mid].y + E.rows[mid].h < top) lo = mid + 1; else { ans = mid; hi = mid - 1; }
}
return ans;
}
function renderWindow() {
if (!E.rows.length) { for (const el of E.rendered.values()) el.remove(); E.rendered.clear(); return; }
const top = Math.max(0, scroll.scrollTop - 400), bottom = scroll.scrollTop + scroll.clientHeight + 400;
const a = rowAt(top);
let b = a;
while (b < E.rows.length && E.rows[b].y < bottom) b++;
for (const [ri, el] of E.rendered) {
if (ri < a || ri >= b) { el.remove(); E.rendered.delete(ri); }
}
const frag = document.createDocumentFragment();
for (let ri = a; ri < b; ri++) {
if (E.rendered.has(ri)) continue;
const row = E.rows[ri];
const el = document.createElement('div');
el.className = `xp-row ${row.t}`;
el.style.top = `${row.y}px`;
el.style.height = `${row.h}px`;
el.dataset.ri = ri;
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
E.rendered.set(ri, el);
frag.appendChild(el);
}
sizer.appendChild(frag);
}
// ------------------------------------------------------------ items
const isSel = (id) => E.allFiltered || E.sel.has(id);
function headHtml(row) {
return `${esc(dayLabel(row.key))}${row.count} ${row.count === 1 ? 'item' : 'itens'}
`;
}
function thumbSrc(it, px) {
const s = px <= 200 ? 256 : px <= 330 ? 384 : 640;
return thumbUrl(job, it.id, s);
}
function tileHtml(i) {
const it = E.items[i];
const [cl, cc] = CONF[it.confidence];
const on = isSel(it.id);
const [catName, catIco] = CAT[it.category] || CAT.other;
const media = it.category === 'image'
? `
`
: `${catIco}${esc((it.ext || '?').toUpperCase().slice(0, 5))}
`;
return `
${media}
${it.gps ? `${I.pin}` : ''}
${G.cap ? `
${esc(it.name)}${human(it.size)}${it.when != null ? ' · ' + esc(fmtDay(it.when)) : ''}
` : ''}
`;
}
function listHtml(i) {
const it = E.items[i];
const [cl, cc] = CONF[it.confidence];
const on = isSel(it.id);
const [, catIco] = CAT[it.category] || CAT.other;
const th = it.category === 'image' ? `
` : `${catIco}`;
return `
${th}
${esc(it.name)}${esc(it.dir || (it.origin === 'carved' ? 'Encontrado por assinatura' : '/'))}
${esc(fmtDate(it.when))}${human(it.size)}${cl}
`;
}
// failed images become a warning (file possibly damaged)
scroll.addEventListener('error', (e) => {
const img = e.target;
if (img.tagName !== 'IMG' || !img.dataset.th) return;
const wrap = img.closest('.xp-thumb, .xp-lth');
img.remove();
const offline = E.status && E.status.source === false;
wrap?.insertAdjacentHTML('afterbegin', offline
? '🖼IMG
'
: `${I.warn}?
`);
}, true);
// ------------------------------------------------------------ selection
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
function paintSel() {
const set = (el) => {
const id = +el.dataset.id;
el.setAttribute('aria-selected', String(isSel(id)));
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
};
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
paintActions();
}
function selectRange(a, b) {
const [lo, hi] = a < b ? [a, b] : [b, a];
for (let i = lo; i <= hi; i++) E.sel.add(E.items[i].id);
}
function setCursor(i, { extend = false, toggle = false, reveal = false } = {}) {
if (i < 0 || i >= E.items.length) return;
const it = E.items[i];
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(E.items.map((x) => x.id)); }
if (extend && E.anchor >= 0) {
E.sel = new Set();
selectRange(E.anchor, i);
} else if (toggle) {
if (E.sel.has(it.id)) E.sel.delete(it.id); else E.sel.add(it.id);
E.anchor = i;
} else {
E.sel = new Set([it.id]);
E.anchor = i;
}
E.curId = it.id;
if (reveal) revealIndex(i);
paintSel();
schedInspector();
if (matchMedia('(max-width: 1100px)').matches && !extend && !toggle) root.classList.add('show-insp');
}
function revealIndex(i) {
const ri = E.rows.findIndex((r) => r.t === 'row' && r.idx.includes(i));
if (ri < 0) return;
const r = E.rows[ri];
if (r.y < scroll.scrollTop + 4) scroll.scrollTop = r.y - 8;
else if (r.y + r.h > scroll.scrollTop + scroll.clientHeight) scroll.scrollTop = r.y + r.h - scroll.clientHeight + 8;
renderWindow();
}
scroll.addEventListener('scroll', () => renderWindow(), { passive: true });
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
scroll.addEventListener('click', (e) => {
const hs = e.target.closest('[data-hsel]');
if (hs) {
const start = +hs.dataset.hsel, n = +hs.dataset.n;
if (E.allFiltered) { E.allFiltered = false; E.sel = new Set(); }
for (let i = start; i < start + n; i++) E.sel.add(E.items[i].id);
paintSel();
return;
}
const el = e.target.closest('.xp-tile, .xp-lrow');
if (!el) return;
const i = +el.dataset.i;
if (e.target.closest('[data-chk]')) setCursor(i, { toggle: true });
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
});
scroll.addEventListener('dblclick', (e) => {
const el = e.target.closest('.xp-tile, .xp-lrow');
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
});
scroll.addEventListener('keydown', (e) => {
if (!E.items.length) return;
let i = idxOf(E.curId);
if (i < 0) i = 0;
const cols = G.cols;
const move = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, PageDown: cols * 4, PageUp: -cols * 4 }[e.key];
if (move != null) {
e.preventDefault();
setCursor(Math.max(0, Math.min(E.items.length - 1, idxOf(E.curId) < 0 ? 0 : i + move)), { extend: e.shiftKey, reveal: true });
} else if (e.key === 'Home') { e.preventDefault(); setCursor(0, { extend: e.shiftKey, reveal: true }); }
else if (e.key === 'End') { e.preventDefault(); setCursor(E.items.length - 1, { extend: e.shiftKey, reveal: true }); }
else if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (E.curId == null) setCursor(0); openLightbox(idxOf(E.curId)); }
else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); E.allFiltered = true; paintSel(); }
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
});
// ------------------------------------------------------------ action bar
function paintActions() {
const n = selCount();
const html = n
? `${n.toLocaleString(LOC())} selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}
${!E.allFiltered && E.total > n ? `` : ''}
`
: `${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; }
}
actions.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
else if (a === 'all') { E.allFiltered = true; paintSel(); }
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'recall') onRecover({ filter: toQuery() });
});
// ------------------------------------------------------------ sidebar navigation
function paintNav() {
const f = E.facets;
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('');
const years = f.years.map((y) => {
const open = E.expanded.has('y' + y.year) || F.year === y.year;
return node(String(y.year), y.count, `data-f="year" data-v="${y.year}"`, F.year === y.year && !F.month, 0,
`${I.chev}`)
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
const folders = f.folders.filter((d) => {
// only show if all ancestors are expanded
const parts = d.path.split('/');
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
return true;
}).map((d) => {
const hasKids = f.folders.some((o) => o.path.startsWith(d.path + '/'));
const open = E.expanded.has('d' + d.path) || (F.folder || '').startsWith(d.path + '/');
return node(esc(d.name), d.count, `data-f="folder" data-v="${esc(d.path)}"`, F.folder === d.path, d.depth,
hasKids ? `${I.chev}` : '');
}).join('')
+ (f.root ? node('/ (raiz)', f.root, 'data-f="folder" data-v="@raiz"', F.folder === '@raiz') : '')
+ (f.carved ? node('Por assinatura', f.carved, 'data-f="folder" data-v="@carved"', F.folder === '@carved') : '');
const cams = f.cameras.map((c) => node(esc(c.name), c.count, `data-f="camera" data-v="${esc(c.name)}"`, F.camera === c.name)).join('');
const conf = ['high', 'medium', 'low'].map((k) => node(`Confiança ${CONF[k][0]}`, f.confidence[k], `data-f="minConf" data-v="${k}"`, F.minConf === k)).join('');
const extra = (f.gps ? node('Com localização (GPS)', f.gps, 'data-f="gps" data-v="1"', F.gps) : '')
+ (f.small && f.small < f.total ? node('Ocultar arquivos pequenos (menos de 30 KB)', f.total - f.small, 'data-f="bigOnly" data-v="1"', F.bigOnly) : '')
+ (f.mp1 ? node('Mais de 1 megapixel', f.mp1, 'data-f="mp1" data-v="1"', F.mp1) : '')
+ (f.duplicates ? node('Ocultar duplicadas', f.duplicates, 'data-f="dedupe" data-v="1"', F.dedupe) : '');
const html = `${node('Tudo', f.total, 'data-f="all"', !anyFilter())}
`
+ sec('Tipos', cats) + sec('Datas', years) + sec('Pastas', folders) + sec('Câmeras', cams) + sec('Confiança', conf) + sec('Outros', extra);
if (nav._h !== html) { nav._h = html; const st = nav.scrollTop; nav.innerHTML = html; nav.scrollTop = st; }
}
nav.addEventListener('click', (e) => {
const tw = e.target.closest('[data-tw]');
if (tw) {
const k = tw.dataset.tw;
if (E.expanded.has(k)) E.expanded.delete(k); else E.expanded.add(k);
nav._h = null; paintNav();
e.stopPropagation();
return;
}
const b = e.target.closest('.xp-node');
if (!b) return;
const F = E.filters;
const f = b.dataset.f, v = b.dataset.v;
if (f === 'all') clearFilters(false);
else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); }
else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; }
else if (f === 'undated') { F.undated = !F.undated; F.year = F.month = null; }
else if (f === 'gps') F.gps = !F.gps;
else if (f === 'dedupe') F.dedupe = !F.dedupe;
else if (f === 'bigOnly') F.bigOnly = !F.bigOnly;
else if (f === 'mp1') F.mp1 = !F.mp1;
else F[f] = F[f] === v ? null : v;
if (f === 'folder' && F.folder && !F.folder.startsWith('@')) E.expanded.add('d' + F.folder);
nav._h = null;
root.classList.remove('show-nav');
changed();
});
function clearFilters(refresh = true) {
E.filters = { category: null, year: null, month: null, undated: false, folder: null, camera: null, origin: null, minConf: null, gps: false, dedupe: false, bigOnly: false, mp1: false, query: '' };
$('#xpQ').value = '';
if (refresh) changed(); else changed();
}
function changed() {
E.sel = new Set(); E.allFiltered = false; E.curId = null; E.anchor = -1;
scroll.scrollTop = 0;
paintCrumb(); paintChips(); paintNav(); paintInspector();
reload();
}
function paintCrumb() {
const F = E.filters, parts = [];
if (F.category) parts.push(CAT[F.category][0]);
if (F.year) parts.push(String(F.year));
if (F.month) parts.push(MONTHS[F.month - 1]);
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(LOC())}`;
}
function paintChips() {
const F = E.filters, chips = [];
const add = (label, key) => chips.push(``);
if (F.category) add(CAT[F.category][0], 'category');
if (F.year) add(String(F.year), 'year');
if (F.month) add(MONTHS[F.month - 1], 'month');
if (F.undated) add('Sem data', 'undated');
if (F.folder) add(F.folder === '@carved' ? 'Por assinatura' : F.folder === '@raiz' ? 'Raiz' : esc(F.folder), 'folder');
if (F.camera) add(esc(F.camera), 'camera');
if (F.minConf) add(`Confiança ${CONF[F.minConf][0]}`, 'minConf');
if (F.gps) add('Com GPS', 'gps');
if (F.dedupe) add('Sem duplicadas', 'dedupe');
if (F.bigOnly) add('Sem arquivos pequenos', 'bigOnly');
if (F.mp1) add('Mais de 1 MP', 'mp1');
if (F.query) add(`“${esc(F.query)}”`, 'query');
$('#xpChips').innerHTML = chips.length ? chips.join('') + '' : '';
}
$('#xpChips').addEventListener('click', (e) => {
const k = e.target.closest('[data-x]')?.dataset.x;
if (!k) return;
if (k === 'all') return clearFilters();
E.filters[k] = k === 'query' ? '' : k === 'undated' || k === 'gps' || k === 'dedupe' || k === 'bigOnly' || k === 'mp1' ? false : null;
if (k === 'year') E.filters.month = null;
if (k === 'query') $('#xpQ').value = '';
nav._h = null;
changed();
});
// ------------------------------------------------------------ top bar
let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
const paintView = () => {
root.dataset.view = E.view;
host.querySelectorAll('[data-view]').forEach((b) => { if (b.tagName === 'BUTTON') b.setAttribute('aria-pressed', String(b.dataset.view === E.view)); });
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
};
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
E.view = b.dataset.view;
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
paintView(); relayout();
}));
$('#xpSize').addEventListener('input', (e) => {
E.tile = +e.target.value;
try { localStorage.setItem('guara-tile', String(E.tile)); } catch { /* */ }
relayout();
});
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
// ------------------------------------------------------------ inspector panel
let inspT, stopPv = null, metaToken = 0;
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
function timesHtml(it) {
const rows = [['Tirada em', it.taken], ['Modificado', it.modified], ['Criado', it.created], ['Acessado', it.accessed]].filter(([, v]) => v != null);
return rows.map(([k, v]) => `${k}${esc(fmtDate(v))}`).join('');
}
function paintInspector() {
stopPv?.(); stopPv = null;
const n = selCount();
const it = E.items[idxOf(E.curId)];
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(LOC())} itens selecionados${sizeSum != null ? `
${human(sizeSum)} no total` : ''}
`;
return;
}
if (!it) {
insp.innerHTML = `${close}Selecione um arquivo para pré-visualizar.
Setas navegam, Espaço abre em tela cheia.
`;
return;
}
const [cl, cc] = CONF[it.confidence];
const url = kindOf(it) === 'image' ? thumbUrl(job, it.id, 640) : fileUrl(job, it.id);
insp.innerHTML = `${close}
${esc(it.name)}${cl}
${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}
${it.note ? `${esc(it.note)}
` : ''}
- 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, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicking the thumbnail opens the large modal
$('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id));
});
const my = ++metaToken;
rpc('metadata', { job, id: it.id }).then((m) => {
if (my !== metaToken || !$('#xpMeta')) return;
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(fmtMeta(v))}
`).join('')}
`
+ (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(() => {});
}
/** Coordinates and, if enabled by the user, a map (3x3 OpenStreetMap tiles around the point). */
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 = ``;
}
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));
else if (a === 'rec1') onRecover({ ids: [E.curId] });
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
else if (a === 'close') root.classList.remove('show-insp');
});
// ------------------------------------------------------------ fullscreen
let stopLb = null;
function openLightbox(i) {
const it = E.items[i];
if (!it) return;
const d = document.querySelector('#lightbox');
const [cl] = CONF[it.confidence];
d.innerHTML = `
${esc(it.name)}${esc(KIND_LABEL[kindOf(it)])} · ${human(it.size)}${it.when != null ? ' · ' + esc(fmtDate(it.when)) : ''} · confiança ${cl} · ${i + 1} de ${E.items.length}
`;
stopLb?.();
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);
d.querySelector('#lbClose').onclick = () => d.close();
d.querySelector('#lbRec').onclick = () => { d.close(); onRecover({ ids: [it.id] }); };
d.onclose = () => { stopLb?.(); stopLb = null; scroll.focus({ preventScroll: true }); };
d.onkeydown = (e) => { if (e.key === 'ArrowLeft') go(-1); else if (e.key === 'ArrowRight') go(1); };
if (!d.open) d.showModal();
}
// ------------------------------------------------------------ main paint
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
paintView(); paintCrumb(); paintActions(); paintInspector();
reloadAll();
return {
/** Called on every scan status update. */
onStatus(st) {
E.status = st;
const enriching = !!st.enriching;
const now = Date.now();
const foundChanged = st.found !== E.lastFound;
const justDone = st.status !== 'running' && E.running;
E.running = st.status === 'running';
if ((foundChanged && now - E.lastReload > 1500) || justDone || (E.wasEnriching && !enriching) || (enriching && now - E.lastReload > 2500)) {
E.lastFound = st.found;
reloadAll();
}
E.wasEnriching = enriching;
},
query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
/** Language changed in Settings: rebuild JS-rendered elements (dates, months, labels). */
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
focus() { scroll.focus({ preventScroll: true }); },
};
}