Add Recently Added, bulk rename, time remaining, library stats, backup export
Five more usability features on top of the search/thumbnails batch: - Recently Added: dashboard card of courses by folder mtime, separate from Recently Viewed (watched vs. just showed up on disk). - Bulk find/replace rename across every course/folder name at once, with a mandatory preview step before anything touches disk. Refactored the single-item rename route to share the same validate/apply logic. - Estimated time remaining on the loaded course's stats card, computed only from lessons that have actually reported a duration. - Library-wide stats overview: total courses, lessons tracked, time watched, daily streak - read from each course's small progress file rather than re-scanning course contents. - One-click backup/export zip of settings, hidden-paths, recently-viewed, and every course's progress/notes. Also fixes a real performance bug found along the way: search was routing through list_library_directory, which computes a full recursive file count and thumbnail lookup for every course at every level regardless of match - turning a search into an O(every file in the library) scan. Gave search its own lightweight directory-only walk (iter_all_courses), now shared by Recently Added and the stats overview too, so the expensive per-course work only runs for courses that actually match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -163,6 +163,48 @@ Asked for after brainstorming what could make the app nicer to use — see
|
|||||||
avoid re-validating/re-scanning an untrusted path). Button lives in the
|
avoid re-validating/re-scanning an untrusted path). Button lives in the
|
||||||
course stats card, behind a confirm prompt.
|
course stats card, behind a confirm prompt.
|
||||||
|
|
||||||
|
## Search perf fix + five more features (this session)
|
||||||
|
|
||||||
|
Also caught: search on the real deployed library was taking 3+ seconds
|
||||||
|
(scanning ~6000+ files across the real course tree). Root cause:
|
||||||
|
`search_library_courses` walked via `list_library_directory`, which computes
|
||||||
|
a full recursive file count (`rglob`) and thumbnail lookup for *every*
|
||||||
|
course at *every* level, regardless of whether it matched. Rewrote it with
|
||||||
|
its own directory-only walk (`iter_all_courses` - now shared by search,
|
||||||
|
Recently Added, and library stats below) so the expensive per-course work
|
||||||
|
only runs for courses that actually match. Verified with a synthetic
|
||||||
|
6000-file library: effectively instant for narrow queries, same cost as
|
||||||
|
before only in the pathological "everything matches" case (unavoidable -
|
||||||
|
you need real data for every result you return).
|
||||||
|
|
||||||
|
1. **Recently Added** — dashboard card showing courses by folder mtime
|
||||||
|
(`get_recently_added_courses`), separate from Recently Viewed (which
|
||||||
|
tracks what's been *watched*, not what showed up on disk).
|
||||||
|
2. **Bulk find/replace rename** — `Settings → Manage Library → Bulk Rename`.
|
||||||
|
Preview (`GET /api/bulk-rename/preview`) before apply
|
||||||
|
(`POST /api/bulk-rename/apply`) is mandatory; apply takes the *exact*
|
||||||
|
list the client saw in preview (not a re-derived pattern match), and
|
||||||
|
continues past individual failures (e.g. a name collision) rather than
|
||||||
|
aborting the whole batch. Scans every directory in the library, course
|
||||||
|
and category/group folders alike (`_iter_all_directories`) - by user's
|
||||||
|
choice, since the naming problem isn't limited to course-level folders.
|
||||||
|
The single-item rename route (`/api/rename-path`) was refactored to
|
||||||
|
share the same validation/apply logic (`validate_new_name`,
|
||||||
|
`perform_rename`) - confirmed byte-for-byte identical error responses
|
||||||
|
after the refactor.
|
||||||
|
3. **Estimated time remaining** — added to `_calculate_completion_stats`;
|
||||||
|
only ever computed over lessons that have actually reported a duration
|
||||||
|
(i.e. been played at least once) - no data, no estimate shown, rather
|
||||||
|
than a misleading "0 remaining."
|
||||||
|
4. **Library stats overview** — total courses, lessons tracked, time
|
||||||
|
watched, daily streak. Reads each course's small progress JSON directly
|
||||||
|
rather than re-scanning course contents, so it stays cheap regardless of
|
||||||
|
how many files live inside each course.
|
||||||
|
5. **Backup/export** — `GET /api/backup`, a `Settings → Backup & Export`
|
||||||
|
button. Zips settings/hidden-paths/recent-views plus every course's
|
||||||
|
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
||||||
|
course files themselves.
|
||||||
|
|
||||||
## Known limitations still open
|
## Known limitations still open
|
||||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||||
curation apply app-wide, not per-browser/per-user.
|
curation apply app-wide, not per-browser/per-user.
|
||||||
|
|||||||
+329
-64
@@ -10,10 +10,12 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import List, Dict, Optional, Any, Tuple
|
from typing import List, Dict, Optional, Any, Tuple, Iterator
|
||||||
from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
|
from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -453,28 +455,41 @@ class DynamicCourseParser:
|
|||||||
"""Calculate completion statistics for a directory node"""
|
"""Calculate completion statistics for a directory node"""
|
||||||
total_lessons = 0
|
total_lessons = 0
|
||||||
completed_lessons = 0
|
completed_lessons = 0
|
||||||
|
# Only lessons that have actually been played report a duration, so
|
||||||
|
# "remaining time" is only ever an estimate over what's known so far
|
||||||
|
# - there's no way to know the length of a lesson nobody's opened yet.
|
||||||
|
total_duration_seconds = 0
|
||||||
|
watched_seconds = 0
|
||||||
|
|
||||||
def count_lessons_recursive(n: DirectoryNode):
|
def count_lessons_recursive(n: DirectoryNode):
|
||||||
nonlocal total_lessons, completed_lessons
|
nonlocal total_lessons, completed_lessons, total_duration_seconds, watched_seconds
|
||||||
|
|
||||||
# Count lessons in this node
|
# Count lessons in this node
|
||||||
for lesson in n.lessons:
|
for lesson in n.lessons:
|
||||||
total_lessons += 1
|
total_lessons += 1
|
||||||
if lesson.completed:
|
if lesson.completed:
|
||||||
completed_lessons += 1
|
completed_lessons += 1
|
||||||
|
if lesson.duration_seconds:
|
||||||
|
total_duration_seconds += lesson.duration_seconds
|
||||||
|
watched_seconds += (
|
||||||
|
lesson.duration_seconds if lesson.completed
|
||||||
|
else min(lesson.progress_seconds, lesson.duration_seconds)
|
||||||
|
)
|
||||||
|
|
||||||
# Recursively count in children
|
# Recursively count in children
|
||||||
for child in n.children.values():
|
for child in n.children.values():
|
||||||
count_lessons_recursive(child)
|
count_lessons_recursive(child)
|
||||||
|
|
||||||
count_lessons_recursive(node)
|
count_lessons_recursive(node)
|
||||||
|
|
||||||
completion_percentage = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0
|
completion_percentage = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'total_lessons': total_lessons,
|
'total_lessons': total_lessons,
|
||||||
'completed_lessons': completed_lessons,
|
'completed_lessons': completed_lessons,
|
||||||
'completion_percentage': round(completion_percentage, 1)
|
'completion_percentage': round(completion_percentage, 1),
|
||||||
|
'total_duration_seconds': total_duration_seconds,
|
||||||
|
'remaining_seconds': max(0, total_duration_seconds - watched_seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -732,25 +747,19 @@ def get_library_root() -> str:
|
|||||||
return LIBRARY_PATH
|
return LIBRARY_PATH
|
||||||
|
|
||||||
|
|
||||||
def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
|
def iter_all_courses(dir_path: str) -> Iterator[Path]:
|
||||||
"""
|
"""
|
||||||
Recursively search the library for courses whose name contains `query`
|
Recursively yield every course directory under dir_path, respecting
|
||||||
(case-insensitive), respecting hidden paths exactly like normal browsing.
|
hidden paths exactly like normal browsing - without touching file
|
||||||
|
contents/counts. This is the cheap directory-only walk (iterdir +
|
||||||
Deliberately doesn't reuse list_library_directory for the walk itself:
|
_looks_like_course) that search, "Recently Added," and library stats
|
||||||
that function computes media_count (a full recursive file count via
|
all need; the expensive per-course work (media_count via rglob,
|
||||||
rglob) and thumbnail presence for *every* course at each level, which is
|
thumbnail lookup, progress-file reads) is left to each caller to do
|
||||||
fine for showing one directory's worth of courses but turns a full-
|
only for the courses it actually ends up using.
|
||||||
library search into an O(every file in every course) scan. Here, the
|
|
||||||
directory walk only touches directory entries (cheap - iterdir, no file
|
|
||||||
stats), and the expensive per-course lookups only run for the handful of
|
|
||||||
courses whose name actually matches.
|
|
||||||
"""
|
"""
|
||||||
query_lower = query.lower()
|
|
||||||
hidden_set = set(get_hidden_paths())
|
hidden_set = set(get_hidden_paths())
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
def walk(directory: Path):
|
def walk(directory: Path) -> Iterator[Path]:
|
||||||
try:
|
try:
|
||||||
entries = sorted(
|
entries = sorted(
|
||||||
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||||
@@ -762,28 +771,136 @@ def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
if os.path.abspath(str(entry)) in hidden_set:
|
if os.path.abspath(str(entry)) in hidden_set:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if _looks_like_course(entry):
|
if _looks_like_course(entry):
|
||||||
if query_lower in entry.name.lower():
|
yield entry
|
||||||
media_count = len([
|
|
||||||
f for f in entry.rglob('*')
|
|
||||||
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
|
||||||
])
|
|
||||||
results.append({
|
|
||||||
'type': 'course',
|
|
||||||
'name': entry.name,
|
|
||||||
'path': str(entry),
|
|
||||||
'media_files': media_count,
|
|
||||||
'hidden': False,
|
|
||||||
'has_thumbnail': find_course_thumbnail(str(entry)) is not None
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
walk(entry)
|
yield from walk(entry)
|
||||||
|
|
||||||
walk(Path(dir_path))
|
yield from walk(Path(dir_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _course_summary(course_dir: Path) -> Dict[str, Any]:
|
||||||
|
"""Build the {type, name, path, media_files, hidden, has_thumbnail} shape
|
||||||
|
shared by the Library browser, search results, and Recently Added."""
|
||||||
|
media_count = len([
|
||||||
|
f for f in course_dir.rglob('*')
|
||||||
|
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
'type': 'course',
|
||||||
|
'name': course_dir.name,
|
||||||
|
'path': str(course_dir),
|
||||||
|
'media_files': media_count,
|
||||||
|
'hidden': False,
|
||||||
|
'has_thumbnail': find_course_thumbnail(str(course_dir)) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Search the library for courses whose name contains `query`
|
||||||
|
(case-insensitive). The expensive per-course lookups (media_count,
|
||||||
|
thumbnail) only run for courses whose name actually matches - see
|
||||||
|
iter_all_courses.
|
||||||
|
"""
|
||||||
|
query_lower = query.lower()
|
||||||
|
return [
|
||||||
|
_course_summary(course)
|
||||||
|
for course in iter_all_courses(dir_path)
|
||||||
|
if query_lower in course.name.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize_days_ago(dt: datetime) -> str:
|
||||||
|
"""'Today' / 'Yesterday' / 'N days ago' / a plain date once it's old enough to matter less."""
|
||||||
|
days = (datetime.now().date() - dt.date()).days
|
||||||
|
if days <= 0:
|
||||||
|
return 'Today'
|
||||||
|
if days == 1:
|
||||||
|
return 'Yesterday'
|
||||||
|
if days < 14:
|
||||||
|
return f'{days} days ago'
|
||||||
|
return dt.strftime('%b %d')
|
||||||
|
|
||||||
|
|
||||||
|
def get_recently_added_courses(limit: int = 5) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Courses whose folder was most recently created/modified on disk, for
|
||||||
|
the dashboard's "Recently Added" card - separate from Recently Viewed
|
||||||
|
(which tracks what you've *watched*, not what showed up in the library).
|
||||||
|
"""
|
||||||
|
library_root = get_library_root()
|
||||||
|
dated = []
|
||||||
|
for course_dir in iter_all_courses(library_root):
|
||||||
|
try:
|
||||||
|
mtime = course_dir.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
dated.append((mtime, course_dir))
|
||||||
|
dated.sort(key=lambda pair: pair[0], reverse=True)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for mtime, course_dir in dated[:limit]:
|
||||||
|
item = _course_summary(course_dir)
|
||||||
|
item['added_display'] = _humanize_days_ago(datetime.fromtimestamp(mtime))
|
||||||
|
results.append(item)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_library_stats() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Library-wide overview for the dashboard: total courses, lessons with
|
||||||
|
any progress record, completed lessons, total time watched, and the
|
||||||
|
current daily streak. Reads each course's small progress JSON file
|
||||||
|
directly rather than re-scanning course directory contents, so this
|
||||||
|
stays cheap regardless of how many files are inside each course.
|
||||||
|
"""
|
||||||
|
total_courses = 0
|
||||||
|
lessons_tracked = 0
|
||||||
|
completed_lessons = 0
|
||||||
|
watched_seconds = 0
|
||||||
|
active_dates = set()
|
||||||
|
|
||||||
|
for course_dir in iter_all_courses(get_library_root()):
|
||||||
|
total_courses += 1
|
||||||
|
try:
|
||||||
|
with open(course_dir / '.offlineu_progress.json', 'r') as f:
|
||||||
|
progress = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, entry in progress.items():
|
||||||
|
if key == 'last_accessed_path' or not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
lessons_tracked += 1
|
||||||
|
if entry.get('completed'):
|
||||||
|
completed_lessons += 1
|
||||||
|
watched_seconds += entry.get('duration_seconds') or 0
|
||||||
|
else:
|
||||||
|
watched_seconds += entry.get('progress_seconds') or 0
|
||||||
|
|
||||||
|
last_accessed = entry.get('last_accessed')
|
||||||
|
if last_accessed:
|
||||||
|
try:
|
||||||
|
active_dates.add(datetime.fromisoformat(last_accessed).date())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
streak_days = 0
|
||||||
|
day = datetime.now().date()
|
||||||
|
while day in active_dates:
|
||||||
|
streak_days += 1
|
||||||
|
day -= timedelta(days=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_courses': total_courses,
|
||||||
|
'lessons_tracked': lessons_tracked,
|
||||||
|
'completed_lessons': completed_lessons,
|
||||||
|
'watched_display': format_duration(watched_seconds),
|
||||||
|
'streak_days': streak_days
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
|
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
|
||||||
MAX_RECENT_VIEWS = 20
|
MAX_RECENT_VIEWS = 20
|
||||||
|
|
||||||
@@ -1021,6 +1138,13 @@ def _split_continue_watching(all_views: List[Dict[str, Any]]) -> Tuple[List[Dict
|
|||||||
return continue_watching, recent_views
|
return continue_watching, recent_views
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: int) -> str:
|
||||||
|
"""Render a duration in seconds as e.g. '3h 24m' or '45m' (under an hour)."""
|
||||||
|
total_minutes = max(0, int(seconds)) // 60
|
||||||
|
hours, minutes = divmod(total_minutes, 60)
|
||||||
|
return f"{hours}h {minutes}m" if hours else f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main dashboard"""
|
"""Main dashboard"""
|
||||||
@@ -1034,11 +1158,15 @@ def index():
|
|||||||
course=None,
|
course=None,
|
||||||
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
|
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
|
||||||
continue_watching=continue_watching,
|
continue_watching=continue_watching,
|
||||||
recent_views=recent_views)
|
recent_views=recent_views,
|
||||||
|
recently_added=get_recently_added_courses(),
|
||||||
|
library_stats=get_library_stats())
|
||||||
|
|
||||||
# Apply progress data to tree
|
# Apply progress data to tree
|
||||||
ProgressTracker.apply_progress_to_tree(current_course)
|
ProgressTracker.apply_progress_to_tree(current_course)
|
||||||
stats = ProgressTracker.get_completion_stats(current_course)
|
stats = ProgressTracker.get_completion_stats(current_course)
|
||||||
|
if stats.get('total_duration_seconds'):
|
||||||
|
stats['remaining_display'] = format_duration(stats['remaining_seconds'])
|
||||||
|
|
||||||
return render_template('course_dashboard.html',
|
return render_template('course_dashboard.html',
|
||||||
course=current_course,
|
course=current_course,
|
||||||
@@ -1241,41 +1369,48 @@ def set_hidden_path_api():
|
|||||||
return jsonify({'success': True, 'hidden_paths': updated})
|
return jsonify({'success': True, 'hidden_paths': updated})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/rename-path', methods=['POST'])
|
def validate_new_name(new_name: str) -> Optional[str]:
|
||||||
def rename_path_api():
|
"""Validate a proposed directory name; returns an error message, or None if valid."""
|
||||||
"""Rename a course/folder directory in the library, in place on disk."""
|
if not new_name:
|
||||||
|
return 'New name cannot be empty'
|
||||||
|
if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'):
|
||||||
|
return 'New name cannot contain path separators'
|
||||||
|
if new_name.startswith('.'):
|
||||||
|
return 'New name cannot start with a dot'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def perform_rename(old_path: str, new_name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate and apply a single directory rename within the library root:
|
||||||
|
rebases any hidden-path/recent-view references that pointed inside it,
|
||||||
|
and resets the active course if it (or an ancestor) was the thing
|
||||||
|
renamed. Shared by the single-item and bulk rename routes, always
|
||||||
|
returning a dict with a 'status' key for the caller to respond with.
|
||||||
|
"""
|
||||||
global current_course
|
global current_course
|
||||||
|
|
||||||
data = request.json or {}
|
error = validate_new_name(new_name)
|
||||||
path = data.get('path', '')
|
if error:
|
||||||
new_name = (data.get('new_name') or '').strip()
|
return {'success': False, 'error': error, 'status': 400}
|
||||||
|
|
||||||
if not path:
|
|
||||||
return jsonify({'error': 'path is required'}), 400
|
|
||||||
if not new_name:
|
|
||||||
return jsonify({'error': 'New name cannot be empty'}), 400
|
|
||||||
if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'):
|
|
||||||
return jsonify({'error': 'New name cannot contain path separators'}), 400
|
|
||||||
if new_name.startswith('.'):
|
|
||||||
return jsonify({'error': 'New name cannot start with a dot'}), 400
|
|
||||||
|
|
||||||
library_root = os.path.abspath(get_library_root())
|
library_root = os.path.abspath(get_library_root())
|
||||||
old_abs = os.path.abspath(path)
|
old_abs = os.path.abspath(old_path)
|
||||||
if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)):
|
if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)):
|
||||||
return jsonify({'error': 'Path outside library root'}), 403
|
return {'success': False, 'error': 'Path outside library root', 'status': 403}
|
||||||
if old_abs == library_root:
|
if old_abs == library_root:
|
||||||
return jsonify({'error': 'Cannot rename the library root itself'}), 400
|
return {'success': False, 'error': 'Cannot rename the library root itself', 'status': 400}
|
||||||
if not os.path.isdir(old_abs):
|
if not os.path.isdir(old_abs):
|
||||||
return jsonify({'error': 'Directory not found'}), 404
|
return {'success': False, 'error': 'Directory not found', 'status': 404}
|
||||||
|
|
||||||
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
|
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
|
||||||
if os.path.exists(new_abs):
|
if os.path.exists(new_abs):
|
||||||
return jsonify({'error': f'"{new_name}" already exists here'}), 409
|
return {'success': False, 'error': f'"{new_name}" already exists here', 'status': 409}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
os.rename(old_abs, new_abs)
|
os.rename(old_abs, new_abs)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return jsonify({'error': f'Rename failed: {e}'}), 500
|
return {'success': False, 'error': f'Rename failed: {e}', 'status': 500}
|
||||||
|
|
||||||
rebase_library_path(old_abs, new_abs)
|
rebase_library_path(old_abs, new_abs)
|
||||||
|
|
||||||
@@ -1286,7 +1421,107 @@ def rename_path_api():
|
|||||||
current_course = None
|
current_course = None
|
||||||
active_course_reset = True
|
active_course_reset = True
|
||||||
|
|
||||||
return jsonify({'success': True, 'new_path': new_abs, 'active_course_reset': active_course_reset})
|
return {
|
||||||
|
'success': True,
|
||||||
|
'new_path': new_abs,
|
||||||
|
'active_course_reset': active_course_reset,
|
||||||
|
'status': 200
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/rename-path', methods=['POST'])
|
||||||
|
def rename_path_api():
|
||||||
|
"""Rename a course/folder directory in the library, in place on disk."""
|
||||||
|
data = request.json or {}
|
||||||
|
path = data.get('path', '')
|
||||||
|
new_name = (data.get('new_name') or '').strip()
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return jsonify({'error': 'path is required'}), 400
|
||||||
|
|
||||||
|
result = perform_rename(path, new_name)
|
||||||
|
status = result.pop('status')
|
||||||
|
return jsonify(result), status
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_all_directories(directory: Path, hidden_set: set) -> Iterator[Path]:
|
||||||
|
"""
|
||||||
|
Yield every directory in the library tree - both course folders and the
|
||||||
|
category/group folders above them - skipping hidden ones and not
|
||||||
|
descending into a course's own internal sections (Section 1, etc. -
|
||||||
|
those aren't independently manageable library items anywhere else in
|
||||||
|
the app either, so bulk rename shouldn't touch them).
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
if os.path.abspath(str(entry)) in hidden_set:
|
||||||
|
continue
|
||||||
|
yield entry
|
||||||
|
if not _looks_like_course(entry):
|
||||||
|
yield from _iter_all_directories(entry, hidden_set)
|
||||||
|
|
||||||
|
|
||||||
|
def find_bulk_rename_matches(library_root: str, pattern: str, replacement: str) -> List[Dict[str, str]]:
|
||||||
|
"""Every directory (course or folder) in the library whose name contains `pattern`."""
|
||||||
|
hidden_set = set(get_hidden_paths())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'path': str(directory),
|
||||||
|
'old_name': directory.name,
|
||||||
|
'new_name': directory.name.replace(pattern, replacement)
|
||||||
|
}
|
||||||
|
for directory in _iter_all_directories(Path(library_root), hidden_set)
|
||||||
|
if pattern in directory.name
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bulk-rename/preview')
|
||||||
|
def bulk_rename_preview_api():
|
||||||
|
"""Preview a find/replace rename across the whole library, without touching disk."""
|
||||||
|
pattern = request.args.get('pattern', '')
|
||||||
|
replacement = request.args.get('replacement', '')
|
||||||
|
if not pattern:
|
||||||
|
return jsonify({'error': 'pattern is required'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
matches = find_bulk_rename_matches(library_root, pattern, replacement)
|
||||||
|
return jsonify({'matches': matches})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bulk-rename/apply', methods=['POST'])
|
||||||
|
def bulk_rename_apply_api():
|
||||||
|
"""
|
||||||
|
Apply a bulk rename. Takes the *exact* {path, new_name} list the client
|
||||||
|
got back from /api/bulk-rename/preview, rather than re-deriving matches
|
||||||
|
from the pattern again - what gets renamed is provably what the user
|
||||||
|
saw and approved, and can't drift if the library changed in between.
|
||||||
|
Continues past individual failures (e.g. a collision) instead of
|
||||||
|
aborting the whole batch, reporting a per-item result.
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
items = data.get('items') or []
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
return jsonify({'error': 'items is required'}), 400
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in items:
|
||||||
|
path = item.get('path', '')
|
||||||
|
new_name = item.get('new_name', '')
|
||||||
|
outcome = perform_rename(path, new_name)
|
||||||
|
outcome.pop('status', None)
|
||||||
|
outcome['path'] = path
|
||||||
|
outcome['old_name'] = item.get('old_name', '')
|
||||||
|
outcome['new_name'] = new_name
|
||||||
|
results.append(outcome)
|
||||||
|
|
||||||
|
return jsonify({'results': results})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings')
|
@app.route('/settings')
|
||||||
@@ -1350,6 +1585,36 @@ def reset_settings_api():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/backup')
|
||||||
|
def download_backup():
|
||||||
|
"""
|
||||||
|
Bundle everything that isn't recoverable from the course files
|
||||||
|
themselves - settings, hidden-path curation, recently-viewed history,
|
||||||
|
and every course's progress/notes file - into a single downloadable
|
||||||
|
zip. Cheap insurance before a NAS migration or a docker volume mistake.
|
||||||
|
"""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name, path in (
|
||||||
|
('settings.json', SETTINGS_FILE),
|
||||||
|
('hidden_paths.json', HIDDEN_PATHS_FILE),
|
||||||
|
('recent_views.json', RECENT_VIEWS_FILE),
|
||||||
|
):
|
||||||
|
if os.path.exists(path):
|
||||||
|
zf.write(path, name)
|
||||||
|
|
||||||
|
library_root = get_library_root()
|
||||||
|
for course_dir in iter_all_courses(library_root):
|
||||||
|
progress_file = course_dir / '.offlineu_progress.json'
|
||||||
|
if progress_file.exists():
|
||||||
|
relative = os.path.relpath(str(course_dir), library_root)
|
||||||
|
zf.write(progress_file, os.path.join('progress', relative, '.offlineu_progress.json'))
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
filename = f"offlineu-backup-{datetime.now().strftime('%Y-%m-%d')}.zip"
|
||||||
|
return send_file(buffer, mimetype='application/zip', as_attachment=True, download_name=filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/load_course', methods=['POST'])
|
@app.route('/load_course', methods=['POST'])
|
||||||
def load_course():
|
def load_course():
|
||||||
"""Load course from selected directory"""
|
"""Load course from selected directory"""
|
||||||
|
|||||||
@@ -251,6 +251,32 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tile {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tile-value {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tile-label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.library-breadcrumb {
|
.library-breadcrumb {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -635,8 +661,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||||||
</div>
|
</div>
|
||||||
<div style="color: #007acc; font-size: 1.2em;">
|
<div style="text-align: right;">
|
||||||
{{ "%.1f"|format(stats.completion_percentage) }}%
|
<div style="color: #007acc; font-size: 1.2em;">
|
||||||
|
{{ "%.1f"|format(stats.completion_percentage) }}%
|
||||||
|
</div>
|
||||||
|
{% if stats.remaining_display %}
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.85em;">
|
||||||
|
~{{ stats.remaining_display }} remaining
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
@@ -746,6 +779,58 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_course_row(item, meta_text) %}
|
||||||
|
<div class="lesson-item" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
|
||||||
|
<div class="lesson-title">
|
||||||
|
{% if item.has_thumbnail %}
|
||||||
|
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
|
||||||
|
onerror="this.replaceWith(courseFallbackIcon('🎓'))">
|
||||||
|
{% else %}
|
||||||
|
<span class="lesson-icon">🎓</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="lesson-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="lesson-meta">{{ meta_text }}</span>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if library_stats and library_stats.total_courses %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h2>📊 Library Stats</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stats-tile">
|
||||||
|
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
|
||||||
|
<div class="stats-tile-label">Courses</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-tile">
|
||||||
|
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
|
||||||
|
<div class="stats-tile-label">Lessons completed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-tile">
|
||||||
|
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
|
||||||
|
<div class="stats-tile-label">Watched</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-tile">
|
||||||
|
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
|
||||||
|
<div class="stats-tile-label">Day streak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if recently_added %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h2>🆕 Recently Added</h2>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
{% for item in recently_added %}
|
||||||
|
{{ render_course_row(item, item.added_display) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if continue_watching %}
|
{% if continue_watching %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -281,6 +281,30 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
.bulk-rename-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.bulk-rename-row-names {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.bulk-rename-old {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.bulk-rename-new {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
#save-status {
|
#save-status {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: #28a745;
|
color: #28a745;
|
||||||
@@ -427,6 +451,25 @@
|
|||||||
<div id="curate-tree"></div>
|
<div id="curate-tree"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Bulk Rename</h2>
|
||||||
|
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||||
|
Find and replace text across every course/folder name in the library at once - handy for
|
||||||
|
stripping a release-group suffix off a batch of downloads. Nothing changes until you apply.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
|
||||||
|
<input type="text" id="bulk-rename-find" class="hex-input"
|
||||||
|
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||||||
|
placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
||||||
|
<input type="text" id="bulk-rename-replace" class="hex-input"
|
||||||
|
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||||||
|
placeholder="Replace with (blank to remove)">
|
||||||
|
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
||||||
|
</div>
|
||||||
|
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
|
||||||
|
<div id="bulk-rename-preview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Load a Course Manually</h2>
|
<h2>Load a Course Manually</h2>
|
||||||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||||||
@@ -468,6 +511,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Backup & Export</h2>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label>Download a backup
|
||||||
|
<span class="setting-desc">Settings, hidden-path curation, recently-viewed history, and every course's progress/notes - not the course files themselves.</span>
|
||||||
|
</label>
|
||||||
|
<a class="btn" href="/api/backup">Download Backup</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
||||||
<span id="save-status">✓ Saved</span>
|
<span id="save-status">✓ Saved</span>
|
||||||
@@ -740,6 +793,106 @@
|
|||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
|
||||||
|
// ---- Bulk rename: find/replace across every course/folder name ----
|
||||||
|
let bulkRenameMatches = [];
|
||||||
|
|
||||||
|
function previewBulkRename() {
|
||||||
|
const pattern = document.getElementById('bulk-rename-find').value;
|
||||||
|
const replacement = document.getElementById('bulk-rename-replace').value;
|
||||||
|
const status = document.getElementById('bulk-rename-status');
|
||||||
|
const preview = document.getElementById('bulk-rename-preview');
|
||||||
|
preview.innerHTML = '';
|
||||||
|
if (!pattern) {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = 'Enter text to find first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Searching...';
|
||||||
|
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
bulkRenameMatches = data.matches || [];
|
||||||
|
renderBulkRenamePreview();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBulkRenamePreview() {
|
||||||
|
const status = document.getElementById('bulk-rename-status');
|
||||||
|
const preview = document.getElementById('bulk-rename-preview');
|
||||||
|
if (bulkRenameMatches.length === 0) {
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'No matches.';
|
||||||
|
preview.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = '';
|
||||||
|
const rows = bulkRenameMatches.map((m, i) => `
|
||||||
|
<div class="bulk-rename-row">
|
||||||
|
<div class="bulk-rename-row-names">
|
||||||
|
<div class="bulk-rename-old">${m.old_name}</div>
|
||||||
|
<div class="bulk-rename-new">→ ${m.new_name}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
const count = bulkRenameMatches.length;
|
||||||
|
preview.innerHTML = rows +
|
||||||
|
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBulkRenameMatch(index) {
|
||||||
|
bulkRenameMatches.splice(index, 1);
|
||||||
|
renderBulkRenamePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulkRename() {
|
||||||
|
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
|
||||||
|
const status = document.getElementById('bulk-rename-status');
|
||||||
|
status.style.color = 'var(--text-muted)';
|
||||||
|
status.textContent = 'Applying...';
|
||||||
|
fetch('/api/bulk-rename/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ items: bulkRenameMatches })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const results = data.results || [];
|
||||||
|
const succeeded = results.filter(r => r.success).length;
|
||||||
|
const failed = results.filter(r => !r.success);
|
||||||
|
const activeCourseReset = results.some(r => r.active_course_reset);
|
||||||
|
|
||||||
|
status.style.color = failed.length ? '#ff6b6b' : '#28a745';
|
||||||
|
let message = `${succeeded} renamed`;
|
||||||
|
if (failed.length) {
|
||||||
|
message += `, ${failed.length} failed: ` +
|
||||||
|
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
|
||||||
|
} else {
|
||||||
|
message += '.';
|
||||||
|
}
|
||||||
|
if (activeCourseReset) {
|
||||||
|
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
|
||||||
|
}
|
||||||
|
status.textContent = message;
|
||||||
|
|
||||||
|
bulkRenameMatches = [];
|
||||||
|
document.getElementById('bulk-rename-preview').innerHTML = '';
|
||||||
|
document.getElementById('bulk-rename-find').value = '';
|
||||||
|
document.getElementById('bulk-rename-replace').value = '';
|
||||||
|
loadHiddenList();
|
||||||
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function saveLibraryPath() {
|
function saveLibraryPath() {
|
||||||
const input = document.getElementById('library_path');
|
const input = document.getElementById('library_path');
|
||||||
const status = document.getElementById('library-path-status');
|
const status = document.getElementById('library-path-status');
|
||||||
|
|||||||
Reference in New Issue
Block a user