tagliatelle

Simple self-hosted tagging filesystem webapp
Log | Files | Refs

admin-duplicates.js (9786B)


      1 (function () {
      2     const $ = (s) => document.querySelector(s);
      3 
      4     function updateCount() {
      5         const all = document.querySelectorAll('#dup-groups .dup-group');
      6         const total = all.length;
      7         const shown = [...all].filter((g) => !g.hidden).length;
      8         const label = total === 1 ? ' group' : ' groups';
      9         $('#dup-count').textContent = shown === total ? total + label : shown + ' of ' + total + label;
     10         const empty = $('#dup-empty');
     11         empty.textContent = total === 0 ? 'No similar images found.' : 'No groups match the filter.';
     12         empty.hidden = shown !== 0;
     13     }
     14 
     15     // filter results: space separated, case-insensitively against paths with -negation support
     16     function applyFilter() {
     17         const terms = $('#dup-filter').value.toLowerCase().split(/\s+/).filter(Boolean);
     18         const include = terms.filter((t) => t[0] !== '-').map((t) => t);
     19         const exclude = terms.filter((t) => t[0] === '-' && t.length > 1).map((t) => t.slice(1));
     20         document.querySelectorAll('#dup-groups .dup-group').forEach((g) => {
     21             const paths = [...g.querySelectorAll('.dup-card')].map((c) => c._item.path.toLowerCase());
     22             const ok = include.every((t) => paths.some((p) => p.includes(t))) &&
     23                 !exclude.some((t) => paths.some((p) => p.includes(t)));
     24             g.hidden = !ok;
     25         });
     26         updateCount();
     27     }
     28 
     29     // highlight matching part of path in groups
     30     function highlightGroup(groupEl) {
     31         const cards = [...groupEl.querySelectorAll('.dup-card')];
     32         const paths = cards.map((c) => c._item.path);
     33         let pre = 0, suf = 0;
     34         if (paths.length >= 2) {
     35             const min = Math.min(...paths.map((p) => p.length));
     36             while (pre < min && paths.every((p) => p[pre] === paths[0][pre])) pre++;
     37             while (pre + suf < min && paths.every((p) => p[p.length - 1 - suf] === paths[0][paths[0].length - 1 - suf])) suf++;
     38         }
     39         cards.forEach((c) => {
     40             const p = c._item.path;
     41             const el = c.querySelector('.dup-path');
     42             el.textContent = '';
     43             const parts = [[p.slice(0, pre), true], [p.slice(pre, p.length - suf), false], [p.slice(p.length - suf), true]];
     44             for (const [text, match] of parts) {
     45                 if (!text) continue;
     46                 const span = document.createElement('span');
     47                 if (match) span.className = 'dup-match';
     48                 span.textContent = text;
     49                 el.append(span);
     50             }
     51         });
     52     }
     53 
     54     let msgTimer;
     55     function showMsg(msg, isErr) {
     56         const el = $('#dup-msg');
     57         el.textContent = msg;
     58         el.className = isErr ? 'dup-msg--err' : 'dup-msg--ok';
     59         clearTimeout(msgTimer);
     60         msgTimer = setTimeout(() => { el.textContent = ''; }, 8000);
     61     }
     62     const showErr = (msg) => showMsg(msg, true);
     63 
     64     async function post(url, body) {
     65         const res = await fetch(url, {
     66             method: 'POST',
     67             headers: { 'Content-Type': 'application/json' },
     68             body: JSON.stringify(body),
     69         });
     70         if (!res.ok) throw new Error((await res.text()).trim() || res.status);
     71         return res;
     72     }
     73 
     74     // Tracks images in a group to only show replace for pairs
     75     function syncGroup(groupEl) {
     76         const n = groupEl.querySelectorAll('.dup-card').length;
     77         if (n < 2) { groupEl.remove(); } else { groupEl.dataset.count = n; highlightGroup(groupEl); }
     78         updateCount();
     79     }
     80 
     81     async function del(item, card, groupEl) {
     82         if (!confirm('Permanently delete this file?\n\n' + item.path)) return;
     83         try {
     84             await post('/admin/duplicates/delete', { path: item.path });
     85             card.remove();
     86             syncGroup(groupEl);
     87         } catch (e) {
     88             showErr('Delete failed: ' + e.message);
     89         }
     90     }
     91 
     92     async function delAll(groupEl) {
     93         const items = [...groupEl.querySelectorAll('.dup-card')].map((c) => c._item);
     94         if (!confirm('Permanently delete ALL ' + items.length + ' files in this group?\n\n' +
     95             items.map((i) => i.path).join('\n') + '\n\nNo copy will be kept. This cannot be undone.')) return;
     96         try {
     97             await post('/admin/duplicates/delete-group', { paths: items.map((i) => i.path) });
     98             showMsg('Deleted ' + items.length + ' files');
     99             groupEl.remove();
    100             updateCount();
    101         } catch (e) {
    102             showErr('Delete all failed: ' + e.message);
    103         }
    104     }
    105 
    106     // replace files prompt
    107     async function replaceOther(item, card, groupEl) {
    108         const other = [...groupEl.querySelectorAll('.dup-card')].find((c) => c !== card);
    109         if (!other) return;
    110         const dst = other._item;
    111         if (!confirm(
    112             'Replace this file:\n\n' + dst.path + '  (' + dst.dims + ', ' + dst.size + ')\n\n' +
    113             'with this one:\n\n' + item.path + '  (' + item.dims + ', ' + item.size + ')\n\n' +
    114             'The better file is moved to the first location and the original is overwritten. ' +
    115             'This cannot be undone.')) return;
    116         try {
    117             const res = await post('/admin/duplicates/replace', { src: item.path, dst: dst.path });
    118             const { path } = await res.json();
    119             showMsg('Replaced: ' + path);
    120             groupEl.remove(); // only one file is left, so the group is resolved
    121             updateCount();
    122         } catch (e) {
    123             showErr('Replace failed: ' + e.message);
    124         }
    125     }
    126 
    127     function render(groups) {
    128         const root = $('#dup-groups');
    129         root.textContent = '';
    130         groups.forEach((g, i) => {
    131             const groupEl = document.createElement('section');
    132             groupEl.className = 'dup-group';
    133             groupEl.dataset.count = g.length;
    134             const h = document.createElement('h3');
    135             h.textContent = 'Group ' + (i + 1) + ' (' + g.length + ' images)';
    136             const all = document.createElement('button');
    137             all.type = 'button';
    138             all.className = 'dup-btn dup-btn--delall'; all.textContent = 'Delete all';
    139             all.title = 'Delete every file in this group';
    140             all.onclick = () => delAll(groupEl);
    141             h.append(all);
    142             groupEl.append(h);
    143 
    144             const row = document.createElement('div');
    145             row.className = 'dup-row';
    146             g.forEach((item) => {
    147                 const card = document.createElement('div');
    148                 card.className = 'dup-card' + (item.similarity === 'Original' ? ' dup-card--original' : '');
    149                 card._item = item;
    150 
    151                 const url = '/admin/duplicates/image?path=' + encodeURIComponent(item.path);
    152                 const a = document.createElement('a');
    153                 a.href = url; a.target = '_blank';
    154                 const img = document.createElement('img');
    155                 img.loading = 'lazy'; img.src = url; img.alt = '';
    156                 a.append(img);
    157 
    158                 const p = document.createElement('div');
    159                 p.className = 'dup-path'; p.textContent = item.path;
    160 
    161                 const meta = document.createElement('div');
    162                 meta.className = 'dup-meta';
    163                 for (const [cls, text] of [['dup-tag--sim', item.similarity], ['', item.dims], ['', item.size]]) {
    164                     const t = document.createElement('span');
    165                     t.className = 'dup-tag ' + cls; t.textContent = text;
    166                     meta.append(t);
    167                 }
    168 
    169                 const btn = document.createElement('button');
    170                 btn.type = 'button';
    171                 btn.className = 'dup-btn dup-btn--del'; btn.textContent = 'Delete';
    172                 btn.onclick = () => del(item, card, groupEl);
    173 
    174                 const rep = document.createElement('button');
    175                 rep.type = 'button';
    176                 rep.className = 'dup-btn dup-btn--replace'; rep.textContent = 'Replace other with this';
    177                 rep.title = 'Move this file over the other one in the pair';
    178                 rep.onclick = () => replaceOther(item, card, groupEl);
    179 
    180                 card.append(a, p, meta, btn, rep);
    181                 row.append(card);
    182             });
    183             groupEl.append(row);
    184             root.append(groupEl);
    185             highlightGroup(groupEl);
    186         });
    187         $('#dup-bar').hidden = false;
    188         applyFilter();
    189     }
    190 
    191     async function scan(e) {
    192         e.preventDefault();
    193         const dirs = [$('#dup-dir1').value, $('#dup-dir2').value].map((d) => d.trim()).filter(Boolean);
    194         const btn = $('#dup-scan');
    195         const status = $('#dup-status');
    196         btn.disabled = true;
    197         status.textContent = 'Scanning… this can take a while for large directories.';
    198         try {
    199             const res = await post('/admin/duplicates/scan', { dirs });
    200             const data = await res.json();
    201             render(data.groups || []);
    202             status.textContent = '';
    203         } catch (err) {
    204             status.textContent = '';
    205             $('#dup-bar').hidden = false;
    206             showErr('Scan failed: ' + err.message);
    207         } finally {
    208             btn.disabled = false;
    209         }
    210     }
    211 
    212     document.addEventListener('DOMContentLoaded', function () {
    213         $('#dup-form').addEventListener('submit', scan);
    214         $('#dup-filter').addEventListener('input', applyFilter);
    215         // Show the previous scan again after a reload (results are kept in memory only).
    216         fetch('/admin/duplicates/groups').then((r) => r.json()).then((data) => {
    217             if (data.dirs && data.dirs.length) {
    218                 $('#dup-dir1').value = data.dirs[0];
    219                 $('#dup-dir2').value = data.dirs[1] || '';
    220                 render(data.groups || []);
    221             }
    222         }).catch(() => {});
    223     });
    224 })();