Compare commits

..
2 Commits
Author SHA1 Message Date
rmsitzandClaude Sonnet 5 a6c49d3762 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>
2026-08-22 11:36:05 -04:00
rmsitzandClaude Sonnet 5 dbf9528fea Speed up library search on large libraries
search_library_courses was walking via list_library_directory, which
computes a full recursive file count (rglob) and thumbnail lookup for
every course at every level - turning a search into an O(every file in
every course) scan regardless of how few results actually match. Give
search its own lightweight directory-only walk and defer the expensive
per-course lookups until after a name match is confirmed, so cost now
scales with matches found rather than total library size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 09:22:54 -04:00
4 changed files with 623 additions and 49 deletions
+42
View File
@@ -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.
+341 -47
View File
@@ -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,29 +747,160 @@ def get_library_root() -> str:
return LIBRARY_PATH return LIBRARY_PATH
def iter_all_courses(dir_path: str) -> Iterator[Path]:
"""
Recursively yield every course directory under dir_path, respecting
hidden paths exactly like normal browsing - without touching file
contents/counts. This is the cheap directory-only walk (iterdir +
_looks_like_course) that search, "Recently Added," and library stats
all need; the expensive per-course work (media_count via rglob,
thumbnail lookup, progress-file reads) is left to each caller to do
only for the courses it actually ends up using.
"""
hidden_set = set(get_hidden_paths())
def walk(directory: Path) -> Iterator[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:
if os.path.abspath(str(entry)) in hidden_set:
continue
if _looks_like_course(entry):
yield entry
else:
yield from walk(entry)
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]]: def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
""" """
Recursively search the library for courses whose name contains `query` Search the library for courses whose name contains `query`
(case-insensitive). Walks via list_library_directory, so it reuses the (case-insensitive). The expensive per-course lookups (media_count,
exact same course/directory detection and hidden-path filtering as thumbnail) only run for courses whose name actually matches - see
normal browsing - a hidden course or folder never shows up in results. iter_all_courses.
""" """
query_lower = query.lower() query_lower = query.lower()
results: List[Dict[str, Any]] = [] return [
_course_summary(course)
for course in iter_all_courses(dir_path)
if query_lower in course.name.lower()
]
def walk(path: str):
level = list_library_directory(path)
for item in level['items']:
if item['type'] == 'course':
if query_lower in item['name'].lower():
results.append(item)
else:
walk(item['path'])
walk(dir_path) 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
@@ -992,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"""
@@ -1005,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,
@@ -1212,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)
@@ -1257,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')
@@ -1321,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"""
+87 -2
View File
@@ -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">
+153
View File
@@ -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 &amp; 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');