diff --git a/README.md b/README.md index fb2cc01..06f101f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION index 9609c20..74e0a84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026-08-24 17:56 UTC — version display fix +2026-08-24 18:40 UTC — search, bulk actions, undo, storage usage diff --git a/offlineu_core.py b/offlineu_core.py index 8e4d294..3667e53 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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: