Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c49d3762 | ||
|
|
dbf9528fea |
@@ -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
|
||||
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
|
||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||
curation apply app-wide, not per-browser/per-user.
|
||||
|
||||
+335
-41
@@ -10,10 +10,12 @@ import mimetypes
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -453,15 +455,26 @@ class DynamicCourseParser:
|
||||
"""Calculate completion statistics for a directory node"""
|
||||
total_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):
|
||||
nonlocal total_lessons, completed_lessons
|
||||
nonlocal total_lessons, completed_lessons, total_duration_seconds, watched_seconds
|
||||
|
||||
# Count lessons in this node
|
||||
for lesson in n.lessons:
|
||||
total_lessons += 1
|
||||
if lesson.completed:
|
||||
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
|
||||
for child in n.children.values():
|
||||
@@ -474,7 +487,9 @@ class DynamicCourseParser:
|
||||
return {
|
||||
'total_lessons': total_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
|
||||
|
||||
|
||||
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]]:
|
||||
"""
|
||||
Recursively search the library for courses whose name contains `query`
|
||||
(case-insensitive). Walks via list_library_directory, so it reuses the
|
||||
exact same course/directory detection and hidden-path filtering as
|
||||
normal browsing - a hidden course or folder never shows up in results.
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
|
||||
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('/')
|
||||
def index():
|
||||
"""Main dashboard"""
|
||||
@@ -1005,11 +1158,15 @@ def index():
|
||||
course=None,
|
||||
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
|
||||
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
|
||||
ProgressTracker.apply_progress_to_tree(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',
|
||||
course=current_course,
|
||||
@@ -1212,41 +1369,48 @@ def set_hidden_path_api():
|
||||
return jsonify({'success': True, 'hidden_paths': updated})
|
||||
|
||||
|
||||
@app.route('/api/rename-path', methods=['POST'])
|
||||
def rename_path_api():
|
||||
"""Rename a course/folder directory in the library, in place on disk."""
|
||||
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:
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
error = validate_new_name(new_name)
|
||||
if error:
|
||||
return {'success': False, 'error': error, 'status': 400}
|
||||
|
||||
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)):
|
||||
return jsonify({'error': 'Path outside library root'}), 403
|
||||
return {'success': False, 'error': 'Path outside library root', 'status': 403}
|
||||
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):
|
||||
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)
|
||||
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:
|
||||
os.rename(old_abs, new_abs)
|
||||
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)
|
||||
|
||||
@@ -1257,7 +1421,107 @@ def rename_path_api():
|
||||
current_course = None
|
||||
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')
|
||||
@@ -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'])
|
||||
def load_course():
|
||||
"""Load course from selected directory"""
|
||||
|
||||
@@ -251,6 +251,32 @@
|
||||
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 {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -635,8 +661,15 @@
|
||||
<div>
|
||||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||||
</div>
|
||||
<div style="color: #007acc; font-size: 1.2em;">
|
||||
{{ "%.1f"|format(stats.completion_percentage) }}%
|
||||
<div style="text-align: right;">
|
||||
<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 class="progress-bar">
|
||||
@@ -746,6 +779,58 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
|
||||
@@ -281,6 +281,30 @@
|
||||
font-size: 0.8em;
|
||||
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 {
|
||||
font-size: 0.9em;
|
||||
color: #28a745;
|
||||
@@ -427,6 +451,25 @@
|
||||
<div id="curate-tree"></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">
|
||||
<h2>Load a Course Manually</h2>
|
||||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||||
@@ -468,6 +511,16 @@
|
||||
</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">
|
||||
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
||||
<span id="save-status">✓ Saved</span>
|
||||
@@ -740,6 +793,106 @@
|
||||
loadHiddenList();
|
||||
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() {
|
||||
const input = document.getElementById('library_path');
|
||||
const status = document.getElementById('library-path-status');
|
||||
|
||||
Reference in New Issue
Block a user