From 0e9fff65b02b2484d998020a5bc3204911ac5af8 Mon Sep 17 00:00:00 2001 From: Michael Sitz Date: Thu, 20 Aug 2026 20:47:38 -0400 Subject: [PATCH] Adding ability to hide / show courses --- offlineu_core.py | 164 +++++++++++++++++++++++++-- templates/course_dashboard.html | 90 +++++++++++---- templates/lesson_view.html | 4 +- templates/settings.html | 192 ++++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 31 deletions(-) diff --git a/offlineu_core.py b/offlineu_core.py index 68df164..4ffa2d8 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -267,6 +267,7 @@ class Lesson: completed: bool = False last_accessed: Optional[str] = None progress_seconds: int = 0 + duration_seconds: int = 0 order: int = 0 def __post_init__(self): @@ -501,7 +502,38 @@ def _looks_like_course(directory: Path) -> bool: return len(children_with_media) >= 2 -def list_library_directory(dir_path: str) -> Dict[str, Any]: +HIDDEN_PATHS_FILE = os.path.join(DATA_DIR, 'hidden_paths.json') + + +def get_hidden_paths() -> List[str]: + """Load the set of course/directory paths curated out of the Library browser.""" + try: + if os.path.exists(HIDDEN_PATHS_FILE): + with open(HIDDEN_PATHS_FILE, 'r') as f: + data = json.load(f) + if isinstance(data, list): + return data + except (json.JSONDecodeError, OSError) as e: + print(f"Could not load hidden paths: {e}") + return [] + + +def set_path_hidden(path: str, hidden: bool) -> List[str]: + """Add or remove a path from the hidden set; returns the updated list.""" + paths = set(get_hidden_paths()) + normalized = os.path.abspath(path) + if hidden: + paths.add(normalized) + else: + paths.discard(normalized) + result = sorted(paths) + os.makedirs(DATA_DIR, exist_ok=True) + with open(HIDDEN_PATHS_FILE, 'w') as f: + json.dump(result, f, indent=2) + return result + + +def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, Any]: """ List only the immediate children of dir_path for the lazy-loading Library browser: subdirectories to drill into, and course folders to @@ -511,9 +543,16 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: Directories that don't lead to any course anywhere inside them are left out entirely, so drilling down never dead-ends on an empty folder. + + Items the user has curated out (see get_hidden_paths) are excluded + when skip_hidden=True (normal browsing). When skip_hidden=False, they + are included but flagged with 'hidden': True instead - used by the + Settings-page curation UI, which needs to show hidden items so they + can be un-hidden. """ directory = Path(dir_path) items: List[Dict[str, Any]] = [] + hidden_set = set(get_hidden_paths()) if not directory.exists() or not directory.is_dir(): return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} @@ -527,6 +566,11 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]} for entry in entries: + entry_path_str = str(entry) + is_hidden = os.path.abspath(entry_path_str) in hidden_set + if skip_hidden and is_hidden: + continue + if _looks_like_course(entry): media_count = len([ f for f in entry.rglob('*') @@ -535,8 +579,9 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: items.append({ 'type': 'course', 'name': entry.name, - 'path': str(entry), - 'media_files': media_count + 'path': entry_path_str, + 'media_files': media_count, + 'hidden': is_hidden }) else: has_course_inside = any( @@ -547,7 +592,8 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]: items.append({ 'type': 'directory', 'name': entry.name, - 'path': str(entry), + 'path': entry_path_str, + 'hidden': is_hidden }) return {'items': items, 'errors': []} @@ -614,8 +660,26 @@ def get_recent_views() -> List[Dict[str, Any]]: return [] +def _read_lesson_progress_entry(course_path: str, lesson_path: str) -> Dict[str, Any]: + """ + Read a single lesson's progress entry directly from its course's own + progress file, without needing that course to be the currently loaded + one. Used to show watch progress for 'Recently Viewed' entries that may + belong to a different course than whatever's active right now. + """ + if not course_path or not lesson_path: + return {} + progress_file = os.path.join(course_path, '.offlineu_progress.json') + try: + with open(progress_file, 'r') as f: + progress = json.load(f) + return progress.get(lesson_path, {}) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + def get_recent_views_for_display() -> List[Dict[str, Any]]: - """Recent views with a human-friendly timestamp added for the template.""" + """Recent views with a human-friendly timestamp and watch progress added.""" entries = get_recent_views() for entry in entries: try: @@ -623,6 +687,19 @@ def get_recent_views_for_display() -> List[Dict[str, Any]]: entry['viewed_display'] = dt.strftime('%b %d, %I:%M %p').replace(' 0', ' ') except (KeyError, ValueError): entry['viewed_display'] = '' + + lesson_progress = _read_lesson_progress_entry(entry.get('course_path', ''), entry.get('lesson_path', '')) + completed = lesson_progress.get('completed', False) + progress_seconds = lesson_progress.get('progress_seconds', 0) + duration_seconds = lesson_progress.get('duration_seconds', 0) + + entry['completed'] = completed + if completed: + entry['percent_watched'] = 100 + elif duration_seconds: + entry['percent_watched'] = max(0, min(100, round(100 * progress_seconds / duration_seconds))) + else: + entry['percent_watched'] = 0 return entries @@ -648,16 +725,26 @@ class ProgressTracker: print(f"Error saving progress: {e}") @staticmethod - def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, progress_seconds: int = 0): + def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, + progress_seconds: int = 0, duration_seconds: Optional[int] = None): """Update progress for specific lesson by path""" progress = ProgressTracker.load_progress(course) - - progress[lesson_path] = { + existing = progress.get(lesson_path, {}) + + entry = { 'completed': completed, 'progress_seconds': progress_seconds, 'last_accessed': datetime.now().isoformat() } - + # Preserve a previously-known duration if this particular save + # didn't report one, rather than clobbering it back to unknown. + if duration_seconds: + entry['duration_seconds'] = duration_seconds + elif existing.get('duration_seconds'): + entry['duration_seconds'] = existing['duration_seconds'] + + progress[lesson_path] = entry + # Update last accessed path progress['last_accessed_path'] = lesson_path @@ -683,10 +770,12 @@ class ProgressTracker: lesson.completed = progress[lesson_path].get('completed', False) lesson.last_accessed = progress[lesson_path].get('last_accessed') lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0) + lesson.duration_seconds = progress[lesson_path].get('duration_seconds', 0) elif lesson_path_with_title in progress: lesson.completed = progress[lesson_path_with_title].get('completed', False) lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed') lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0) + lesson.duration_seconds = progress[lesson_path_with_title].get('duration_seconds', 0) # Recursively apply to children for child in node.children.values(): @@ -839,6 +928,60 @@ def browse_library(): }) +@app.route('/library/manage') +def browse_library_manage(): + """ + Same as /library, but includes items the user has hidden (flagged + 'hidden': true rather than filtered out), so the Settings-page + curation UI can browse the whole tree and toggle visibility. + """ + library_root = os.path.abspath(get_library_root()) + requested_path = request.args.get('path', library_root) + target_path = os.path.abspath(requested_path) + + if not (target_path == library_root or target_path.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root', 'items': [], 'errors': ['Access denied']}), 403 + + result = list_library_directory(target_path, skip_hidden=False) + return jsonify({ + 'library_path': library_root, + 'current_path': target_path, + 'items': result['items'], + 'errors': result['errors'] + }) + + +@app.route('/api/hidden-paths', methods=['GET']) +def get_hidden_paths_api(): + """List currently-hidden course/directory paths, with display names.""" + paths = get_hidden_paths() + return jsonify({ + 'hidden_paths': [ + {'path': p, 'name': os.path.basename(p.rstrip(os.sep)) or p} + for p in paths + ] + }) + + +@app.route('/api/hidden-paths', methods=['POST']) +def set_hidden_path_api(): + """Hide or un-hide a course/directory from the Library browser.""" + data = request.json or {} + path = data.get('path', '') + hidden = bool(data.get('hidden', True)) + + if not path: + return jsonify({'error': 'path is required'}), 400 + + library_root = os.path.abspath(get_library_root()) + target = os.path.abspath(path) + if not (target == library_root or target.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root'}), 403 + + updated = set_path_hidden(target, hidden) + return jsonify({'success': True, 'hidden_paths': updated}) + + @app.route('/settings') def settings_page(): """Render the display-settings page.""" @@ -1068,10 +1211,11 @@ def update_progress(): lesson_path = data.get('lesson_path') completed = data.get('completed', False) progress_seconds = data.get('progress_seconds', 0) + duration_seconds = data.get('duration_seconds') or None try: ProgressTracker.update_lesson_progress( - current_course, lesson_path, completed, progress_seconds + current_course, lesson_path, completed, progress_seconds, duration_seconds ) return jsonify({'success': True}) except Exception as e: diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index affcfb3..8d5dd09 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -349,6 +349,8 @@ transition: all 0.3s; cursor: pointer; border-left: 3px solid #666; + position: relative; + overflow: hidden; } .lesson-item:hover { @@ -366,6 +368,38 @@ background: #2d5a2d; } + .lesson-item.in-progress { + border-left-color: var(--accent); + } + + .lesson-progress-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.08); + } + + .lesson-progress-fill { + height: 100%; + background: var(--accent); + } + + .lesson-item.completed .lesson-progress-fill { + background: #28a745; + } + + .watched-badge { + font-size: 0.75em; + background: var(--bg-primary); + color: var(--accent); + padding: 2px 7px; + border-radius: 3px; + border: 1px solid var(--accent); + white-space: nowrap; + } + .lesson-title { display: flex; align-items: center; @@ -458,25 +492,6 @@
- {% if recent_views %} -
-
-

๐Ÿ• Recently Viewed

-
- {% for view in recent_views %} -
-
- โ–ถ๏ธ - {{ view.lesson_title }} -
- {{ view.course_name }}{% if view.viewed_display %} ยท {{ view.viewed_display }}{% endif %} -
- {% endfor %} -
-
-
- {% endif %} {% if course %}
{% else %} + {% if recent_views %} +
+
+

๐Ÿ• Recently Viewed

+
+ {% for view in recent_views %} +
+
+ โ–ถ๏ธ + {{ view.lesson_title }} +
+
+ {% if view.completed %} + โœ“ + {% elif view.percent_watched %} + {{ view.percent_watched }}% watched + {% endif %} + {{ view.course_name }}{% if view.viewed_display %} ยท {{ view.viewed_display }}{% endif %} +
+ {% if view.percent_watched %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %}

Your Courses

diff --git a/templates/lesson_view.html b/templates/lesson_view.html index e5468c7..83826b2 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -452,13 +452,15 @@ } function saveProgress(progressSeconds, completed = false) { + const duration = activeMedia && isFinite(activeMedia.duration) ? Math.floor(activeMedia.duration) : null; fetch('/api/progress', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ lesson_path: '{{ lesson_path }}', completed: completed, - progress_seconds: Math.floor(progressSeconds) + progress_seconds: Math.floor(progressSeconds), + duration_seconds: duration }) }).catch(err => console.error('Failed to save progress:', err)); } diff --git a/templates/settings.html b/templates/settings.html index 7cee9c0..5b9c773 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -203,6 +203,58 @@ color: var(--text-primary); } .btn-secondary:hover { background: var(--bg-tertiary-hover); } + .btn-sm { + padding: 5px 12px; + font-size: 0.85em; + } + .curate-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; + cursor: pointer; + } + .curate-row:hover { + background: var(--bg-tertiary-hover); + } + .curate-row.is-hidden { + opacity: 0.55; + } + .curate-row-name { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + } + .curate-row-name span.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .curate-toggle { + font-size: 1.1em; + width: 18px; + text-align: center; + flex-shrink: 0; + } + .curate-children { + margin-left: 22px; + margin-top: 4px; + display: none; + } + .curate-children.expanded { + display: block; + } + .curate-empty-hint { + color: var(--text-muted); + font-size: 0.9em; + padding: 6px 0; + } #save-status { font-size: 0.9em; color: #28a745; @@ -331,6 +383,23 @@
+
+

