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 })();