Add delete and ignore actions to Duplicate Courses
Delete permanently removes a course from disk (confirmed with the full path first) and cleans up any Hidden/Next Up/Recently Viewed references to it. Ignore marks a specific pair as confirmed-not- duplicates so it stops resurfacing in scans, without suppressing either course's other matches; ignored pairs are listed and reversible under "Ignored matches," and now travel with backup/restore. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+146
-1
@@ -247,6 +247,10 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dup-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -304,6 +308,14 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85em;
|
||||
@@ -533,6 +545,11 @@
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-secondary" id="dup-scan-btn" onclick="scanDuplicates()">{{ icons.icon('refresh', 14) }} Scan for Duplicates</button>
|
||||
<span id="dup-status"></span>
|
||||
<button class="btn btn-secondary btn-sm" style="margin-left: auto;" onclick="toggleIgnoredDuplicates()">Ignored matches</button>
|
||||
</div>
|
||||
<div id="dup-ignored-wrap" style="display: none; margin-top: 14px;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Confirmed not duplicates</div>
|
||||
<div id="dup-ignored-list"></div>
|
||||
</div>
|
||||
<div id="dup-results"></div>
|
||||
</div>
|
||||
@@ -1223,12 +1240,16 @@
|
||||
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}, out of ${data.course_count} courses.`;
|
||||
results.innerHTML = groups.map(group => `
|
||||
<div class="dup-group">
|
||||
<div class="dup-group-header">${Math.round(group.similarity * 100)}% similar</div>
|
||||
<div class="dup-group-header">
|
||||
<span>${Math.round(group.similarity * 100)}% similar</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick="ignoreDuplicateGroup(this)">Not a duplicate - ignore</button>
|
||||
</div>
|
||||
${group.courses.map(c => `
|
||||
<div class="dup-course-row">
|
||||
<span class="dup-course-name">${escapeHtml(c.name)}</span>
|
||||
<span class="dup-course-path">${escapeHtml(c.path)}</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick="hideDuplicateCourse(this, '${c.path.replace(/'/g, "\\'")}')">Hide</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteDuplicateCourse(this, '${c.path.replace(/'/g, "\\'")}', '${c.name.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -1259,6 +1280,130 @@
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function deleteDuplicateCourse(btnEl, path, name) {
|
||||
if (!confirm(`Permanently delete "${name}" and everything inside it?\n\n${path}\n\nThis cannot be undone.`)) return;
|
||||
btnEl.disabled = true;
|
||||
fetch('/api/library/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (!ok || !data.success) {
|
||||
btnEl.disabled = false;
|
||||
alert(data.error || 'Delete failed');
|
||||
return;
|
||||
}
|
||||
if (data.active_course_reset) {
|
||||
alert('That was your active course - reload the main page to pick a different one.');
|
||||
}
|
||||
const row = btnEl.closest('.dup-course-row');
|
||||
const group = row ? row.closest('.dup-group') : null;
|
||||
if (row) row.remove();
|
||||
if (group && group.querySelectorAll('.dup-course-row').length < 2) {
|
||||
group.remove();
|
||||
}
|
||||
refreshDupStatusText();
|
||||
})
|
||||
.catch(() => {
|
||||
btnEl.disabled = false;
|
||||
alert('Could not reach the server.');
|
||||
});
|
||||
}
|
||||
|
||||
// Keeps the "N possible duplicate groups" line honest after a
|
||||
// client-side removal (delete/ignore), without needing a full
|
||||
// re-scan that would lose the user's place in a long results list.
|
||||
function refreshDupStatusText() {
|
||||
const status = document.getElementById('dup-status');
|
||||
const remaining = document.querySelectorAll('#dup-results .dup-group').length;
|
||||
if (remaining === 0) {
|
||||
status.style.color = 'var(--success)';
|
||||
status.textContent = 'No possible duplicates remaining.';
|
||||
} else {
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = `${remaining} possible duplicate group${remaining === 1 ? '' : 's'} remaining.`;
|
||||
}
|
||||
}
|
||||
|
||||
function ignoreDuplicateGroup(btnEl) {
|
||||
const group = btnEl.closest('.dup-group');
|
||||
if (!group) return;
|
||||
const paths = Array.from(group.querySelectorAll('.dup-course-path')).map(el => el.textContent);
|
||||
if (paths.length < 2) return;
|
||||
if (!confirm('Mark these as NOT duplicates of each other? They won\'t be flagged together again (reversible from "Ignored matches").')) return;
|
||||
btnEl.disabled = true;
|
||||
fetch('/api/library/duplicates/ignore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
group.remove();
|
||||
refreshDupStatusText();
|
||||
} else {
|
||||
btnEl.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => { btnEl.disabled = false; });
|
||||
}
|
||||
|
||||
function toggleIgnoredDuplicates() {
|
||||
const wrap = document.getElementById('dup-ignored-wrap');
|
||||
if (wrap.style.display !== 'none') {
|
||||
wrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
wrap.style.display = 'block';
|
||||
loadIgnoredDuplicates();
|
||||
}
|
||||
|
||||
function loadIgnoredDuplicates() {
|
||||
const list = document.getElementById('dup-ignored-list');
|
||||
list.innerHTML = '<div class="curate-empty-hint">Loading…</div>';
|
||||
fetch('/api/library/duplicates/ignored')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const ignored = data.ignored || [];
|
||||
if (!ignored.length) {
|
||||
list.innerHTML = '<div class="curate-empty-hint">Nothing ignored.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = ignored.map(pair => `
|
||||
<div class="bulk-rename-row">
|
||||
<div class="bulk-rename-row-names">
|
||||
${escapeHtml(pair.a.name)} <span style="color: var(--text-muted);">↔</span> ${escapeHtml(pair.b.name)}
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="restoreIgnoredDuplicate(this, '${pair.a.path.replace(/'/g, "\\'")}', '${pair.b.path.replace(/'/g, "\\'")}')">Restore</button>
|
||||
</div>
|
||||
`).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
list.innerHTML = '<p style="color:var(--error);">Could not reach the server.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function restoreIgnoredDuplicate(btnEl, pathA, pathB) {
|
||||
btnEl.disabled = true;
|
||||
fetch('/api/library/duplicates/ignore', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ a: pathA, b: pathB })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadIgnoredDuplicates();
|
||||
} else {
|
||||
btnEl.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => { btnEl.disabled = false; });
|
||||
}
|
||||
|
||||
// ---- Stale References ----
|
||||
let lastStaleItems = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user