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:
2026-08-24 11:11:41 -04:00
co-authored by Claude Sonnet 5
parent 9f0f49a399
commit 49b68b8e51
3 changed files with 296 additions and 6 deletions
+146 -1
View File
@@ -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 = [];