feat(ui): browse and recover by folder
A Folders mode next to grid and list shows one level at a time: subfolders first with their file count and size, then the files, under a clickable breadcrumb. Folders and files are marked with tri-state boxes that persist while navigating; the bar shows the exact total from the core, and the selection is recovered relative to each marked folder. The sidebar folder tree navigates in this mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca677a8bc1
commit
00aceecf70
7 files changed
+317
-15
No files matched your search
@@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
|
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
|
||||||
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
|
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
|
||||||
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
|
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
|
||||||
|
/* folder browsing */
|
||||||
|
.xp-path { display: flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; }
|
||||||
|
.xp-cpart { border: 0; background: none; color: var(--text-2); font: inherit; font-size: 14px; padding: 4px 6px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
|
||||||
|
.xp-cpart:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.xp-cpart:last-child { color: var(--text); font-weight: 600; }
|
||||||
|
.xp-csep { color: var(--text-3); }
|
||||||
|
.xp-lth .xp-fico { color: var(--accent); background: var(--accent-soft); border-radius: 8px; }
|
||||||
|
.xp-dir .xp-lnm b { font-weight: 600; }
|
||||||
|
.xp-dir[data-state="mixed"] .xp-chk { opacity: 1; background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round'%3E%3Cpath d='M7 12h10'/%3E%3C/svg%3E") center / 14px no-repeat; }
|
||||||
|
.xp-lrow[data-cur="1"] { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
.xp-folder-insp { display: grid; gap: 14px; padding: 20px 4px; }
|
||||||
|
.xp-folder-insp h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
|
||||||
|
.xp-fbig { width: 64px; height: 64px; display: grid; place-items: center; border-radius: 16px; color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.xp-fbig svg { width: 34px; height: 34px; }
|
||||||
|
.xp-folder-insp .row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
|
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
|
||||||
.xp-insp-x { display: none; align-self: flex-end; }
|
.xp-insp-x { display: none; align-self: flex-end; }
|
||||||
.xp-veil { display: none; }
|
.xp-veil { display: none; }
|
||||||
|
|||||||
+9
-3
@@ -687,19 +687,25 @@ async function onProgressClick(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function recover(sel) {
|
async function recover(sel) {
|
||||||
|
// From folder browsing: each marked folder is recreated from itself down
|
||||||
|
// (a/b/Projeto -> Projeto/...), so there is no layout to choose.
|
||||||
|
const bySelection = !!sel.selection;
|
||||||
const f = await askForm({
|
const f = await askForm({
|
||||||
title: 'Recuperar para…',
|
title: 'Recuperar para…',
|
||||||
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
|
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>'
|
||||||
|
+ (bySelection ? ' <span class="sub">Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.</span>' : '')
|
||||||
|
+ (bySelection && sel.filtered ? ' <span class="sub"><b>Atenção:</b> pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.</span>' : ''),
|
||||||
ok: 'Recuperar',
|
ok: 'Recuperar',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
|
||||||
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
|
...(bySelection ? [] : [{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!f || !f.dest) return;
|
if (!f || !f.dest) return;
|
||||||
const dest = f.dest;
|
const dest = f.dest;
|
||||||
|
const { filtered, ...target } = sel;
|
||||||
try {
|
try {
|
||||||
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
|
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout || 'folders', ...target });
|
||||||
watchAux(job, 'Recuperando arquivos', (st) => {
|
watchAux(job, 'Recuperando arquivos', (st) => {
|
||||||
const r = st.report || {};
|
const r = st.report || {};
|
||||||
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
|
||||||
|
|||||||
+205
-7
@@ -28,6 +28,8 @@ const I = {
|
|||||||
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
|
||||||
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
|
||||||
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
|
||||||
|
folder: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
|
||||||
|
folderBig: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>', 22),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createExplorer(deps) {
|
export function createExplorer(deps) {
|
||||||
@@ -62,6 +64,14 @@ export function createExplorer(deps) {
|
|||||||
lastFound: -1,
|
lastFound: -1,
|
||||||
wasEnriching: false,
|
wasEnriching: false,
|
||||||
status: null,
|
status: null,
|
||||||
|
// folder browsing: current folder ('' = root, '@carved' = found by
|
||||||
|
// signature), its subfolders, and a selection of its own that keeps
|
||||||
|
// marked and unmarked folders and files across levels
|
||||||
|
path: '',
|
||||||
|
dirs: [],
|
||||||
|
curDir: null,
|
||||||
|
fs: { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() },
|
||||||
|
fsCount: null,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
E.view = localStorage.getItem('guara-view') || 'grid';
|
E.view = localStorage.getItem('guara-view') || 'grid';
|
||||||
@@ -74,7 +84,7 @@ export function createExplorer(deps) {
|
|||||||
<div class="xp-crumb" id="xpCrumb"></div>
|
<div class="xp-crumb" id="xpCrumb"></div>
|
||||||
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
|
||||||
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
|
||||||
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
|
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button><button data-view="folders" aria-label="Pastas" title="Navegar pelas pastas">${I.folder}</button></div>
|
||||||
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
|
||||||
</div>
|
</div>
|
||||||
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
|
||||||
@@ -112,7 +122,10 @@ export function createExplorer(deps) {
|
|||||||
};
|
};
|
||||||
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
|
||||||
|
|
||||||
|
const folderMode = () => E.view === 'folders';
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
|
if (folderMode()) return reloadFolder();
|
||||||
const token = ++E.reloadToken;
|
const token = ++E.reloadToken;
|
||||||
E.lastReload = Date.now();
|
E.lastReload = Date.now();
|
||||||
const base = { ...toQuery(), sort: E.sort };
|
const base = { ...toQuery(), sort: E.sort };
|
||||||
@@ -132,6 +145,29 @@ export function createExplorer(deps) {
|
|||||||
paintAll();
|
paintAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reloadFolder() {
|
||||||
|
const token = ++E.reloadToken;
|
||||||
|
E.lastReload = Date.now();
|
||||||
|
const { folder, ...filters } = toQuery();
|
||||||
|
const base = { ...filters, sort: E.sort === 'date' || E.sort === 'date_asc' ? E.sort : E.sort || 'name' };
|
||||||
|
let items = [], total = 0, dirs = null;
|
||||||
|
for (;;) {
|
||||||
|
let r;
|
||||||
|
try { r = await rpc('browse', { job, path: E.path, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
|
||||||
|
if (token !== E.reloadToken) return;
|
||||||
|
if (!dirs) dirs = r.folders;
|
||||||
|
items = items.concat(r.files);
|
||||||
|
total = r.total_files;
|
||||||
|
if (!r.files.length || items.length >= total || items.length >= 30000) break;
|
||||||
|
}
|
||||||
|
E.dirs = dirs || [];
|
||||||
|
E.items = items;
|
||||||
|
E.total = total;
|
||||||
|
paintCrumb();
|
||||||
|
relayout();
|
||||||
|
paintAll();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFacets() {
|
async function loadFacets() {
|
||||||
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
try { E.facets = await rpc('facets', { job }); } catch { return; }
|
||||||
paintNav();
|
paintNav();
|
||||||
@@ -157,7 +193,7 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
function geometry() {
|
function geometry() {
|
||||||
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
const W = Math.max(200, scroll.clientWidth - PAD * 2);
|
||||||
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
|
if (E.view === 'list' || E.view === 'folders') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
|
||||||
const T = E.tile;
|
const T = E.tile;
|
||||||
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
|
||||||
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
|
||||||
@@ -176,6 +212,9 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
const rows = [];
|
const rows = [];
|
||||||
let y = PAD;
|
let y = PAD;
|
||||||
|
if (folderMode()) {
|
||||||
|
E.dirs.forEach((_, d) => { rows.push({ t: 'dir', y, h: G.rowH, d }); y += G.rowH; });
|
||||||
|
}
|
||||||
let cur = null, rowItems = [];
|
let cur = null, rowItems = [];
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
|
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
|
||||||
@@ -238,7 +277,7 @@ export function createExplorer(deps) {
|
|||||||
el.style.top = `${row.y}px`;
|
el.style.top = `${row.y}px`;
|
||||||
el.style.height = `${row.h}px`;
|
el.style.height = `${row.h}px`;
|
||||||
el.dataset.ri = ri;
|
el.dataset.ri = ri;
|
||||||
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
|
el.innerHTML = row.t === 'head' ? headHtml(row) : row.t === 'dir' ? dirHtml(row.d) : row.idx.map((i) => (E.view === 'grid' ? tileHtml(i) : listHtml(i))).join('');
|
||||||
E.rendered.set(ri, el);
|
E.rendered.set(ri, el);
|
||||||
frag.appendChild(el);
|
frag.appendChild(el);
|
||||||
}
|
}
|
||||||
@@ -246,7 +285,82 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ items
|
// ------------------------------------------------------------ items
|
||||||
const isSel = (id) => E.allFiltered || E.sel.has(id);
|
// `p` is inside `d` (or is `d`); '' holds everything, '@carved' only itself.
|
||||||
|
const under = (p, d) => d === '' || p === d || (d !== '@carved' && p.startsWith(d + '/'));
|
||||||
|
// Deepest marked/unmarked folder holding `p` decides, as in the core.
|
||||||
|
const covered = (p) => {
|
||||||
|
let best = null;
|
||||||
|
for (const d of E.fs.folders) if (under(p, d) && (best == null || d.length > best[0].length)) best = [d, true];
|
||||||
|
for (const d of E.fs.xfolders) if (under(p, d) && (best == null || d.length >= best[0].length)) best = [d, false];
|
||||||
|
return !!best && best[1];
|
||||||
|
};
|
||||||
|
const dirOf = (it) => (it.origin === 'carved' ? '@carved' : (it.dir || '').replace(/^\/+|\/+$/g, ''));
|
||||||
|
const fileOn = (it) => !E.fs.xfiles.has(it.id) && (E.fs.files.has(it.id) || covered(dirOf(it)));
|
||||||
|
// 'on', 'off' or 'mixed' for a folder row
|
||||||
|
const dirState = (d) => {
|
||||||
|
const on = covered(d);
|
||||||
|
const inside = (p) => p !== d && under(p, d);
|
||||||
|
const marks = on
|
||||||
|
? [...E.fs.xfolders].some(inside) || [...E.fs.xfiles.values()].some((p) => under(p, d)) || [...E.fs.folders].some(inside)
|
||||||
|
: [...E.fs.folders].some(inside) || [...E.fs.files.values()].some((p) => under(p, d));
|
||||||
|
return marks ? 'mixed' : on ? 'on' : 'off';
|
||||||
|
};
|
||||||
|
const fsEmpty = () => !E.fs.folders.size && !E.fs.files.size;
|
||||||
|
const dropInside = (d) => {
|
||||||
|
for (const k of [...E.fs.folders]) if (under(k, d)) E.fs.folders.delete(k);
|
||||||
|
for (const k of [...E.fs.xfolders]) if (under(k, d)) E.fs.xfolders.delete(k);
|
||||||
|
for (const [id, p] of [...E.fs.files]) if (under(p, d)) E.fs.files.delete(id);
|
||||||
|
for (const [id, p] of [...E.fs.xfiles]) if (under(p, d)) E.fs.xfiles.delete(id);
|
||||||
|
};
|
||||||
|
// Like a file manager's tri-state box: a fully marked folder is unmarked;
|
||||||
|
// an unmarked or partly marked one becomes fully marked.
|
||||||
|
function toggleDir(d) {
|
||||||
|
const unmark = dirState(d) === 'on';
|
||||||
|
dropInside(d);
|
||||||
|
if (unmark) { if (covered(d)) E.fs.xfolders.add(d); } else if (!covered(d)) E.fs.folders.add(d);
|
||||||
|
fsChanged();
|
||||||
|
}
|
||||||
|
function toggleFile(it) {
|
||||||
|
const p = dirOf(it);
|
||||||
|
if (fileOn(it)) { E.fs.files.delete(it.id); if (covered(p)) E.fs.xfiles.set(it.id, p); } else { E.fs.xfiles.delete(it.id); if (!covered(p)) E.fs.files.set(it.id, p); }
|
||||||
|
fsChanged();
|
||||||
|
}
|
||||||
|
const fsSelection = () => ({
|
||||||
|
folders: [...E.fs.folders], ids: [...E.fs.files.keys()], exclude: [...E.fs.xfiles.keys()], exclude_folders: [...E.fs.xfolders],
|
||||||
|
});
|
||||||
|
let countT;
|
||||||
|
function fsChanged() {
|
||||||
|
paintSel();
|
||||||
|
clearTimeout(countT);
|
||||||
|
if (fsEmpty()) { E.fsCount = null; paintActions(); return; }
|
||||||
|
countT = setTimeout(async () => {
|
||||||
|
try { E.fsCount = await rpc('selection_count', { job, selection: fsSelection() }); } catch { E.fsCount = null; }
|
||||||
|
paintActions();
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
function goTo(path) {
|
||||||
|
E.path = path; E.curDir = null; E.curId = null;
|
||||||
|
scroll.scrollTop = 0;
|
||||||
|
paintInspector();
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
const parentOf = (p) => (p === '@carved' || !p.includes('/') ? '' : p.slice(0, p.lastIndexOf('/')));
|
||||||
|
const dirLabel = (p) => (p === '' ? 'Raiz' : p === '@carved' ? 'Por assinatura' : p.split('/').pop());
|
||||||
|
|
||||||
|
function dirHtml(d) {
|
||||||
|
const it = E.dirs[d];
|
||||||
|
const st = dirState(it.path);
|
||||||
|
return `<div class="xp-lrow xp-dir" role="option" data-dir="${d}" aria-selected="${st === 'on'}" data-state="${st}" ${E.curDir === it.path ? 'data-cur="1"' : ''} title="${esc(it.path === '@carved' ? 'Arquivos encontrados por assinatura, sem pasta conhecida' : it.path)}">
|
||||||
|
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth"><span class="xp-lico xp-fico">${I.folderBig}</span></div>
|
||||||
|
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${it.files.toLocaleString(LOC())} ${it.files === 1 ? 'arquivo' : 'arquivos'}</small></div>
|
||||||
|
<span class="xp-lcol"></span><span class="xp-lcol num">${human(it.bytes)}</span><span></span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSel = (id) => {
|
||||||
|
if (folderMode()) { const it = E.items[idxOf(id)]; return !!it && fileOn(it); }
|
||||||
|
return E.allFiltered || E.sel.has(id);
|
||||||
|
};
|
||||||
|
|
||||||
function headHtml(row) {
|
function headHtml(row) {
|
||||||
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
|
||||||
@@ -310,7 +424,15 @@ export function createExplorer(deps) {
|
|||||||
el.setAttribute('aria-selected', String(isSel(id)));
|
el.setAttribute('aria-selected', String(isSel(id)));
|
||||||
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
|
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
|
||||||
};
|
};
|
||||||
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
|
scroll.querySelectorAll('.xp-tile, .xp-lrow:not(.xp-dir)').forEach(set);
|
||||||
|
scroll.querySelectorAll('.xp-dir').forEach((el) => {
|
||||||
|
const d = E.dirs[+el.dataset.dir];
|
||||||
|
if (!d) return;
|
||||||
|
const st = dirState(d.path);
|
||||||
|
el.dataset.state = st;
|
||||||
|
el.setAttribute('aria-selected', String(st === 'on'));
|
||||||
|
if (E.curDir === d.path) el.dataset.cur = '1'; else delete el.dataset.cur;
|
||||||
|
});
|
||||||
paintActions();
|
paintActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +475,21 @@ export function createExplorer(deps) {
|
|||||||
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
|
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
|
||||||
|
|
||||||
scroll.addEventListener('click', (e) => {
|
scroll.addEventListener('click', (e) => {
|
||||||
|
const dr = e.target.closest('.xp-dir');
|
||||||
|
if (dr) {
|
||||||
|
const d = E.dirs[+dr.dataset.dir];
|
||||||
|
if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleDir(d.path);
|
||||||
|
else { E.curDir = d.path; E.curId = null; paintSel(); paintInspector(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (folderMode()) {
|
||||||
|
const el = e.target.closest('.xp-lrow');
|
||||||
|
if (!el) return;
|
||||||
|
const it = E.items[+el.dataset.i];
|
||||||
|
if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleFile(it);
|
||||||
|
else { E.curDir = null; E.curId = it.id; paintSel(); schedInspector(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hs = e.target.closest('[data-hsel]');
|
const hs = e.target.closest('[data-hsel]');
|
||||||
if (hs) {
|
if (hs) {
|
||||||
const start = +hs.dataset.hsel, n = +hs.dataset.n;
|
const start = +hs.dataset.hsel, n = +hs.dataset.n;
|
||||||
@@ -368,10 +505,24 @@ export function createExplorer(deps) {
|
|||||||
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
|
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
|
||||||
});
|
});
|
||||||
scroll.addEventListener('dblclick', (e) => {
|
scroll.addEventListener('dblclick', (e) => {
|
||||||
|
const dr = e.target.closest('.xp-dir');
|
||||||
|
if (dr) { if (!e.target.closest('[data-chk]')) goTo(E.dirs[+dr.dataset.dir].path); return; }
|
||||||
|
if (folderMode()) {
|
||||||
|
const el = e.target.closest('.xp-lrow');
|
||||||
|
if (el && !e.target.closest('[data-chk]')) openLightbox(+el.dataset.i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const el = e.target.closest('.xp-tile, .xp-lrow');
|
const el = e.target.closest('.xp-tile, .xp-lrow');
|
||||||
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
|
||||||
});
|
});
|
||||||
scroll.addEventListener('keydown', (e) => {
|
scroll.addEventListener('keydown', (e) => {
|
||||||
|
if (folderMode()) {
|
||||||
|
if ((e.key === 'Backspace' || (e.altKey && e.key === 'ArrowUp')) && E.path !== '') { e.preventDefault(); goTo(parentOf(E.path)); return; }
|
||||||
|
if (e.key === 'Enter' && E.curDir != null) { e.preventDefault(); goTo(E.curDir); return; }
|
||||||
|
if (e.key === ' ' && E.curDir != null) { e.preventDefault(); toggleDir(E.curDir); return; }
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); toggleDir(E.path); return; }
|
||||||
|
if (e.key === 'Escape') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; }
|
||||||
|
}
|
||||||
if (!E.items.length) return;
|
if (!E.items.length) return;
|
||||||
let i = idxOf(E.curId);
|
let i = idxOf(E.curId);
|
||||||
if (i < 0) i = 0;
|
if (i < 0) i = 0;
|
||||||
@@ -389,6 +540,19 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
// ------------------------------------------------------------ action bar
|
// ------------------------------------------------------------ action bar
|
||||||
function paintActions() {
|
function paintActions() {
|
||||||
|
if (folderMode()) {
|
||||||
|
const c = E.fsCount;
|
||||||
|
const here = dirState(E.path);
|
||||||
|
const html = !fsEmpty()
|
||||||
|
? `<span class="info">${c ? `<b>${c.files.toLocaleString(LOC())}</b> ${c.files === 1 ? 'arquivo' : 'arquivos'} · ${human(c.bytes)}` : 'Calculando seleção…'}</span>
|
||||||
|
<button class="btn sm" data-a="clear">Limpar seleção</button>
|
||||||
|
${here !== 'on' ? `<button class="btn sm" data-a="here">Marcar esta pasta</button>` : ''}
|
||||||
|
<button class="btn primary" data-a="frec" ${c && c.files ? '' : 'disabled'}>Recuperar seleção</button>`
|
||||||
|
: `<span class="info">${E.dirs.length.toLocaleString(LOC())} ${E.dirs.length === 1 ? 'pasta' : 'pastas'} · ${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'arquivo' : 'arquivos'} aqui</span>
|
||||||
|
${E.dirs.length || E.total ? '<button class="btn primary" data-a="here">Marcar esta pasta</button>' : ''}`;
|
||||||
|
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const n = selCount();
|
const n = selCount();
|
||||||
const html = n
|
const html = n
|
||||||
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
|
||||||
@@ -401,6 +565,9 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
actions.addEventListener('click', (e) => {
|
actions.addEventListener('click', (e) => {
|
||||||
const a = e.target.closest('[data-a]')?.dataset.a;
|
const a = e.target.closest('[data-a]')?.dataset.a;
|
||||||
|
if (folderMode() && a === 'clear') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; }
|
||||||
|
if (a === 'here') { if (dirState(E.path) !== 'on') { dropInside(E.path); if (!covered(E.path)) E.fs.folders.add(E.path); fsChanged(); } return; }
|
||||||
|
if (a === 'frec') { onRecover({ selection: fsSelection(), filtered: anyFilter() }); return; }
|
||||||
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
|
||||||
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
else if (a === 'all') { E.allFiltered = true; paintSel(); }
|
||||||
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
||||||
@@ -461,6 +628,12 @@ export function createExplorer(deps) {
|
|||||||
if (!b) return;
|
if (!b) return;
|
||||||
const F = E.filters;
|
const F = E.filters;
|
||||||
const f = b.dataset.f, v = b.dataset.v;
|
const f = b.dataset.f, v = b.dataset.v;
|
||||||
|
if (folderMode() && f === 'folder') {
|
||||||
|
if (!v.startsWith('@')) E.expanded.add('d' + v);
|
||||||
|
nav._h = null; paintNav();
|
||||||
|
root.classList.remove('show-nav');
|
||||||
|
return goTo(v === '@raiz' ? '' : v);
|
||||||
|
}
|
||||||
if (f === 'all') clearFilters(false);
|
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 === '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 === '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; }
|
||||||
@@ -490,6 +663,12 @@ export function createExplorer(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paintCrumb() {
|
function paintCrumb() {
|
||||||
|
if (folderMode()) {
|
||||||
|
const parts = E.path === '' ? [] : E.path === '@carved' ? ['@carved'] : E.path.split('/').map((_, i, a) => a.slice(0, i + 1).join('/'));
|
||||||
|
const link = (p, label) => `<button class="xp-cpart" data-go="${esc(p)}">${esc(label)}</button>`;
|
||||||
|
$('#xpCrumb').innerHTML = `<nav class="xp-path" aria-label="Pasta atual">${[link('', 'Raiz'), ...parts.map((p) => link(p, dirLabel(p)))].join('<span class="xp-csep">›</span>')}</nav>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const F = E.filters, parts = [];
|
const F = E.filters, parts = [];
|
||||||
if (F.category) parts.push(CAT[F.category][0]);
|
if (F.category) parts.push(CAT[F.category][0]);
|
||||||
if (F.year) parts.push(String(F.year));
|
if (F.year) parts.push(String(F.year));
|
||||||
@@ -528,6 +707,11 @@ export function createExplorer(deps) {
|
|||||||
changed();
|
changed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#xpCrumb').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('[data-go]');
|
||||||
|
if (b && folderMode()) goTo(b.dataset.go);
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------ top bar
|
// ------------------------------------------------------------ top bar
|
||||||
let qt;
|
let qt;
|
||||||
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
|
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
|
||||||
@@ -538,9 +722,11 @@ export function createExplorer(deps) {
|
|||||||
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
|
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
|
||||||
};
|
};
|
||||||
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
|
||||||
|
const was = folderMode();
|
||||||
E.view = b.dataset.view;
|
E.view = b.dataset.view;
|
||||||
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
|
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
|
||||||
paintView(); relayout();
|
paintView();
|
||||||
|
if (was !== folderMode()) { E.curId = null; E.curDir = null; E.dirs = []; paintCrumb(); paintInspector(); actions._h = null; reload(); } else relayout();
|
||||||
}));
|
}));
|
||||||
$('#xpSize').addEventListener('input', (e) => {
|
$('#xpSize').addEventListener('input', (e) => {
|
||||||
E.tile = +e.target.value;
|
E.tile = +e.target.value;
|
||||||
@@ -561,7 +747,17 @@ export function createExplorer(deps) {
|
|||||||
|
|
||||||
function paintInspector() {
|
function paintInspector() {
|
||||||
stopPv?.(); stopPv = null;
|
stopPv?.(); stopPv = null;
|
||||||
const n = selCount();
|
if (folderMode() && E.curDir != null) {
|
||||||
|
const d = E.dirs.find((x) => x.path === E.curDir);
|
||||||
|
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||||
|
if (d) {
|
||||||
|
insp.innerHTML = `${close}<div class="xp-folder-insp"><div class="xp-fbig">${I.folderBig}</div><h3>${esc(d.name)}</h3>
|
||||||
|
<dl class="kv"><dt>Caminho</dt><dd>${esc(d.path === '@carved' ? 'Encontrados por assinatura (sem pasta conhecida)' : d.path)}</dd><dt>Arquivos</dt><dd>${d.files.toLocaleString(LOC())}</dd><dt>Tamanho</dt><dd>${human(d.bytes)}</dd></dl>
|
||||||
|
<div class="row"><button class="btn" data-i="open">Abrir pasta</button><button class="btn primary" data-i="mark">${dirState(d.path) === 'on' ? 'Desmarcar pasta' : 'Marcar pasta inteira'}</button></div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const n = folderMode() ? 0 : selCount();
|
||||||
const it = E.items[idxOf(E.curId)];
|
const it = E.items[idxOf(E.curId)];
|
||||||
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
|
||||||
if (n > 1) {
|
if (n > 1) {
|
||||||
@@ -623,6 +819,8 @@ export function createExplorer(deps) {
|
|||||||
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
|
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 === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
|
||||||
else if (a === 'close') root.classList.remove('show-insp');
|
else if (a === 'close') root.classList.remove('show-insp');
|
||||||
|
else if (a === 'open' && E.curDir != null) goTo(E.curDir);
|
||||||
|
else if (a === 'mark' && E.curDir != null) { toggleDir(E.curDir); paintInspector(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------ fullscreen
|
// ------------------------------------------------------------ fullscreen
|
||||||
|
|||||||
@@ -598,4 +598,25 @@ export const EN = {
|
|||||||
'Reiniciando…': 'Restarting…',
|
'Reiniciando…': 'Restarting…',
|
||||||
'Atualização concluída': 'Update completed',
|
'Atualização concluída': 'Update completed',
|
||||||
'Falha na atualização: {n}': 'Update failed: {n}',
|
'Falha na atualização: {n}': 'Update failed: {n}',
|
||||||
|
'Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.': 'Each marked folder is recreated in the destination from itself down, with all its subfolders. Files marked on their own go to the root of the destination.',
|
||||||
|
'Atenção:': 'Note:',
|
||||||
|
'pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.': 'marked folders are recovered whole; the active filters do not apply to them.',
|
||||||
|
'Navegar pelas pastas': 'Browse folders',
|
||||||
|
'Arquivos encontrados por assinatura, sem pasta conhecida': 'Files found by signature, with no known folder',
|
||||||
|
'Calculando seleção…': 'Counting the selection…',
|
||||||
|
'Marcar esta pasta': 'Mark this folder',
|
||||||
|
'{n} {n} · {n} {n} aqui': '{n} {n} · {n} {n} here',
|
||||||
|
'Pasta atual': 'Current folder',
|
||||||
|
'Encontrados por assinatura (sem pasta conhecida)': 'Found by signature (no known folder)',
|
||||||
|
'Desmarcar pasta': 'Unmark folder',
|
||||||
|
'Marcar pasta inteira': 'Mark whole folder',
|
||||||
|
'Abrir pasta': 'Open folder',
|
||||||
|
'Recuperar seleção': 'Recover selection',
|
||||||
|
'Caminho': 'Path',
|
||||||
|
'Arquivos': 'Files',
|
||||||
|
'arquivo': 'file',
|
||||||
|
'pastas': 'folders',
|
||||||
|
'{n} arquivo': '{n} file',
|
||||||
|
'{n} arquivos': '{n} files',
|
||||||
|
'{n} {n} · {n}': '{n} {n} · {n}',
|
||||||
};
|
};
|
||||||
@@ -242,7 +242,7 @@ pub fn browse(found: &[Found], path: &str, q: &Query) -> Value {
|
|||||||
|
|
||||||
/// What the user marked in folder browsing: whole folders (recursive; `""` is
|
/// What the user marked in folder browsing: whole folders (recursive; `""` is
|
||||||
/// everything, `"@carved"` the files found by signature), single files, and
|
/// everything, `"@carved"` the files found by signature), single files, and
|
||||||
/// files unmarked inside a marked folder.
|
/// files or subfolders unmarked inside a marked folder.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Selection {
|
pub struct Selection {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -251,6 +251,8 @@ pub struct Selection {
|
|||||||
pub ids: Vec<u64>,
|
pub ids: Vec<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exclude: Vec<u64>,
|
pub exclude: Vec<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude_folders: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `f` is inside the browsing folder `folder` (recursively).
|
/// Whether `f` is inside the browsing folder `folder` (recursively).
|
||||||
@@ -265,19 +267,43 @@ pub fn in_folder(f: &Found, folder: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Files a selection resolves to, in discovery order. Marked folders take
|
/// Files a selection resolves to, in discovery order. For each file the
|
||||||
/// everything inside them, whatever filters the view had; an excluded id
|
/// deepest marked or unmarked folder holding it decides; marked folders take
|
||||||
/// always wins.
|
/// everything inside them, whatever filters the view had. A file marked on its
|
||||||
|
/// own is in, and an excluded id always wins.
|
||||||
pub fn resolve_selection<'a>(found: &'a [Found], sel: &Selection) -> Vec<&'a Found> {
|
pub fn resolve_selection<'a>(found: &'a [Found], sel: &Selection) -> Vec<&'a Found> {
|
||||||
let ids: std::collections::HashSet<u64> = sel.ids.iter().copied().collect();
|
let ids: std::collections::HashSet<u64> = sel.ids.iter().copied().collect();
|
||||||
let exclude: std::collections::HashSet<u64> = sel.exclude.iter().copied().collect();
|
let exclude: std::collections::HashSet<u64> = sel.exclude.iter().copied().collect();
|
||||||
|
let marks: Vec<(&str, bool)> = sel
|
||||||
|
.folders
|
||||||
|
.iter()
|
||||||
|
.map(|d| (d.trim_matches('/'), true))
|
||||||
|
.chain(
|
||||||
|
sel.exclude_folders
|
||||||
|
.iter()
|
||||||
|
.map(|d| (d.trim_matches('/'), false)),
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
found
|
found
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| !exclude.contains(&f.id))
|
.filter(|f| !exclude.contains(&f.id))
|
||||||
.filter(|f| ids.contains(&f.id) || sel.folders.iter().any(|d| in_folder(f, d)))
|
.filter(|f| {
|
||||||
|
ids.contains(&f.id)
|
||||||
|
|| marks
|
||||||
|
.iter()
|
||||||
|
.filter(|(d, _)| in_folder(f, d))
|
||||||
|
.max_by_key(|(d, included)| (d.len(), !included))
|
||||||
|
.is_some_and(|(_, included)| *included)
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Files and bytes a selection resolves to.
|
||||||
|
pub fn selection_count(found: &[Found], sel: &Selection) -> Value {
|
||||||
|
let files = resolve_selection(found, sel);
|
||||||
|
json!({ "files": files.len(), "bytes": files.iter().map(|f| f.size).sum::<u64>() })
|
||||||
|
}
|
||||||
|
|
||||||
/// Facet counts for sidebar navigation.
|
/// Facet counts for sidebar navigation.
|
||||||
pub fn facets(found: &[Found]) -> Value {
|
pub fn facets(found: &[Found]) -> Value {
|
||||||
let mut cats: HashMap<&'static str, usize> = HashMap::new();
|
let mut cats: HashMap<&'static str, usize> = HashMap::new();
|
||||||
@@ -581,6 +607,7 @@ mod tests {
|
|||||||
folders: vec!["DCIM/100CANON".into()],
|
folders: vec!["DCIM/100CANON".into()],
|
||||||
ids: vec![3],
|
ids: vec![3],
|
||||||
exclude: vec![],
|
exclude: vec![],
|
||||||
|
exclude_folders: vec![],
|
||||||
};
|
};
|
||||||
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 5]);
|
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 5]);
|
||||||
let all = Selection {
|
let all = Selection {
|
||||||
@@ -597,10 +624,33 @@ mod tests {
|
|||||||
folders: vec!["DCIM".into()],
|
folders: vec!["DCIM".into()],
|
||||||
ids: vec![5],
|
ids: vec![5],
|
||||||
exclude: vec![5, 2],
|
exclude: vec![5, 2],
|
||||||
|
exclude_folders: vec![],
|
||||||
};
|
};
|
||||||
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]);
|
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excluded_subfolder_is_left_out() {
|
||||||
|
let found = sample();
|
||||||
|
let sel = Selection {
|
||||||
|
folders: vec!["DCIM".into()],
|
||||||
|
exclude_folders: vec!["DCIM/100CANON".into()],
|
||||||
|
..Selection::default()
|
||||||
|
};
|
||||||
|
assert_eq!(ids(resolve_selection(&found, &sel)), vec![2]);
|
||||||
|
// a folder marked again inside the excluded one comes back
|
||||||
|
let sel = Selection {
|
||||||
|
folders: vec!["".into(), "DCIM/100CANON".into()],
|
||||||
|
exclude_folders: vec!["DCIM".into()],
|
||||||
|
..Selection::default()
|
||||||
|
};
|
||||||
|
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 4, 5]);
|
||||||
|
assert_eq!(
|
||||||
|
selection_count(&found, &sel),
|
||||||
|
json!({ "files": 4, "bytes": 113 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selection_carved_folder() {
|
fn selection_carved_folder() {
|
||||||
let found = sample();
|
let found = sample();
|
||||||
|
|||||||
@@ -1262,6 +1262,17 @@ impl Engine {
|
|||||||
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
self.browse(&s("job")?, path, &q)?
|
self.browse(&s("job")?, path, &q)?
|
||||||
}
|
}
|
||||||
|
"selection_count" => {
|
||||||
|
let sel: crate::explorer::Selection = p
|
||||||
|
.get("selection")
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::from_value)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let j = self.job(&s("job")?)?;
|
||||||
|
let st = j.st.lock().unwrap();
|
||||||
|
crate::explorer::selection_count(&st.found, &sel)
|
||||||
|
}
|
||||||
"results" => {
|
"results" => {
|
||||||
let q: Query = p
|
let q: Query = p
|
||||||
.get("query")
|
.get("query")
|
||||||
|
|||||||
@@ -783,6 +783,7 @@ fn navegacao_por_pastas_e_recuperacao_da_selecao() {
|
|||||||
folders: vec!["Documentos/Trabalho".into()],
|
folders: vec!["Documentos/Trabalho".into()],
|
||||||
ids: vec![id_of("_raia.jpg")],
|
ids: vec![id_of("_raia.jpg")],
|
||||||
exclude: vec![id_of("tese.docx")],
|
exclude: vec![id_of("tese.docx")],
|
||||||
|
exclude_folders: vec![],
|
||||||
};
|
};
|
||||||
let out = tempfile::tempdir().unwrap();
|
let out = tempfile::tempdir().unwrap();
|
||||||
let rj = eng
|
let rj = eng
|
||||||
|
|||||||
Reference in new issue
Block a user