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
+152 -8
View File
@@ -267,6 +267,7 @@ class Lesson:
completed: bool = False completed: bool = False
last_accessed: Optional[str] = None last_accessed: Optional[str] = None
progress_seconds: int = 0 progress_seconds: int = 0
duration_seconds: int = 0
order: int = 0 order: int = 0
def __post_init__(self): def __post_init__(self):
@@ -501,7 +502,38 @@ def _looks_like_course(directory: Path) -> bool:
return len(children_with_media) >= 2 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 List only the immediate children of dir_path for the lazy-loading
Library browser: subdirectories to drill into, and course folders to 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 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. 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) directory = Path(dir_path)
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
hidden_set = set(get_hidden_paths())
if not directory.exists() or not directory.is_dir(): if not directory.exists() or not directory.is_dir():
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} 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}"]} return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]}
for entry in entries: 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): if _looks_like_course(entry):
media_count = len([ media_count = len([
f for f in entry.rglob('*') f for f in entry.rglob('*')
@@ -535,8 +579,9 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
items.append({ items.append({
'type': 'course', 'type': 'course',
'name': entry.name, 'name': entry.name,
'path': str(entry), 'path': entry_path_str,
'media_files': media_count 'media_files': media_count,
'hidden': is_hidden
}) })
else: else:
has_course_inside = any( has_course_inside = any(
@@ -547,7 +592,8 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
items.append({ items.append({
'type': 'directory', 'type': 'directory',
'name': entry.name, 'name': entry.name,
'path': str(entry), 'path': entry_path_str,
'hidden': is_hidden
}) })
return {'items': items, 'errors': []} return {'items': items, 'errors': []}
@@ -614,8 +660,26 @@ def get_recent_views() -> List[Dict[str, Any]]:
return [] 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]]: 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() entries = get_recent_views()
for entry in entries: for entry in entries:
try: 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', ' ') entry['viewed_display'] = dt.strftime('%b %d, %I:%M %p').replace(' 0', ' ')
except (KeyError, ValueError): except (KeyError, ValueError):
entry['viewed_display'] = '' 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 return entries
@@ -648,15 +725,25 @@ class ProgressTracker:
print(f"Error saving progress: {e}") print(f"Error saving progress: {e}")
@staticmethod @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""" """Update progress for specific lesson by path"""
progress = ProgressTracker.load_progress(course) progress = ProgressTracker.load_progress(course)
existing = progress.get(lesson_path, {})
progress[lesson_path] = { entry = {
'completed': completed, 'completed': completed,
'progress_seconds': progress_seconds, 'progress_seconds': progress_seconds,
'last_accessed': datetime.now().isoformat() '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 # Update last accessed path
progress['last_accessed_path'] = lesson_path progress['last_accessed_path'] = lesson_path
@@ -683,10 +770,12 @@ class ProgressTracker:
lesson.completed = progress[lesson_path].get('completed', False) lesson.completed = progress[lesson_path].get('completed', False)
lesson.last_accessed = progress[lesson_path].get('last_accessed') lesson.last_accessed = progress[lesson_path].get('last_accessed')
lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0) 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: elif lesson_path_with_title in progress:
lesson.completed = progress[lesson_path_with_title].get('completed', False) lesson.completed = progress[lesson_path_with_title].get('completed', False)
lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed') lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed')
lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0) 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 # Recursively apply to children
for child in node.children.values(): 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') @app.route('/settings')
def settings_page(): def settings_page():
"""Render the display-settings page.""" """Render the display-settings page."""
@@ -1068,10 +1211,11 @@ def update_progress():
lesson_path = data.get('lesson_path') lesson_path = data.get('lesson_path')
completed = data.get('completed', False) completed = data.get('completed', False)
progress_seconds = data.get('progress_seconds', 0) progress_seconds = data.get('progress_seconds', 0)
duration_seconds = data.get('duration_seconds') or None
try: try:
ProgressTracker.update_lesson_progress( ProgressTracker.update_lesson_progress(
current_course, lesson_path, completed, progress_seconds current_course, lesson_path, completed, progress_seconds, duration_seconds
) )
return jsonify({'success': True}) return jsonify({'success': True})
except Exception as e: except Exception as e:
+70 -20
View File
@@ -349,6 +349,8 @@
transition: all 0.3s; transition: all 0.3s;
cursor: pointer; cursor: pointer;
border-left: 3px solid #666; border-left: 3px solid #666;
position: relative;
overflow: hidden;
} }
.lesson-item:hover { .lesson-item:hover {
@@ -366,6 +368,38 @@
background: #2d5a2d; 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 { .lesson-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -458,25 +492,6 @@
</div> </div>
<div class="page-content"> <div class="page-content">
{% if recent_views %}
<div class="container">
<div class="card">
<h2>🕐 Recently Viewed</h2>
<div style="margin-top: 12px;">
{% for view in recent_views %}
<div class="lesson-item"
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
<span class="lesson-icon">▶️</span>
<span>{{ view.lesson_title }}</span>
</div>
<span class="lesson-meta">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if course %} {% if course %}
<div class="nav"> <div class="nav">
<div class="container"> <div class="container">
@@ -537,7 +552,8 @@
{% for lesson in node.lessons %} {% for lesson in node.lessons %}
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %} {% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
<div class="lesson-item {% if lesson.completed %}completed{% endif %}" {% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'"> onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
<div class="lesson-title"> <div class="lesson-title">
<span class="lesson-icon"> <span class="lesson-icon">
@@ -553,10 +569,15 @@
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span> <span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
{% if lesson.completed %} {% if lesson.completed %}
<span class="status-icon completed"></span> <span class="status-icon completed"></span>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %} {% else %}
<span class="status-icon pending"></span> <span class="status-icon pending"></span>
{% endif %} {% endif %}
</div> </div>
{% if percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -568,6 +589,35 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{% if recent_views %}
<div class="container">
<div class="card">
<h2>🕐 Recently Viewed</h2>
<div style="margin-top: 12px;">
{% for view in recent_views %}
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
<span class="lesson-icon">▶️</span>
<span>{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
<span class="status-icon completed"></span>
{% elif view.percent_watched %}
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
{% endif %}
<span>{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% if view.percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="container"> <div class="container">
<div class="card" id="library-card"> <div class="card" id="library-card">
<h2>Your Courses</h2> <h2>Your Courses</h2>
+3 -1
View File
@@ -452,13 +452,15 @@
} }
function saveProgress(progressSeconds, completed = false) { function saveProgress(progressSeconds, completed = false) {
const duration = activeMedia && isFinite(activeMedia.duration) ? Math.floor(activeMedia.duration) : null;
fetch('/api/progress', { fetch('/api/progress', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
lesson_path: '{{ lesson_path }}', lesson_path: '{{ lesson_path }}',
completed: completed, completed: completed,
progress_seconds: Math.floor(progressSeconds) progress_seconds: Math.floor(progressSeconds),
duration_seconds: duration
}) })
}).catch(err => console.error('Failed to save progress:', err)); }).catch(err => console.error('Failed to save progress:', err));
} }
+192
View File
@@ -203,6 +203,58 @@
color: var(--text-primary); color: var(--text-primary);
} }
.btn-secondary:hover { background: var(--bg-tertiary-hover); } .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 { #save-status {
font-size: 0.9em; font-size: 0.9em;
color: #28a745; color: #28a745;
@@ -331,6 +383,23 @@
</div> </div>
</div> </div>
<div class="card">
<h2>Curate Library</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Hide courses or whole folders from the Library browser without touching anything on disk.
Hiding a folder hides everything inside it.
</p>
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse to hide</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></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;">
@@ -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 => `
<div class="curate-row">
<div class="curate-row-name">
<span>🚫</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).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 = '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
});
}
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 = `<div class="curate-empty-hint">${reason}</div>`;
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 = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${toggleBtn}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${toggleBtn}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).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 = '<div class="curate-empty-hint">Loading...</div>';
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() { 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');