Adding ability to hide / show courses
This commit is contained in:
+154
-10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user