Tighten Sort Unsorted matching; consolidate file management tools

Fix Sort Unsorted picking a wrong destination on words that are common
clutter across many categories (a creator's name, generic marketing
filler) by ranking matches on a folder's own deliberate name over
incidental sibling-course words, and downweighting borrowed words that
recur across many categories. Add a folder-picker/create-new/rename UI
to override any proposal.

Turn /unsorted into a general File Management page: move Refresh
Library, Bulk Rename, and Manage Library (hide/rename) here from
Settings, add a Move action to Manage Library using the same picker,
and add Duplicate Courses and Clean Up Stale References tools. Add a
footer link to the page from the dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 10:29:55 -04:00
co-authored by Claude Sonnet 5
parent 4eda493e00
commit 9ff8c70311
5 changed files with 1325 additions and 535 deletions
+19
View File
@@ -197,6 +197,21 @@
transform: translateY(0);
}
.page-footer-link {
text-align: center;
padding: 24px 0 8px;
}
.page-footer-link a {
color: var(--text-muted);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
}
.page-footer-link a:hover {
color: var(--accent);
}
.bottom-tab-bar {
position: fixed;
left: 0;
@@ -1313,6 +1328,10 @@
</div>
{% endif %}
{% endif %}
<div class="page-footer-link">
<a href="/unsorted">{{ icons.icon('folder', 14) }} File Management</a>
</div>
</div>
<nav class="bottom-tab-bar">
+6 -466
View File
@@ -221,101 +221,6 @@
padding: 5px 12px;
font-size: 0.85em;
}
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
cursor: pointer;
}
.curate-row:hover {
background: var(--bg-tertiary-hover);
}
.curate-row.is-hidden {
opacity: 0.55;
}
.curate-row-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.curate-row-name span.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curate-toggle {
font-size: 1.1em;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.curate-children {
margin-left: 22px;
margin-top: 4px;
display: none;
}
.curate-children.expanded {
display: block;
}
.curate-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
padding: 6px 0;
}
.curate-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.curate-rename-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.95em;
font-family: var(--font-family);
}
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: var(--error);
}
.bulk-rename-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
}
.bulk-rename-row-names {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.bulk-rename-old {
color: var(--text-muted);
font-size: 0.9em;
text-decoration: line-through;
}
.bulk-rename-new {
color: var(--text-primary);
}
#save-status {
font-size: 0.9em;
color: var(--success);
@@ -448,11 +353,6 @@
<button class="btn" onclick="saveLibraryPath()">Save</button>
</div>
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
<div style="display: flex; align-items: center; gap: 10px;">
<button class="btn btn-secondary btn-sm" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Lengths &amp; Cover Art</button>
@@ -463,50 +363,14 @@
</div>
<div class="card">
<h2>Sort Unsorted Courses</h2>
<h2>File Management</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Drop new courses in an "Unsorted" folder at the root of your library, then use this to
propose where each one belongs based on your existing folder structure - nothing moves
until you review and confirm on its own page.
Sort new courses out of an "Unsorted" folder at the root of your library, refresh the
library cache, bulk-rename or move things around, hide courses, check for duplicates, and
clean up stale references - all on its own page, with room to review before anything
changes on disk.
</p>
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open Sorter</a>
</div>
<div class="card">
<h2>Manage Library</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Hide courses or whole folders from the Library browser without touching anything on disk
(hiding a folder hides everything inside it), or rename a course/folder directly - no need
to go to the NAS.
</p>
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div>
<div class="card">
<h2>Bulk Rename</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Find and replace text across every course/folder name in the library at once - handy for
stripping a release-group suffix off a batch of downloads. Nothing changes until you apply.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
<input type="text" id="bulk-rename-find" class="hex-input"
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
<input type="text" id="bulk-rename-replace" class="hex-input"
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
placeholder="Replace with (blank to remove)">
<button class="btn" onclick="previewBulkRename()">Preview</button>
</div>
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
<div id="bulk-rename-preview"></div>
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open File Management</a>
</div>
<div class="card">
@@ -627,10 +491,6 @@
// built dynamically here are kept in sync with the same path data
// by hand.
const ICON_SVGS = {
ban: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"></path></svg>',
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>',
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
};
@@ -682,309 +542,6 @@
}
});
// ---- Library curation: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
.then(r => r.json())
.then(data => {
const wrap = document.getElementById('hidden-list-wrap');
const list = document.getElementById('hidden-list');
const items = data.hidden_paths || [];
if (items.length === 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
list.innerHTML = items.map(item => `
<div class="curate-row">
<div class="curate-row-name">
<span>${ICON_SVGS.ban}</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).join('');
})
.catch(() => {});
}
function loadCurateLevel(path, container, isRoot) {
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
}
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
content.dataset.loaded = 'true';
content.dataset.path = path;
loadCurateLevel(path, content, false);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
})
.catch(() => {});
}
// ---- Library curation: rename a course/folder in place on disk ----
function startRename(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
if (!row || !nameSpan) return;
let settled = false;
const input = document.createElement('input');
input.type = 'text';
input.className = 'curate-rename-input';
input.value = currentName;
input.onclick = (e) => e.stopPropagation();
nameSpan.replaceWith(input);
input.focus();
input.select();
function cancel() {
if (settled) return;
settled = true;
input.replaceWith(nameSpan);
}
function commit() {
if (settled) return;
const newName = input.value.trim();
if (!newName || newName === currentName) {
cancel();
return;
}
settled = true;
submitRename(btnEl, path, newName, row, nameSpan, input);
}
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
}
function submitRename(btnEl, path, newName, row, nameSpan, input) {
fetch('/api/rename-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, new_name: newName })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
showRowError(row, data.error || 'Rename failed');
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
input.replaceWith(nameSpan);
showRowError(row, 'Could not reach the server');
});
}
function showRowError(row, message) {
let status = row.querySelector('.curate-row-status');
if (!status) {
status = document.createElement('div');
status.className = 'curate-row-status';
row.appendChild(status);
}
status.textContent = message;
}
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
// ---- Bulk rename: find/replace across every course/folder name ----
let bulkRenameMatches = [];
function previewBulkRename() {
const pattern = document.getElementById('bulk-rename-find').value;
const replacement = document.getElementById('bulk-rename-replace').value;
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
preview.innerHTML = '';
if (!pattern) {
status.style.color = 'var(--error)';
status.textContent = 'Enter text to find first.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Searching...';
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
.then(r => r.json())
.then(data => {
bulkRenameMatches = data.matches || [];
renderBulkRenamePreview();
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function renderBulkRenamePreview() {
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
if (bulkRenameMatches.length === 0) {
status.style.color = 'var(--text-muted)';
status.textContent = 'No matches.';
preview.innerHTML = '';
return;
}
status.textContent = '';
const rows = bulkRenameMatches.map((m, i) => `
<div class="bulk-rename-row">
<div class="bulk-rename-row-names">
<div class="bulk-rename-old">${m.old_name}</div>
<div class="bulk-rename-new">→ ${m.new_name}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
</div>
`).join('');
const count = bulkRenameMatches.length;
preview.innerHTML = rows +
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
}
function removeBulkRenameMatch(index) {
bulkRenameMatches.splice(index, 1);
renderBulkRenamePreview();
}
function applyBulkRename() {
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
const status = document.getElementById('bulk-rename-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Applying...';
fetch('/api/bulk-rename/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: bulkRenameMatches })
})
.then(r => r.json())
.then(data => {
const results = data.results || [];
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
const activeCourseReset = results.some(r => r.active_course_reset);
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
let message = `${succeeded} renamed`;
if (failed.length) {
message += `, ${failed.length} failed: ` +
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
} else {
message += '.';
}
if (activeCourseReset) {
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
}
status.textContent = message;
bulkRenameMatches = [];
document.getElementById('bulk-rename-preview').innerHTML = '';
document.getElementById('bulk-rename-find').value = '';
document.getElementById('bulk-rename-replace').value = '';
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function saveLibraryPath() {
const input = document.getElementById('library_path');
const status = document.getElementById('library-path-status');
@@ -1010,23 +567,6 @@
});
}
function refreshLibraryCache() {
const status = document.getElementById('library-refresh-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Refreshing…';
fetch('/api/library/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
function updatePrewarmStatus(data) {
const btn = document.getElementById('prewarm-durations-btn');
const status = document.getElementById('prewarm-durations-status');
+884 -22
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sort Unsorted - OfflineU</title>
<title>File Management - OfflineU</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
@@ -103,6 +103,179 @@
padding: 20px 25px;
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.15em;
color: var(--text-primary);
margin: 34px 0 6px;
}
.section-title:first-of-type { margin-top: 0; }
.section-desc {
color: var(--text-muted);
font-size: 0.92em;
margin: 0 0 14px;
}
.text-input {
flex: 1;
min-width: 160px;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 10px;
font-size: 0.9em;
font-family: var(--font-family);
}
.bulk-rename-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
}
.bulk-rename-row-names {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.bulk-rename-old {
color: var(--text-muted);
font-size: 0.9em;
text-decoration: line-through;
}
.bulk-rename-new {
color: var(--text-primary);
}
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
cursor: pointer;
}
.curate-row:hover {
background: var(--bg-tertiary-hover);
}
.curate-row.is-hidden {
opacity: 0.55;
}
.curate-row-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.curate-row-name span.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curate-toggle {
font-size: 1.1em;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.curate-children {
margin-left: 22px;
margin-top: 4px;
display: none;
}
.curate-children.expanded {
display: block;
}
.curate-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
padding: 6px 0;
}
.curate-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.curate-rename-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.95em;
font-family: var(--font-family);
}
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: var(--error);
}
.curate-move-panel {
background: var(--bg-tertiary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px 14px;
margin: -3px 0 8px 0;
}
.curate-move-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.curate-move-status {
font-size: 0.82em;
margin-top: 6px;
min-height: 1.1em;
}
.dup-group {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 12px 14px;
margin-bottom: 10px;
}
.dup-group-header {
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--accent);
margin-bottom: 8px;
}
.dup-course-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 0;
border-top: 1px solid var(--border-color);
}
.dup-course-row:first-of-type {
border-top: none;
}
.dup-course-name {
font-weight: 600;
}
.dup-course-path {
flex: 1;
min-width: 160px;
color: var(--text-muted);
font-size: 0.8em;
overflow-wrap: break-word;
word-break: break-word;
}
.toolbar {
display: flex;
align-items: center;
@@ -188,15 +361,27 @@
color: var(--text-muted);
margin-top: 4px;
}
.sort-row-dest {
.sort-row-fields {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 240px;
flex-wrap: wrap;
gap: 14px;
margin-top: 10px;
}
.sort-row-dest input {
.sort-field {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 5px;
}
.sort-field label {
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--text-muted);
}
.sort-field input,
.sort-field select {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
@@ -204,9 +389,18 @@
padding: 8px 10px;
font-size: 0.9em;
font-family: var(--font-family);
width: 100%;
}
.sort-row-dest input:invalid,
.sort-row-dest input.empty {
.dest-picker {
display: flex;
align-items: center;
gap: 8px;
}
.dest-picker select { flex: 1; }
.dest-new-input {
margin-top: 6px;
}
.dest-new-input.empty {
border-color: var(--error);
}
.action-bar {
@@ -269,8 +463,11 @@
<div class="page-content">
<div class="container">
<h1>{{ icons.icon('folder', 24) }} Sort Unsorted</h1>
<p class="subtitle">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure. Nothing moves until you apply it.</p>
<h1>{{ icons.icon('folder', 24) }} File Management</h1>
<p class="subtitle">Sort new courses, refresh the library cache, bulk-rename, hide/move/rename anything already filed, check for duplicates, and clean up stale references. Nothing changes on disk until you review and apply it.</p>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Sort Unsorted</h2>
<p class="section-desc">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure.</p>
<div class="card">
<div class="toolbar">
@@ -295,6 +492,60 @@
</div>
<div id="empty-state" class="empty-hint" style="display: none;"></div>
<h2 class="section-title">{{ icons.icon('refresh', 18) }} Refresh Library</h2>
<p class="section-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="refresh-library-btn" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
</div>
<h2 class="section-title">{{ icons.icon('pencil', 18) }} Bulk Rename</h2>
<p class="section-desc">Find and replace text across every course/folder name in the library at once - handy for stripping a release-group suffix off a batch of downloads.</p>
<div class="card">
<div class="toolbar" style="margin-bottom: 10px;">
<input type="text" id="bulk-rename-find" class="text-input" placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
<input type="text" id="bulk-rename-replace" class="text-input" placeholder="Replace with (blank to remove)">
<button class="btn" onclick="previewBulkRename()">Preview</button>
</div>
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
<div id="bulk-rename-preview"></div>
</div>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Manage Library</h2>
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS.</p>
<div class="card">
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div>
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Courses</h2>
<p class="section-desc">Scan for courses that look like the same thing filed twice - a re-download that landed in a different folder, or something that's both sorted and still sitting in Unsorted. Matching reuses the same tokenizer as Sort Unsorted, so platform names and release-group tags don't throw it off.</p>
<div class="card">
<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>
</div>
<div id="dup-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="stale-scan-btn" onclick="scanStaleReferences()">{{ icons.icon('refresh', 14) }} Scan for Stale References</button>
<span id="stale-status"></span>
</div>
<div id="stale-results"></div>
</div>
</div>
</div>
@@ -319,9 +570,15 @@
// template literals can't call the Jinja macro.
const ICON_SVGS = {
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"></path></svg>',
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
ban: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>',
};
let lastItems = [];
let lastCategories = [];
const NEW_FOLDER_VALUE = '__new__';
function badgeLabel(type) {
if (type === 'existing') return 'Existing folder';
@@ -336,11 +593,36 @@
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function categoryOptionsHtml() {
return lastCategories.map(path =>
`<option value="${escapeAttr(path)}">${escapeHtml(path.split('/').join(' / '))}</option>`
).join('');
}
// Shared across Sort Unsorted's own picker and Manage Library's Move
// panel, so both work the same regardless of whether the Unsorted
// folder exists (Sort Unsorted's own scan skips categories entirely
// when there's nothing to sort).
function loadCategories() {
return fetch('/api/library/categories')
.then(r => r.json())
.then(data => { lastCategories = data.categories || []; })
.catch(() => {});
}
function renderRows(items) {
const container = document.getElementById('sort-rows');
const optionsHtml = categoryOptionsHtml();
container.innerHTML = items.map((item, idx) => {
const checked = item.type !== 'manual' ? 'checked' : '';
const destValue = item.destination || '';
// An 'existing' proposal always names a real folder already in
// lastCategories, so it's the only case the <select> can point
// straight at; 'new_folder' and 'manual' (and any hand-edited
// destination that no longer matches a real folder) fall back
// to the "create new" option with the free-text path exposed.
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
return `
<div class="sort-row" data-idx="${idx}">
<input type="checkbox" class="sort-row-check" ${checked}>
@@ -350,15 +632,40 @@
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
</div>
<div class="sort-row-reason">${escapeHtml(item.reason || '')}</div>
</div>
<div class="sort-row-dest">
${ICON_SVGS.folder}
<input type="text" class="dest-input ${destValue ? '' : 'empty'}" value="${escapeAttr(destValue)}" placeholder="e.g. IT/AWS">
<div class="sort-row-fields">
<div class="sort-field">
<label>Folder name</label>
<input type="text" class="rename-input" value="${escapeAttr(item.name)}">
</div>
<div class="sort-field">
<label>Destination folder</label>
<div class="dest-picker">
${ICON_SVGS.folder}
<select class="dest-select" data-initial="${escapeAttr(isNew ? NEW_FOLDER_VALUE : destValue)}">
${optionsHtml}
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="dest-new-input ${isNew && !destValue ? 'empty' : ''}"
style="display: ${isNew ? 'block' : 'none'};"
value="${escapeAttr(isNew ? destValue : '')}" placeholder="e.g. IT/NewCategory">
</div>
</div>
</div>
</div>`;
}).join('');
container.querySelectorAll('.dest-input').forEach(input => {
container.querySelectorAll('.dest-select').forEach(select => {
select.value = select.dataset.initial;
const row = select.closest('.sort-row');
const newInput = row.querySelector('.dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
});
container.querySelectorAll('.dest-new-input').forEach(input => {
input.addEventListener('input', () => {
input.classList.toggle('empty', input.value.trim() === '');
});
@@ -418,14 +725,18 @@
if (!checkbox.checked) return;
const idx = parseInt(row.dataset.idx, 10);
const item = lastItems[idx];
const destInput = row.querySelector('.dest-input');
const destination = destInput.value.trim();
const select = row.querySelector('.dest-select');
const newInput = row.querySelector('.dest-new-input');
const renameInput = row.querySelector('.rename-input');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
const name = renameInput.value.trim();
if (!destination) {
skippedEmpty.push(item.name);
destInput.classList.add('empty');
if (usingNew) newInput.classList.add('empty');
return;
}
moves.push({ source: item.path, destination });
moves.push({ source: item.path, destination, name });
});
if (skippedEmpty.length) {
@@ -464,7 +775,7 @@
if (errorCount) {
console.warn('Unsorted apply errors:', data.errors);
}
scanUnsorted();
loadCategories().then(() => scanUnsorted());
})
.catch(() => {
btn.disabled = false;
@@ -473,7 +784,558 @@
});
}
scanUnsorted();
function refreshLibraryCache() {
const status = document.getElementById('library-refresh-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Refreshing…';
fetch('/api/library/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
// ---- Bulk rename: find/replace across every course/folder name ----
let bulkRenameMatches = [];
function previewBulkRename() {
const pattern = document.getElementById('bulk-rename-find').value;
const replacement = document.getElementById('bulk-rename-replace').value;
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
preview.innerHTML = '';
if (!pattern) {
status.style.color = 'var(--error)';
status.textContent = 'Enter text to find first.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Searching...';
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
.then(r => r.json())
.then(data => {
bulkRenameMatches = data.matches || [];
renderBulkRenamePreview();
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function renderBulkRenamePreview() {
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
if (bulkRenameMatches.length === 0) {
status.style.color = 'var(--text-muted)';
status.textContent = 'No matches.';
preview.innerHTML = '';
return;
}
status.textContent = '';
const rows = bulkRenameMatches.map((m, i) => `
<div class="bulk-rename-row">
<div class="bulk-rename-row-names">
<div class="bulk-rename-old">${m.old_name}</div>
<div class="bulk-rename-new">→ ${m.new_name}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
</div>
`).join('');
const count = bulkRenameMatches.length;
preview.innerHTML = rows +
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
}
function removeBulkRenameMatch(index) {
bulkRenameMatches.splice(index, 1);
renderBulkRenamePreview();
}
function applyBulkRename() {
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
const status = document.getElementById('bulk-rename-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Applying...';
fetch('/api/bulk-rename/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: bulkRenameMatches })
})
.then(r => r.json())
.then(data => {
const results = data.results || [];
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
const activeCourseReset = results.some(r => r.active_course_reset);
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
let message = `${succeeded} renamed`;
if (failed.length) {
message += `, ${failed.length} failed: ` +
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
} else {
message += '.';
}
if (activeCourseReset) {
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
}
status.textContent = message;
bulkRenameMatches = [];
document.getElementById('bulk-rename-preview').innerHTML = '';
document.getElementById('bulk-rename-find').value = '';
document.getElementById('bulk-rename-replace').value = '';
loadHiddenList();
loadCategories();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Manage Library: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
.then(r => r.json())
.then(data => {
const wrap = document.getElementById('hidden-list-wrap');
const list = document.getElementById('hidden-list');
const items = data.hidden_paths || [];
if (items.length === 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
list.innerHTML = items.map(item => `
<div class="curate-row">
<div class="curate-row-name">
<span>${ICON_SVGS.ban}</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).join('');
})
.catch(() => {});
}
function loadCurateLevel(path, container, isRoot) {
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
}
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
content.dataset.loaded = 'true';
content.dataset.path = path;
loadCurateLevel(path, content, false);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
})
.catch(() => {});
}
// ---- Manage Library: rename a course/folder in place on disk ----
function startRename(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
if (!row || !nameSpan) return;
let settled = false;
const input = document.createElement('input');
input.type = 'text';
input.className = 'curate-rename-input';
input.value = currentName;
input.onclick = (e) => e.stopPropagation();
nameSpan.replaceWith(input);
input.focus();
input.select();
function cancel() {
if (settled) return;
settled = true;
input.replaceWith(nameSpan);
}
function commit() {
if (settled) return;
const newName = input.value.trim();
if (!newName || newName === currentName) {
cancel();
return;
}
settled = true;
submitRename(btnEl, path, newName, row, nameSpan, input);
}
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
}
function submitRename(btnEl, path, newName, row, nameSpan, input) {
fetch('/api/rename-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, new_name: newName })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
showRowError(row, data.error || 'Rename failed');
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
input.replaceWith(nameSpan);
showRowError(row, 'Could not reach the server');
});
}
function showRowError(row, message) {
let status = row.querySelector('.curate-row-status');
if (!status) {
status = document.createElement('div');
status.className = 'curate-row-status';
row.appendChild(status);
}
status.textContent = message;
}
// ---- Manage Library: move a course/folder elsewhere in the library ----
function startMove(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
if (!row) return;
const existing = row.nextElementSibling;
if (existing && existing.classList.contains('curate-move-panel')) {
existing.remove();
return;
}
document.querySelectorAll('.curate-move-panel').forEach(p => p.remove());
const optionsHtml = categoryOptionsHtml();
const panel = document.createElement('div');
panel.className = 'curate-move-panel';
panel.innerHTML = `
<div class="sort-field">
<label>Move "${escapeHtml(currentName)}" to</label>
<div class="dest-picker">
${ICON_SVGS.folder}
<select class="move-dest-select">
${optionsHtml}
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="move-dest-new-input text-input" style="display: none; margin-top: 6px;" placeholder="e.g. IT/NewCategory">
</div>
<div class="curate-move-actions">
<button class="btn btn-sm">Move</button>
<button class="btn btn-secondary btn-sm">Cancel</button>
</div>
<div class="curate-move-status"></div>
`;
row.after(panel);
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
if (!optionsHtml) {
select.value = NEW_FOLDER_VALUE;
newInput.style.display = 'block';
newInput.focus();
}
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
cancelBtn.onclick = () => panel.remove();
moveBtn.onclick = () => submitMove(panel, btnEl, path);
}
function submitMove(panel, btnEl, path) {
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
const status = panel.querySelector('.curate-move-status');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
if (!destination) {
status.style.color = 'var(--error)';
status.textContent = 'Choose or type a destination first.';
if (usingNew) newInput.focus();
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Moving…';
fetch('/api/library/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: path, destination })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
status.style.color = 'var(--error)';
status.textContent = data.error || 'Move failed';
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new location.');
}
loadHiddenList();
loadCategories();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Duplicate Courses ----
function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn');
const status = document.getElementById('dup-status');
const results = document.getElementById('dup-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/duplicates')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const groups = data.groups || [];
if (!groups.length) {
status.style.color = 'var(--success)';
status.textContent = `No likely duplicates found across ${data.course_count} course${data.course_count === 1 ? '' : 's'}.`;
return;
}
status.style.color = 'var(--text-muted)';
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>
${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>
</div>
`).join('')}
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function hideDuplicateCourse(btnEl, path) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: true })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btnEl.disabled = true;
btnEl.textContent = 'Hidden';
const row = btnEl.closest('.dup-course-row');
if (row) row.style.opacity = '0.5';
}
})
.catch(() => {});
}
// ---- Stale References ----
let lastStaleItems = [];
function scanStaleReferences() {
const btn = document.getElementById('stale-scan-btn');
const status = document.getElementById('stale-status');
const results = document.getElementById('stale-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/stale-references')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed' };
const items = [];
for (const category of ['hidden', 'next_up', 'recent_views']) {
for (const entry of (data[category] || [])) {
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
}
}
if (!items.length) {
status.style.color = 'var(--success)';
status.textContent = 'No stale references found.';
return;
}
status.textContent = '';
lastStaleItems = items;
results.innerHTML = `
<div id="stale-rows">${items.map((item, i) => `
<div class="bulk-rename-row" data-idx="${i}">
<div class="bulk-rename-row-names">
<span class="sort-badge new_folder">${item.label}</span>
${escapeHtml(item.name)}
</div>
<button class="btn btn-secondary btn-sm" onclick="removeStaleItem(${i})">Remove</button>
</div>
`).join('')}</div>
<button class="btn" style="margin-top: 10px;" onclick="cleanAllStaleReferences()">Remove All ${items.length}</button>
`;
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function removeStaleItem(index) {
const item = lastStaleItems[index];
if (!item) return;
fetch('/api/library/stale-references/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [{ category: item.category, path: item.path }] })
})
.then(r => r.json())
.then(data => {
if (data.success) scanStaleReferences();
})
.catch(() => {});
}
function cleanAllStaleReferences() {
if (!lastStaleItems.length) return;
if (!confirm(`Remove all ${lastStaleItems.length} stale reference(s)? This does not touch any files on disk.`)) return;
fetch('/api/library/stale-references/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: lastStaleItems.map(i => ({ category: i.category, path: i.path })) })
})
.then(r => r.json())
.then(() => scanStaleReferences())
.catch(() => {});
}
loadCategories().then(() => scanUnsorted());
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
</script>
</body>
</html>