Add search, bulk actions, undo, and storage usage to File Management

- Manage Library search: find any course/folder by name anywhere in
  the tree instead of expanding levels one by one; results carry the
  same Move/Rename/Hide actions.
- Bulk select: every row gets a checkbox, so items found via search or
  expanded across different tree levels can be hidden, shown, or moved
  to the same destination together in one batch.
- Undo last action: a "Last action: ... [Undo]" bar appears after any
  move/rename (Sort Unsorted apply, Bulk Rename apply, a Manage
  Library move/rename, a bulk move) and reverses the whole batch.
  Deliberately never covers Hide/Show (already a one-click toggle) or
  Delete (permanent by design) - only ever move/rename, which are
  trivially reversible. Re-checks each item before reversing it, so a
  partial failure reports exactly what did and didn't reverse.
- Storage Usage: disk usage per top-level library folder, largest
  first, with a simple proportional bar; a manual scan since it reads
  every file's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 14:41:26 -04:00
co-authored by Claude Sonnet 5
parent 71d6158c60
commit ba8d7e2a1b
4 changed files with 780 additions and 54 deletions
+20 -1
View File
@@ -151,7 +151,23 @@ silently stay blank instead of erroring.
bulk-select elsewhere to hide or queue several at once), rename a
course/folder in place, or **move** one anywhere else in the library via
the same folder-picker/create-new-folder UI Sort Unsorted uses - handy
for correcting a bad auto-sort later or just reorganizing.
for correcting a bad auto-sort later or just reorganizing. A search box
finds any course/folder by name anywhere in the tree instead of
expanding levels one by one (results get the same Move/Rename/Hide
actions, just without an expand arrow, since a hit is somewhere specific
rather than a level to browse into); every row also has a checkbox, so
several items - found via search or expanded across different tree
levels - can be hidden, shown, or moved to the same destination together
in one batch instead of one at a time.
- *Undo*: a "Last action: ... [Undo]" bar appears after any move or
rename (Sort Unsorted apply, Bulk Rename apply, a Manage Library
move/rename, a bulk move) and reverses the whole batch in one click.
Only ever covers move/rename - hiding is already a one-click toggle with
nothing to undo, and Delete is permanent by design, so it's deliberately
never in this history no matter how "undo" gets framed. Re-checks each
item before reversing it (the original spot may have been reused since),
so a partial batch failure reports exactly what did and didn't reverse
rather than silently doing nothing.
- *Duplicate Courses*: scans every course in the library (Unsorted
included) for names that look like the same thing filed twice, using the
same tokenizer as Sort Unsorted so a platform-name or release-tag
@@ -171,6 +187,9 @@ silently stay blank instead of erroring.
the NAS instead of through the app (doing it through the app already
keeps these in sync). Review individually or clear them all at once;
course files themselves are never touched.
- *Storage Usage*: disk usage per top-level library folder, largest
first, with a simple proportional bar per entry - manually triggered
(it reads every file's size) rather than run automatically.
**Dashboard**
- Library-wide stats (courses, lessons completed, time watched, time
+1 -1
View File
@@ -1 +1 @@
2026-08-24 17:56 UTC — version display fix
2026-08-24 18:40 UTC — search, bulk actions, undo, storage usage
+301
View File
@@ -95,6 +95,34 @@ def cache_get_or_compute(key: str, compute, ttl: float = CACHE_TTL_SECONDS):
return value
# ---- Undo history for move/rename operations ----
#
# Only move and rename are ever recorded here - both are trivially
# reversible (move/rename the result back). Hide/show is already a
# one-click toggle with no need for "undo," and delete is permanent by
# design, so it's never in this history no matter how it's framed in the
# UI. Recorded as batches (not one entry per file) since the operations
# that populate this - Sort Unsorted apply, Bulk Rename apply, a bulk
# move - already act on several items as a single user action; undoing
# should mirror that, not require clicking "undo" N times.
_UNDO_HISTORY_MAX = 20
_undo_history: List[Dict[str, Any]] = []
def _record_undo_batch(label: str, entries: List[Dict[str, str]]) -> None:
"""Push one reversible batch - entries are {'old_path', 'new_path'}
pairs; the label is shown in the UI's Undo button. No-ops if entries
is empty (e.g. every item in a batch failed)."""
if not entries:
return
_undo_history.append({
'label': label,
'entries': entries,
'timestamp': datetime.now().isoformat(),
})
del _undo_history[:-_UNDO_HISTORY_MAX]
def invalidate_cache():
"""Clear all cached library-scan results - call after anything that changes what's on disk (rename, hide/show, library path change)."""
_cache_store.clear()
@@ -3015,6 +3043,57 @@ def browse_library_manage():
})
def search_manage_tree(query: str, library_root: str) -> List[Dict[str, Any]]:
"""
Every course or category folder anywhere in the library whose name
contains `query` (case-insensitive) - lets Manage Library jump
straight to something instead of manually expanding the tree level by
level. Includes hidden items (flagged 'hidden': True), unlike the
normal Library browser - finding something specifically to un-hide it
is exactly what this is for. Stops descending once a course is found,
same as everywhere else, so a course's internal chapter folders never
show up as if they were independently manageable.
"""
query_lower = query.lower().strip()
if not query_lower:
return []
hidden_set = set(get_hidden_paths())
results: List[Dict[str, Any]] = []
def walk(directory: Path):
try:
entries = sorted(
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
)
except (PermissionError, OSError):
return
for entry in entries:
is_course = _looks_like_course(entry)
is_hidden = os.path.abspath(str(entry)) in hidden_set
if query_lower in entry.name.lower():
results.append({
'type': 'course' if is_course else 'directory',
'name': entry.name,
'path': str(entry),
'hidden': is_hidden,
})
if not is_course:
walk(entry)
walk(Path(library_root))
return results
@app.route('/api/library/manage-search')
def manage_search_api():
"""Search endpoint backing Manage Library's search box (see
search_manage_tree)."""
query = request.args.get('q', '')
library_root = os.path.abspath(get_library_root())
return jsonify({'results': search_manage_tree(query, library_root)})
@app.route('/library/thumbnail')
def library_thumbnail():
"""Serve a course's cover image (see find_course_thumbnail), if it has one."""
@@ -3143,9 +3222,81 @@ def apply_unsorted_api():
errors.append({'source': source, 'error': str(e)})
invalidate_cache()
undo_entries = [{'old_path': m['source'], 'new_path': m['destination']} for m in moved]
_record_undo_batch(f'Sort Unsorted ({len(moved)} item{"" if len(moved) == 1 else "s"})', undo_entries)
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
def _directory_size_bytes(directory: Path, max_depth: int = 20) -> int:
"""Total size of every file anywhere under `directory` (bounded depth,
same caution used elsewhere in File Management for deep/unusual
trees). Skips anything it can't read rather than failing the whole
scan - a NAS with mixed permissions shouldn't block a size report for
everything else."""
total = 0
def walk(d: Path, depth: int) -> None:
nonlocal total
try:
entries = list(d.iterdir())
except (PermissionError, OSError):
return
for e in entries:
if e.name.startswith('.'):
continue
try:
if e.is_file():
total += e.stat().st_size
elif e.is_dir() and depth < max_depth:
walk(e, depth + 1)
except OSError:
continue
walk(directory, 0)
return total
def _format_bytes(n: int) -> str:
"""Human-readable size, e.g. 12.3 GB - matches the precision people
actually care about for a course library (no need for KB granularity)."""
size = float(n)
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
if size < 1024 or unit == 'TB':
return f'{size:.0f} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
size /= 1024
return f'{size:.1f} TB'
@app.route('/api/library/storage-usage')
def storage_usage_api():
"""
Disk usage per top-level library folder (category or a bare course
sitting directly at the root), sorted largest-first, for spotting
what's eating the most space on the NAS. Manually triggered like
Duplicate Courses - a full recursive size scan touches every file in
the library, too expensive to run automatically on page load.
"""
library_root = Path(get_library_root())
try:
top_level = sorted(
(p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
)
except (PermissionError, OSError) as e:
return jsonify({'error': str(e)}), 500
entries = []
for entry in top_level:
if entry.name == UNSORTED_FOLDER_NAME:
continue
size = _directory_size_bytes(entry)
entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)})
entries.sort(key=lambda e: e['bytes'], reverse=True)
total_bytes = sum(e['bytes'] for e in entries)
return jsonify({'entries': entries, 'total_bytes': total_bytes, 'total_human': _format_bytes(total_bytes)})
@app.route('/api/library/categories')
def library_categories_api():
"""Every category/subcategory folder in the library, for destination
@@ -3210,6 +3361,8 @@ def move_library_item_api():
invalidate_cache()
rebase_library_path(source_abs, final_path)
_record_undo_batch(f'Move "{os.path.basename(source_abs)}"',
[{'old_path': source_abs, 'new_path': final_path}])
global current_course
active_course_reset = False
@@ -3222,6 +3375,145 @@ def move_library_item_api():
return jsonify({'success': True, 'new_path': final_path, 'active_course_reset': active_course_reset})
@app.route('/api/library/bulk-move', methods=['POST'])
def bulk_move_library_items_api():
"""
Move several courses/folders already in the library to the same
destination in one batch - the multi-select complement to
/api/library/move for reorganizing many items at once. Same safety
checks as a single move, applied per item; continues past individual
failures and reports each outcome, the same pattern Bulk Rename uses.
"""
data = request.json or {}
sources = data.get('sources') or []
destination_relative = (data.get('destination') or '').strip().strip('/')
if not isinstance(sources, list) or not sources or not destination_relative:
return jsonify({'success': False, 'error': 'sources and destination are required'}), 400
library_root = os.path.abspath(get_library_root())
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
global current_course
results = []
undo_entries = []
active_course_reset = False
for source in sources:
source_abs = os.path.abspath(source)
result = {'source': source}
if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)) or source_abs == library_root:
result.update(success=False, error='Source is outside the library')
elif dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
result.update(success=False, error='Cannot move a folder into itself')
elif not os.path.isdir(source_abs):
result.update(success=False, error='No longer exists')
else:
final_name = os.path.basename(source_abs)
final_path = os.path.join(dest_dir, final_name)
if final_path == source_abs:
result.update(success=False, error='Already there')
elif os.path.exists(final_path):
result.update(success=False, error=f'"{final_name}" already exists at that destination')
else:
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(source_abs, final_path)
rebase_library_path(source_abs, final_path)
undo_entries.append({'old_path': source_abs, 'new_path': final_path})
result.update(success=True, new_path=final_path)
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
current_course = None
active_course_reset = True
except OSError as e:
result.update(success=False, error=str(e))
results.append(result)
invalidate_cache()
moved_count = len(undo_entries)
_record_undo_batch(f'Bulk move ({moved_count} item{"" if moved_count == 1 else "s"}) to "{destination_relative}"',
undo_entries)
return jsonify({'success': True, 'moved': moved_count, 'results': results, 'active_course_reset': active_course_reset})
@app.route('/api/library/undo-last')
def undo_last_status_api():
"""Whether there's a move/rename batch available to undo, and what it
was - lets the UI show a specific "Undo: <label>" instead of a blind
button, or hide it entirely when there's nothing to undo."""
if not _undo_history:
return jsonify({'available': False})
batch = _undo_history[-1]
return jsonify({'available': True, 'label': batch['label'], 'count': len(batch['entries'])})
@app.route('/api/library/undo-last', methods=['POST'])
def undo_last_action_api():
"""
Reverse the most recent move/rename batch (Sort Unsorted apply, Bulk
Rename apply, a Manage Library move/rename, a bulk move) as a single
action, moving/renaming each entry back to where it came from.
Re-checks every entry before touching it - the destination must still
exist and the original location must still be free - since the
filesystem may have changed since the batch was recorded; a partial
failure still pops the batch (retrying the same undo again wouldn't
help) and reports exactly what did and didn't reverse.
"""
if not _undo_history:
return jsonify({'success': False, 'error': 'Nothing to undo'}), 400
batch = _undo_history.pop()
library_root = os.path.abspath(get_library_root())
results = []
global current_course
active_course_reset = False
for entry in reversed(batch['entries']):
old_abs = os.path.abspath(entry['old_path'])
new_abs = os.path.abspath(entry['new_path'])
result = {'old_path': entry['old_path'], 'new_path': entry['new_path']}
if not (new_abs == library_root or new_abs.startswith(library_root + os.sep)):
result.update(success=False, error='Outside the library')
elif not os.path.isdir(new_abs):
result.update(success=False, error='No longer exists - already moved or renamed again?')
elif os.path.exists(old_abs):
result.update(success=False, error='Original location is occupied again')
else:
try:
os.makedirs(os.path.dirname(old_abs), exist_ok=True)
shutil.move(new_abs, old_abs)
rebase_library_path(new_abs, old_abs)
result['success'] = True
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == new_abs or course_abs.startswith(new_abs + os.sep):
current_course = None
active_course_reset = True
except OSError as e:
result.update(success=False, error=str(e))
results.append(result)
invalidate_cache()
reversed_count = sum(1 for r in results if r['success'])
return jsonify({
'success': True,
'label': batch['label'],
'reversed': reversed_count,
'total': len(results),
'results': results,
'active_course_reset': active_course_reset,
})
@app.route('/api/library/duplicates')
def duplicate_courses_api():
"""Scan for courses that look like copies of each other by name (see
@@ -3537,6 +3829,9 @@ def rename_path_api():
return jsonify({'error': 'path is required'}), 400
result = perform_rename(path, new_name)
if result.get('success'):
_record_undo_batch(f'Rename "{os.path.basename(path)}"',
[{'old_path': os.path.abspath(path), 'new_path': result['new_path']}])
status = result.pop('status')
return jsonify(result), status
@@ -3658,6 +3953,7 @@ def bulk_rename_apply_api():
return jsonify({'error': 'items is required'}), 400
results = []
undo_entries = []
for item in items:
path = item.get('path', '')
new_name = item.get('new_name', '')
@@ -3666,8 +3962,13 @@ def bulk_rename_apply_api():
outcome['path'] = path
outcome['old_name'] = item.get('old_name', '')
outcome['new_name'] = new_name
if outcome.get('success'):
undo_entries.append({'old_path': os.path.abspath(path), 'new_path': outcome['new_path']})
results.append(outcome)
succeeded = len(undo_entries)
_record_undo_batch(f'Bulk rename ({succeeded} item{"" if succeeded == 1 else "s"})', undo_entries)
return jsonify({'results': results})
+433 -27
View File
@@ -286,6 +286,70 @@
overflow-wrap: break-word;
word-break: break-word;
}
.undo-bar {
display: none;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 20px;
font-size: 0.9em;
}
.manage-bulk-bar {
display: none;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 0.9em;
}
.manage-bulk-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.storage-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-top: 1px solid var(--border-color);
}
.storage-row:first-of-type {
border-top: none;
}
.storage-row-name {
flex: 0 0 160px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storage-bar-track {
flex: 1;
height: 10px;
background: var(--bg-primary);
border-radius: 5px;
overflow: hidden;
}
.storage-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 5px;
}
.storage-row-size {
flex: 0 0 70px;
text-align: right;
color: var(--text-muted);
font-size: 0.9em;
}
.toolbar {
display: flex;
align-items: center;
@@ -501,6 +565,11 @@
<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>
<div id="undo-bar" class="undo-bar">
<span id="undo-label"></span>
<button class="btn btn-secondary btn-sm" id="undo-btn" onclick="performUndo()">{{ icons.icon('refresh', 14) }} Undo</button>
</div>
<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>
@@ -555,17 +624,46 @@
</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>
<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. Select several to hide or move at once.</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 class="toolbar" style="margin-bottom: 10px;">
<input type="text" id="manage-search-input" class="text-input" placeholder="Search courses and folders…" oninput="onManageSearchInput()">
<button class="btn btn-secondary btn-sm" id="manage-search-clear" onclick="clearManageSearch()" style="display: none;">Clear</button>
</div>
<div id="manage-bulk-bar" class="manage-bulk-bar">
<span id="manage-bulk-count"></span>
<div class="manage-bulk-actions">
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(true)">Hide Selected</button>
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(false)">Show Selected</button>
<button class="btn btn-secondary btn-sm" onclick="startBulkMove()">Move Selected…</button>
<button class="btn btn-secondary btn-sm" onclick="clearManageSelection()">Clear</button>
</div>
</div>
<div id="manage-bulk-move-panel"></div>
<div id="manage-browse-wrap">
<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 id="manage-search-results" style="display: none;"></div>
</div>
<h2 class="section-title">{{ icons.icon('bar-chart', 18) }} Storage Usage</h2>
<p class="section-desc">Disk usage per top-level library folder, largest first - handy for spotting what's eating the most space on the NAS. Scans every file in the library, so it's a manual trigger rather than something that runs automatically.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
<span id="storage-status"></span>
</div>
<div id="storage-results"></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>
@@ -878,6 +976,7 @@
console.warn('Unsorted apply errors:', data.errors);
}
loadCategories().then(() => scanUnsorted());
checkUndoStatus();
})
.catch(() => {
btn.disabled = false;
@@ -1015,6 +1114,7 @@
loadHiddenList();
loadCategories();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
checkUndoStatus();
})
.catch(() => {
status.style.color = 'var(--error)';
@@ -1022,6 +1122,254 @@
});
}
// ---- Undo last move/rename ----
function checkUndoStatus() {
fetch('/api/library/undo-last')
.then(r => r.json())
.then(data => {
const bar = document.getElementById('undo-bar');
if (data.available) {
document.getElementById('undo-label').textContent = `Last action: ${data.label}`;
bar.style.display = 'flex';
} else {
bar.style.display = 'none';
}
})
.catch(() => {});
}
function performUndo() {
const btn = document.getElementById('undo-btn');
btn.disabled = true;
fetch('/api/library/undo-last', { method: 'POST' })
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
btn.disabled = false;
if (!ok || !data.success) {
alert((data && data.error) || 'Undo failed');
checkUndoStatus();
return;
}
if (data.active_course_reset) {
alert('That undo affected your active course - reload the main page to pick it up.');
}
const failed = (data.results || []).filter(r => !r.success);
if (failed.length) {
alert(`Undid ${data.reversed} of ${data.total} - ${failed.length} couldn't be reversed (see console).`);
console.warn('Undo errors:', failed);
}
loadHiddenList();
loadCategories();
refreshManageView();
checkUndoStatus();
})
.catch(() => {
btn.disabled = false;
alert('Could not reach the server.');
});
}
// ---- Manage Library: bulk selection ----
let selectedManagePaths = new Map(); // path -> name
function curateCheckboxHtml(item) {
const checked = selectedManagePaths.has(item.path) ? 'checked' : '';
return `<input type="checkbox" class="sort-row-check curate-select" data-path="${escapeAttr(item.path)}" data-name="${escapeAttr(item.name)}" ${checked} onclick="event.stopPropagation(); toggleManageSelect(this)">`;
}
function toggleManageSelect(checkboxEl) {
const path = checkboxEl.dataset.path;
if (checkboxEl.checked) {
selectedManagePaths.set(path, checkboxEl.dataset.name);
} else {
selectedManagePaths.delete(path);
}
updateManageBulkBar();
}
function clearManageSelection() {
selectedManagePaths.clear();
document.querySelectorAll('.curate-select').forEach(cb => { cb.checked = false; });
updateManageBulkBar();
}
function updateManageBulkBar() {
const bar = document.getElementById('manage-bulk-bar');
const count = selectedManagePaths.size;
document.getElementById('manage-bulk-count').textContent = `${count} selected`;
bar.style.display = count > 0 ? 'flex' : 'none';
if (count === 0) {
document.getElementById('manage-bulk-move-panel').innerHTML = '';
}
}
function isManageSearchActive() {
return document.getElementById('manage-search-results').style.display !== 'none';
}
// Bulk actions can touch items selected from several different
// expanded tree levels (or search results) at once, so there's no
// single "level" left to refresh afterward - just reload whichever
// view is currently showing from scratch.
function refreshManageView() {
if (isManageSearchActive()) {
runManageSearch();
} else {
loadCurateLevel(null, document.getElementById('curate-tree'), true);
}
}
function bulkSetHidden(hidden) {
const paths = Array.from(selectedManagePaths.keys());
if (!paths.length) return;
fetch('/api/hidden-paths/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths, hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
clearManageSelection();
loadHiddenList();
refreshManageView();
}
})
.catch(() => {});
}
function startBulkMove() {
const panelWrap = document.getElementById('manage-bulk-move-panel');
if (panelWrap.innerHTML.trim()) {
panelWrap.innerHTML = '';
return;
}
const count = selectedManagePaths.size;
const optionsHtml = categoryOptionsHtml();
panelWrap.innerHTML = `
<div class="curate-move-panel">
<div class="sort-field">
<label>Move ${count} selected item${count === 1 ? '' : 's'} 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>
${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
</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>
</div>
`;
const panel = panelWrap.querySelector('.curate-move-panel');
const select = panel.querySelector('.move-dest-select');
wireNewFolderFields(select, panel.querySelector('.sort-field'));
if (!optionsHtml) {
select.value = NEW_FOLDER_VALUE;
panel.querySelector('.new-folder-name').focus();
}
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
cancelBtn.onclick = () => { panelWrap.innerHTML = ''; };
moveBtn.onclick = () => submitBulkMove(panel);
}
function submitBulkMove(panel) {
const select = panel.querySelector('.move-dest-select');
const fieldsWrap = panel.querySelector('.sort-field');
const status = panel.querySelector('.curate-move-status');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
if (!destination) {
status.style.color = 'var(--error)';
status.textContent = 'Choose or type a destination first.';
if (usingNew) panel.querySelector('.new-folder-name').focus();
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Moving…';
const sources = Array.from(selectedManagePaths.keys());
fetch('/api/library/bulk-move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sources, 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 && data.error) || 'Move failed';
return;
}
if (data.active_course_reset) {
alert('Your active course was moved - reload the main page to pick it up under its new location.');
}
const failedCount = (data.results || []).filter(r => !r.success).length;
clearManageSelection();
loadHiddenList();
loadCategories();
refreshManageView();
checkUndoStatus();
if (failedCount) {
alert(`Moved ${data.moved}, ${failedCount} failed - see console for details.`);
console.warn('Bulk move errors:', data.results.filter(r => !r.success));
}
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Manage Library: search ----
let manageSearchDebounce = null;
function onManageSearchInput() {
clearTimeout(manageSearchDebounce);
const query = document.getElementById('manage-search-input').value.trim();
document.getElementById('manage-search-clear').style.display = query ? 'inline-flex' : 'none';
if (!query) {
clearManageSearch();
return;
}
manageSearchDebounce = setTimeout(runManageSearch, 250);
}
function runManageSearch() {
const query = document.getElementById('manage-search-input').value.trim();
if (!query) return;
const resultsEl = document.getElementById('manage-search-results');
document.getElementById('manage-browse-wrap').style.display = 'none';
resultsEl.style.display = 'block';
fetch(`/api/library/manage-search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => renderManageSearchResults(data.results || []))
.catch(() => {
resultsEl.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function clearManageSearch() {
document.getElementById('manage-search-input').value = '';
document.getElementById('manage-search-clear').style.display = 'none';
document.getElementById('manage-search-results').style.display = 'none';
document.getElementById('manage-search-results').innerHTML = '';
document.getElementById('manage-browse-wrap').style.display = 'block';
}
function renderManageSearchResults(items) {
const container = document.getElementById('manage-search-results');
if (!items.length) {
container.innerHTML = '<div class="curate-empty-hint">No matches.</div>';
return;
}
container.innerHTML = items.map(item => curateRowHtml(item, false)).join('');
}
// ---- Manage Library: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
@@ -1063,28 +1411,28 @@
});
}
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 => {
// Shared by the tree (expandable=true) and search results
// (expandable=false, since a flat hit list has nowhere to expand
// into) - keeps Move/Rename/Hide/select behavior identical either
// way instead of maintaining two versions of the same row markup.
function curateRowHtml(item, expandable) {
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 checkbox = curateCheckboxHtml(item);
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') {
if (item.type === 'course' || !expandable) {
return `
<div class="curate-row ${hiddenClass}">
${checkbox}
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
<span class="name">${escapeHtml(item.name)}</span>
</div>
${actions}
</div>
@@ -1092,16 +1440,25 @@
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
${checkbox}
<div class="curate-row-name">
<span class="curate-toggle"></span>
<span>${icon}</span>
<span class="name">${item.name}</span>
<span class="name">${escapeHtml(item.name)}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
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 => curateRowHtml(item, true)).join('');
}
function toggleCurateDir(rowEl, path) {
@@ -1124,6 +1481,22 @@
loadCurateLevel(path, content, false);
}
// Refreshes wherever a single-row action happened: the tree level
// it was in (root or one expanded folder), or re-runs the search
// if that's the active view. Bulk actions use refreshManageView()
// instead, since a bulk selection can span several levels at once.
function refreshManageContainer(btnEl) {
if (isManageSearchActive()) {
runManageSearch();
return;
}
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);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
@@ -1134,13 +1507,7 @@
.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);
refreshManageContainer(btnEl);
}
})
.catch(() => {});
@@ -1202,10 +1569,8 @@
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);
refreshManageContainer(btnEl);
checkUndoStatus();
})
.catch(() => {
input.replaceWith(nameSpan);
@@ -1300,10 +1665,8 @@
}
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);
refreshManageContainer(btnEl);
checkUndoStatus();
})
.catch(() => {
status.style.color = 'var(--error)';
@@ -1311,6 +1674,48 @@
});
}
// ---- Storage Usage ----
function scanStorageUsage() {
const btn = document.getElementById('storage-scan-btn');
const status = document.getElementById('storage-status');
const results = document.getElementById('storage-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning… this reads every file, may take a moment.';
results.innerHTML = '';
fetch('/api/library/storage-usage')
.then(r => r.json())
.then(data => {
btn.disabled = false;
if (data.error) {
status.style.color = 'var(--error)';
status.textContent = data.error;
return;
}
const entries = data.entries || [];
if (!entries.length) {
status.style.color = 'var(--text-muted)';
status.textContent = 'Nothing found.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = `Total: ${data.total_human}`;
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
results.innerHTML = entries.map(e => `
<div class="storage-row">
<span class="storage-row-name">${escapeHtml(e.name)}</span>
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
<span class="storage-row-size">${escapeHtml(e.human)}</span>
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Duplicate Courses ----
function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn');
@@ -1578,6 +1983,7 @@
loadCategories().then(() => scanUnsorted());
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
checkUndoStatus();
</script>
</body>
</html>