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:
@@ -151,7 +151,23 @@ silently stay blank instead of erroring.
|
|||||||
bulk-select elsewhere to hide or queue several at once), rename a
|
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
|
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
|
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
|
- *Duplicate Courses*: scans every course in the library (Unsorted
|
||||||
included) for names that look like the same thing filed twice, using the
|
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
|
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
|
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;
|
keeps these in sync). Review individually or clear them all at once;
|
||||||
course files themselves are never touched.
|
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**
|
**Dashboard**
|
||||||
- Library-wide stats (courses, lessons completed, time watched, time
|
- Library-wide stats (courses, lessons completed, time watched, time
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-08-24 17:56 UTC — version display fix
|
2026-08-24 18:40 UTC — search, bulk actions, undo, storage usage
|
||||||
|
|||||||
@@ -95,6 +95,34 @@ def cache_get_or_compute(key: str, compute, ttl: float = CACHE_TTL_SECONDS):
|
|||||||
return value
|
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():
|
def invalidate_cache():
|
||||||
"""Clear all cached library-scan results - call after anything that changes what's on disk (rename, hide/show, library path change)."""
|
"""Clear all cached library-scan results - call after anything that changes what's on disk (rename, hide/show, library path change)."""
|
||||||
_cache_store.clear()
|
_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')
|
@app.route('/library/thumbnail')
|
||||||
def library_thumbnail():
|
def library_thumbnail():
|
||||||
"""Serve a course's cover image (see find_course_thumbnail), if it has one."""
|
"""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)})
|
errors.append({'source': source, 'error': str(e)})
|
||||||
|
|
||||||
invalidate_cache()
|
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})
|
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')
|
@app.route('/api/library/categories')
|
||||||
def library_categories_api():
|
def library_categories_api():
|
||||||
"""Every category/subcategory folder in the library, for destination
|
"""Every category/subcategory folder in the library, for destination
|
||||||
@@ -3210,6 +3361,8 @@ def move_library_item_api():
|
|||||||
|
|
||||||
invalidate_cache()
|
invalidate_cache()
|
||||||
rebase_library_path(source_abs, final_path)
|
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
|
global current_course
|
||||||
active_course_reset = False
|
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})
|
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')
|
@app.route('/api/library/duplicates')
|
||||||
def duplicate_courses_api():
|
def duplicate_courses_api():
|
||||||
"""Scan for courses that look like copies of each other by name (see
|
"""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
|
return jsonify({'error': 'path is required'}), 400
|
||||||
|
|
||||||
result = perform_rename(path, new_name)
|
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')
|
status = result.pop('status')
|
||||||
return jsonify(result), status
|
return jsonify(result), status
|
||||||
|
|
||||||
@@ -3658,6 +3953,7 @@ def bulk_rename_apply_api():
|
|||||||
return jsonify({'error': 'items is required'}), 400
|
return jsonify({'error': 'items is required'}), 400
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
undo_entries = []
|
||||||
for item in items:
|
for item in items:
|
||||||
path = item.get('path', '')
|
path = item.get('path', '')
|
||||||
new_name = item.get('new_name', '')
|
new_name = item.get('new_name', '')
|
||||||
@@ -3666,8 +3962,13 @@ def bulk_rename_apply_api():
|
|||||||
outcome['path'] = path
|
outcome['path'] = path
|
||||||
outcome['old_name'] = item.get('old_name', '')
|
outcome['old_name'] = item.get('old_name', '')
|
||||||
outcome['new_name'] = new_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)
|
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})
|
return jsonify({'results': results})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+433
-27
@@ -286,6 +286,70 @@
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: 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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -501,6 +565,11 @@
|
|||||||
<h1>{{ icons.icon('folder', 24) }} File Management</h1>
|
<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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">{{ icons.icon('folder', 18) }} Manage Library</h2>
|
<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 class="card">
|
||||||
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
<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 style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
|
||||||
<div id="hidden-list"></div>
|
<div id="hidden-list"></div>
|
||||||
</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 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-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||||||
<div id="curate-tree"></div>
|
<div id="curate-tree"></div>
|
||||||
</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>
|
<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>
|
<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);
|
console.warn('Unsorted apply errors:', data.errors);
|
||||||
}
|
}
|
||||||
loadCategories().then(() => scanUnsorted());
|
loadCategories().then(() => scanUnsorted());
|
||||||
|
checkUndoStatus();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -1015,6 +1114,7 @@
|
|||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
loadCategories();
|
loadCategories();
|
||||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
checkUndoStatus();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
status.style.color = 'var(--error)';
|
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 ----
|
// ---- Manage Library: hide/show courses & folders ----
|
||||||
function loadHiddenList() {
|
function loadHiddenList() {
|
||||||
fetch('/api/hidden-paths')
|
fetch('/api/hidden-paths')
|
||||||
@@ -1063,28 +1411,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCurateLevel(data, container) {
|
// Shared by the tree (expandable=true) and search results
|
||||||
if (!data.items || data.items.length === 0) {
|
// (expandable=false, since a flat hit list has nowhere to expand
|
||||||
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
|
// into) - keeps Move/Rename/Hide/select behavior identical either
|
||||||
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
|
// way instead of maintaining two versions of the same row markup.
|
||||||
return;
|
function curateRowHtml(item, expandable) {
|
||||||
}
|
|
||||||
container.innerHTML = data.items.map(item => {
|
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
const safeName = item.name.replace(/'/g, "\\'");
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
|
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
|
||||||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
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 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 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 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>`;
|
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
|
||||||
|
|
||||||
if (item.type === 'course') {
|
if (item.type === 'course' || !expandable) {
|
||||||
return `
|
return `
|
||||||
<div class="curate-row ${hiddenClass}">
|
<div class="curate-row ${hiddenClass}">
|
||||||
|
${checkbox}
|
||||||
<div class="curate-row-name">
|
<div class="curate-row-name">
|
||||||
<span>${icon}</span>
|
<span>${icon}</span>
|
||||||
<span class="name">${item.name}</span>
|
<span class="name">${escapeHtml(item.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
${actions}
|
${actions}
|
||||||
</div>
|
</div>
|
||||||
@@ -1092,16 +1440,25 @@
|
|||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
||||||
|
${checkbox}
|
||||||
<div class="curate-row-name">
|
<div class="curate-row-name">
|
||||||
<span class="curate-toggle">▶</span>
|
<span class="curate-toggle">▶</span>
|
||||||
<span>${icon}</span>
|
<span>${icon}</span>
|
||||||
<span class="name">${item.name}</span>
|
<span class="name">${escapeHtml(item.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
${actions}
|
${actions}
|
||||||
</div>
|
</div>
|
||||||
<div class="curate-children" data-loaded="false"></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) {
|
function toggleCurateDir(rowEl, path) {
|
||||||
@@ -1124,6 +1481,22 @@
|
|||||||
loadCurateLevel(path, content, false);
|
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) {
|
function toggleHidden(path, hidden, btnEl) {
|
||||||
fetch('/api/hidden-paths', {
|
fetch('/api/hidden-paths', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1134,13 +1507,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
// Refresh just the level this toggle happened in, rather
|
refreshManageContainer(btnEl);
|
||||||
// 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(() => {});
|
.catch(() => {});
|
||||||
@@ -1202,10 +1569,8 @@
|
|||||||
alert('That was your active course - reload the main page to pick it up under its new name.');
|
alert('That was your active course - reload the main page to pick it up under its new name.');
|
||||||
}
|
}
|
||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
refreshManageContainer(btnEl);
|
||||||
const isRootContainer = container.id === 'curate-tree';
|
checkUndoStatus();
|
||||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
|
||||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
input.replaceWith(nameSpan);
|
input.replaceWith(nameSpan);
|
||||||
@@ -1300,10 +1665,8 @@
|
|||||||
}
|
}
|
||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
loadCategories();
|
loadCategories();
|
||||||
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
refreshManageContainer(btnEl);
|
||||||
const isRootContainer = container.id === 'curate-tree';
|
checkUndoStatus();
|
||||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
|
||||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
status.style.color = 'var(--error)';
|
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 ----
|
// ---- Duplicate Courses ----
|
||||||
function scanDuplicates() {
|
function scanDuplicates() {
|
||||||
const btn = document.getElementById('dup-scan-btn');
|
const btn = document.getElementById('dup-scan-btn');
|
||||||
@@ -1578,6 +1983,7 @@
|
|||||||
loadCategories().then(() => scanUnsorted());
|
loadCategories().then(() => scanUnsorted());
|
||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
checkUndoStatus();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user