Add Resume button, Surprise Me random pick, and bulk select

Three quality-of-life additions to the Library browser and course
pages:

- A course page shows a "Resume: <lesson>" button when there's a
  last-visited lesson, using Course.last_accessed_path (already
  populated by apply_progress_to_tree, just not surfaced before) -
  no more scrolling the tree to find where you left off.

- "Surprise Me" picks a random not-yet-fully-watched course and loads
  it, for when there's too much library to decide what to watch.
  Reuses the already-cached get_all_course_dirs() and the same
  per-course completed/total shape _scan_library_activity() already
  computes for Stale Courses - no new scanning.

- A select-mode toggle in the Library browser adds checkboxes to every
  row/card (list and grid) with a bulk-action bar to hide or queue
  several courses/folders at once, via two new endpoints
  (/api/hidden-paths/bulk, /api/next-up/bulk) that reuse the existing
  single-item set_path_hidden()/set_path_queued() in a loop. Selection
  is scoped to the current folder view and clears on navigation.

Also fixes runTranscriptSearch() going silently blank on zero results
instead of showing a "no matches" message, and updates the Help page
to cover all of this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:06:29 -04:00
co-authored by Claude Sonnet 5
parent 8f6b00280f
commit 9e7dc85558
3 changed files with 277 additions and 15 deletions
+68 -1
View File
@@ -7,6 +7,7 @@ Enhanced version with dynamic subdirectory navigation
import os
import json
import mimetypes
import random
import re
import sys
import argparse
@@ -1120,6 +1121,19 @@ def _scan_library_activity() -> Dict[str, Any]:
}
def get_random_incomplete_course() -> Optional[Path]:
"""A random course for the dashboard's "Surprise Me" pick - excludes
courses that are already fully watched where possible, falling back to
the whole library if everything's done."""
all_dirs = get_all_course_dirs()
if not all_dirs:
return None
scan = _scan_library_activity()
fully_completed = {c['path'] for c in scan['courses'] if c['total'] > 0 and c['completed'] >= c['total']}
candidates = [d for d in all_dirs if str(d) not in fully_completed]
return random.choice(candidates or all_dirs)
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
return {
@@ -1827,13 +1841,20 @@ def index():
if stats.get('total_duration_seconds'):
stats['remaining_display'] = format_duration(stats['remaining_seconds'])
resume_lesson = None
if current_course.last_accessed_path:
lesson, _ = find_lesson_in_tree(current_course.root_node, current_course.last_accessed_path)
if lesson:
resume_lesson = {'title': lesson.title, 'url': get_lesson_url(lesson, current_course.path)}
return render_template('course_dashboard.html',
course=current_course,
stats=stats,
continue_watching=continue_watching,
recent_views=recent_views,
has_note=bool(course_has_any_notes(current_course)),
is_queued=os.path.abspath(current_course.path) in get_next_up_paths())
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
resume_lesson=resume_lesson)
@app.route('/browse')
@@ -2037,6 +2058,24 @@ def set_hidden_path_api():
return jsonify({'success': True, 'hidden_paths': updated})
@app.route('/api/hidden-paths/bulk', methods=['POST'])
def bulk_set_hidden_paths_api():
"""Hide or un-hide several courses/directories at once."""
data = request.json or {}
paths = data.get('paths') or []
hidden = bool(data.get('hidden', True))
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_hidden_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_hidden(target, hidden)
return jsonify({'success': True, 'hidden_paths': updated})
def validate_new_name(new_name: str) -> Optional[str]:
"""Validate a proposed directory name; returns an error message, or None if valid."""
if not new_name:
@@ -2302,6 +2341,17 @@ def load_course():
return jsonify({'error': str(e)}), 500
@app.route('/random-pick')
def random_pick():
""""Surprise Me" - load a random not-yet-fully-watched course and land on its dashboard."""
global current_course
course_dir = get_random_incomplete_course()
if not course_dir:
return redirect(url_for('index'))
current_course = get_course_tree(str(course_dir))
return redirect(url_for('index'))
@app.route('/recent/open')
def open_recent():
"""
@@ -2383,6 +2433,23 @@ def set_next_up_api():
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/bulk', methods=['POST'])
def bulk_set_next_up_api():
"""Add several courses to the Next Up queue at once."""
data = request.json or {}
paths = data.get('paths') or []
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_next_up_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_queued(target, True)
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/reorder', methods=['POST'])
def reorder_next_up_api():
"""Replace the Next Up order wholesale, from the client's up/down-reordered list."""