Curate Library

+

+ Hide courses or whole folders from the Library browser without touching anything on disk. + Hiding a folder hides everything inside it. +

+ + + +
Browse to hide
+
+
+
+

Load a Course Manually

@@ -431,6 +500,129 @@ } }); + // ---- Library curation: hide/show courses & folders ---- + function loadHiddenList() { + fetch('/api/hidden-paths') + .then(r => r.json()) + .then(data => { + const wrap = document.getElementById('hidden-list-wrap'); + const list = document.getElementById('hidden-list'); + const items = data.hidden_paths || []; + if (items.length === 0) { + wrap.style.display = 'none'; + return; + } + wrap.style.display = 'block'; + list.innerHTML = items.map(item => ` +
+
+ ๐Ÿšซ + ${item.name} +
+ +
+ `).join(''); + }) + .catch(() => {}); + } + + function loadCurateLevel(path, container, isRoot) { + const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage'; + fetch(url) + .then(r => r.json()) + .then(data => { + if (isRoot) { + document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`; + } + renderCurateLevel(data, container); + }) + .catch(() => { + container.innerHTML = '

Could not reach the library scanner.

'; + }); + } + + function renderCurateLevel(data, container) { + if (!data.items || data.items.length === 0) { + const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.'; + container.innerHTML = `
${reason}
`; + return; + } + container.innerHTML = data.items.map(item => { + const safePath = item.path.replace(/'/g, "\\'"); + const icon = item.type === 'course' ? '๐ŸŽ“' : '๐Ÿ“'; + const hiddenClass = item.hidden ? 'is-hidden' : ''; + const toggleBtn = ``; + + if (item.type === 'course') { + return ` +
+
+ ${icon} + ${item.name} +
+ ${toggleBtn} +
+ `; + } + return ` +
+
+ โ–ถ + ${icon} + ${item.name} +
+ ${toggleBtn} +
+
+ `; + }).join(''); + } + + function toggleCurateDir(rowEl, path) { + const content = rowEl.nextElementSibling; + const toggleIcon = rowEl.querySelector('.curate-toggle'); + if (!content || !content.classList.contains('curate-children')) return; + + if (content.classList.contains('expanded')) { + content.classList.remove('expanded'); + if (toggleIcon) toggleIcon.textContent = 'โ–ถ'; + return; + } + content.classList.add('expanded'); + if (toggleIcon) toggleIcon.textContent = 'โ–ผ'; + + if (content.dataset.loaded === 'true') return; + content.innerHTML = '
Loading...
'; + content.dataset.loaded = 'true'; + content.dataset.path = path; + loadCurateLevel(path, content, false); + } + + function toggleHidden(path, hidden, btnEl) { + fetch('/api/hidden-paths', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, hidden: hidden }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + loadHiddenList(); + // Refresh just the level this toggle happened in, rather + // than collapsing the whole tree back to root. + const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree')) + : document.getElementById('curate-tree'); + const isRootContainer = container.id === 'curate-tree'; + const refreshPath = isRootContainer ? null : container.dataset.path; + loadCurateLevel(refreshPath, container, isRootContainer); + } + }) + .catch(() => {}); + } + + loadHiddenList(); + loadCurateLevel(null, document.getElementById('curate-tree'), true); + function saveLibraryPath() { const input = document.getElementById('library_path'); const status = document.getElementById('library-path-status');