Adding ability to hide / show courses

This commit is contained in:
2026-08-20 20:47:38 -04:00
parent 26a7701968
commit 0e9fff65b0
4 changed files with 419 additions and 31 deletions
+154 -10
View File
@@ -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: