// 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) => `${d}`; 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 = `
${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)); 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}
${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, 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 }); }, }; }