diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f81cc4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New +2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen +2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load) +2026-08-24 14:41 UTC — Add search, bulk actions, undo, and storage usage to File Management +2026-08-24 14:00 UTC — Fix build version showing "unknown" in production +2026-08-24 13:52 UTC — Fix wrapped-course false positive in picker; add build version display +2026-08-24 13:21 UTC — Exclude media-free subtrees from the destination picker too +2026-08-24 13:13 UTC — Exclude course/item leaves from destination picker; ease new-folder UX +2026-08-24 11:45 UTC — Add wildcard and regex modes to Bulk Rename +2026-08-24 11:11 UTC — Add delete and ignore actions to Duplicate Courses +2026-08-24 10:37 UTC — Move File Management link into the bottom tab bar +2026-08-24 10:29 UTC — Tighten Sort Unsorted matching; consolidate file management tools +2026-08-24 09:10 UTC — Add Unsorted folder auto-sort with keyword matching diff --git a/README.md b/README.md index 53662fb..9f25281 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,10 @@ guessing. Deliberately not derived from `git rev-parse` at Docker build time - Dockhand's build context doesn't reliably have `.git` available, which silently produced "unknown" instead of an actual commit. Reads as `dev` outside Docker (no `VERSION` file to read, e.g. before the first -commit that adds one). +commit that adds one). `CHANGELOG.md` at the repo root accumulates that +same line on every commit (newest first) instead of overwriting it, and +shows as a "What's New" list under the version line in Settings - so you +can see recent history, not just the current build. --- @@ -186,18 +189,22 @@ silently stay blank instead of erroring. listed (and reversible) under "Ignored matches," and persist through backup/restore alongside hidden paths and Next Up. - *Clean Up Stale References*: finds entries in the hidden-paths list, - Next Up queue, or Recently Viewed history that point at a path no longer - on disk - normally from renaming/moving/deleting a course directly on - the NAS instead of through the app (doing it through the app already - keeps these in sync). Review individually or clear them all at once; - course files themselves are never touched. + Next Up queue, Favorites, or Recently Viewed history that point at a path + no longer on disk - normally from renaming/moving/deleting a course + directly on the NAS instead of through the app (doing it through the app + already keeps these in sync). Review individually or clear them all at + once; course files themselves are never touched. - *Storage Usage*: disk usage per top-level library folder, largest first, with a simple proportional bar per entry - manually triggered - (it reads every file's size) rather than run automatically. + (it reads every file's size) rather than run automatically. Click any + folder row to drill into its own contents one level at a time, with a + Back button to climb back out. **Dashboard** - Library-wide stats (courses, lessons completed, time watched, time remaining, day streak) and a 90-day activity heatmap +- Favorites — star any course from its list row or its own page for quick + access from the dashboard, independent of recency or curated queues - Next Up queue (manually curated, reorderable) - Pick Back Up (courses with progress that have gone stale) - Recently Added / Recently Viewed @@ -207,6 +214,10 @@ silently stay blank instead of erroring. **Playback & progress** - Video/audio player with resize, playback-speed presets, and resume-from- last-position +- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek + 10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next + lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the + full list - Auto-tracks watch progress and completion per lesson - Video/audio durations read via `ffprobe` and cached persistently per course (`.offlineu_duration_cache.json`), so total runtime and per-lesson @@ -236,9 +247,9 @@ silently stay blank instead of erroring. **Backup & integrations** - One-click backup export and restore: settings, hidden-path choices, the - Next Up queue, recent-view history, ignored-duplicate pairs, Outline - config, and every course's progress/notes as a zip (not the course files - themselves). Restore + Next Up queue, Favorites, recent-view history, ignored-duplicate pairs, + Outline config, and every course's progress/notes as a zip (not the + course files themselves). Restore overwrites current data and needs a matching course folder to already exist for each course's progress to land - it's a "put my data back" action, not a merge @@ -256,6 +267,7 @@ Everything under `OFFLINEU_DATA_DIR` (app-wide, not tied to a course): | `settings.json` | Theme, layout, library path, etc. | | `hidden_paths.json` | Courses/folders curated out of the browser | | `next_up.json` | The Next Up queue, in order | +| `favorites.json` | Favorited course paths | | `recent_views.json` | Cross-course "Recently Viewed" history | | `ignored_duplicates.json` | Course-path pairs confirmed not duplicates | | `outline_config.json` | Outline API token/collection mapping | diff --git a/VERSION b/VERSION index 237fc0d..2384778 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026-08-24 23:00 UTC — fix video resize handle drifting off-screen +2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New diff --git a/offlineu_core.py b/offlineu_core.py index 959fb6c..1ab105e 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -57,6 +57,25 @@ def _load_build_version() -> str: BUILD_VERSION = _load_build_version() + +def load_changelog_entries(limit: int = 10) -> List[str]: + """ + Reads CHANGELOG.md for the Settings page's "What's New" list - one + line per commit (same timestamp + description format as VERSION), + newest first. VERSION itself is overwritten each commit and only ever + shows the current build; this accumulates that same line over time so + you can see what changed recently without digging through git log. + Falls back to an empty list if the file is missing (shouldn't happen + once committed, same reasoning as _load_build_version). + """ + changelog_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'CHANGELOG.md') + try: + with open(changelog_file, 'r') as f: + lines = [line.strip() for line in f if line.strip()] + return lines[:limit] + except OSError: + return [] + # Supported file types VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'} @@ -1052,6 +1071,51 @@ def get_next_up_courses() -> List[Dict[str, Any]]: return result +FAVORITES_FILE = os.path.join(DATA_DIR, 'favorites.json') + + +def get_favorite_paths() -> List[str]: + """Load the set of favorited course paths - order doesn't matter here (favorites always render alphabetically), unlike Next Up's queue.""" + try: + if os.path.exists(FAVORITES_FILE): + with open(FAVORITES_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 favorites: {e}") + return [] + + +def _save_favorite_paths(paths: List[str]) -> None: + os.makedirs(DATA_DIR, exist_ok=True) + with open(FAVORITES_FILE, 'w') as f: + json.dump(paths, f, indent=2) + + +def set_path_favorited(path: str, favorited: bool) -> List[str]: + """Add or remove a course from favorites; returns the updated list.""" + paths = get_favorite_paths() + normalized = os.path.abspath(path) + if favorited: + if normalized not in paths: + paths.append(normalized) + else: + paths = [p for p in paths if p != normalized] + _save_favorite_paths(paths) + return paths + + +def get_favorite_courses() -> List[Dict[str, Any]]: + """Favorited courses resolved to displayable summaries (name order, since there's no queue order to preserve), silently dropping any path no longer on disk.""" + results = [] + for path in sorted(get_favorite_paths(), key=lambda p: os.path.basename(p.rstrip(os.sep)).lower()): + p = Path(path) + if p.is_dir(): + results.append(_course_summary(p)) + return results + + def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str: """If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`.""" if value == old_abs: @@ -1083,6 +1147,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None: if rebased_queue != queued: _save_next_up_paths(rebased_queue) + favorited = get_favorite_paths() + rebased_favorites = [_rebase_prefix(p, old_abs, new_abs) for p in favorited] + if rebased_favorites != favorited: + _save_favorite_paths(rebased_favorites) + views = get_recent_views() changed = False for entry in views: @@ -1145,6 +1214,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, directory = Path(dir_path) items: List[Dict[str, Any]] = [] hidden_set = set(get_hidden_paths()) + favorite_set = set(get_favorite_paths()) if not directory.exists() or not directory.is_dir(): return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} @@ -1166,6 +1236,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, if _looks_like_course(entry): item = _course_summary(entry) item['hidden'] = is_hidden + item['favorited'] = os.path.abspath(entry_path_str) in favorite_set item['completion_percentage'] = _course_completion_percentage(entry, item['media_files']) items.append(item) else: @@ -1753,7 +1824,7 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]: /api/library/stale-references/clean is called with the exact items reviewed here. """ - stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': []} + stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': [], 'favorites': []} for path in get_hidden_paths(): if not os.path.isdir(path): @@ -1763,6 +1834,10 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]: if not os.path.isdir(path): stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path}) + for path in get_favorite_paths(): + if not os.path.isdir(path): + stale['favorites'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path}) + seen: Set[str] = set() for entry in get_recent_views(): path = entry.get('course_path', '') @@ -1945,11 +2020,13 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]: get_all_course_dirs. """ query_lower = query.lower() + favorite_set = set(get_favorite_paths()) results = [] for course in get_all_course_dirs(): if query_lower not in course.name.lower(): continue item = _course_summary(course) + item['favorited'] = os.path.abspath(str(course)) in favorite_set item['completion_percentage'] = _course_completion_percentage(course, item['media_files']) results.append(item) return results @@ -2897,6 +2974,7 @@ def index(): stale_courses=format_stale_courses(scan), activity_heatmap=format_activity_heatmap(scan), next_up=get_next_up_courses(), + favorites=get_favorite_courses(), active_tab='home') # Apply progress data to tree @@ -2917,6 +2995,7 @@ def index(): recent_views=recent_views, has_note=bool(course_has_any_notes(current_course)), is_queued=os.path.abspath(current_course.path) in get_next_up_paths(), + is_favorited=os.path.abspath(current_course.path) in get_favorite_paths(), resume_lesson=resume_lesson, active_tab='home') @@ -3280,34 +3359,80 @@ def _format_bytes(n: int) -> str: return f'{size:.1f} TB' +def _list_entries_with_sizes(directory: Path, skip_names: Optional[set] = None) -> List[Dict[str, Any]]: + """ + Every immediate child of `directory` with its total size - a + subdirectory gets its full recursive size (via _directory_size_bytes), + a loose file gets its own size directly. Sorted largest-first. Shared + by the top-level Storage Usage scan and its drill-down into a single + folder, which is otherwise the same operation one level deeper. + """ + skip_names = skip_names or set() + try: + children = [ + p for p in directory.iterdir() + if not p.name.startswith('.') and p.name not in skip_names + ] + except (PermissionError, OSError): + return [] + + entries = [] + for entry in children: + try: + is_dir = entry.is_dir() + size = _directory_size_bytes(entry) if is_dir else entry.stat().st_size + except OSError: + continue + entries.append({ + 'name': entry.name, + 'path': str(entry), + 'bytes': size, + 'human': _format_bytes(size), + 'is_dir': is_dir, + }) + + entries.sort(key=lambda e: e['bytes'], reverse=True) + return entries + + @app.route('/api/library/storage-usage') def storage_usage_api(): """ - Disk usage per top-level library folder (category or a bare course - sitting directly at the root), sorted largest-first, for spotting - what's eating the most space on the NAS. Manually triggered like - Duplicate Courses - a full recursive size scan touches every file in - the library, too expensive to run automatically on page load. + Disk usage of a library folder's immediate contents, sorted + largest-first, for spotting what's eating the most space on the NAS. + With no `path` param, scans the library root's top-level folders + (category or a bare course sitting directly at the root); with `path`, + drills into that folder instead (must resolve inside the library + root). Manually triggered like Duplicate Courses - a full recursive + size scan touches every file under each entry, too expensive to run + automatically on page load. """ - library_root = Path(get_library_root()) - try: - top_level = sorted( - (p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')), - key=lambda p: p.name.lower() - ) - except (PermissionError, OSError) as e: - return jsonify({'error': str(e)}), 500 + library_root = Path(get_library_root()).resolve() + subpath = (request.args.get('path') or '').strip() - entries = [] - for entry in top_level: - if entry.name == UNSORTED_FOLDER_NAME: - continue - size = _directory_size_bytes(entry) - entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)}) + if subpath: + base = Path(subpath).resolve() + try: + base.relative_to(library_root) + except ValueError: + return jsonify({'error': 'Path is outside the library'}), 400 + if not base.is_dir(): + return jsonify({'error': 'Not a directory'}), 400 + else: + base = library_root - entries.sort(key=lambda e: e['bytes'], reverse=True) + is_root = base == library_root + skip = {UNSORTED_FOLDER_NAME} if is_root else set() + entries = _list_entries_with_sizes(base, skip) total_bytes = sum(e['bytes'] for e in entries) - return jsonify({'entries': entries, 'total_bytes': total_bytes, 'total_human': _format_bytes(total_bytes)}) + return jsonify({ + 'entries': entries, + 'total_bytes': total_bytes, + 'total_human': _format_bytes(total_bytes), + 'path': str(base), + 'is_root': is_root, + 'parent_path': str(base.parent) if not is_root else None, + }) @app.route('/api/library/categories') @@ -3658,6 +3783,9 @@ def clean_stale_references_api(): elif category == 'recent_views': remove_recent_views_for_path(path) removed += 1 + elif category == 'favorites': + set_path_favorited(path, False) + removed += 1 return jsonify({'success': True, 'removed': removed}) @@ -4015,6 +4143,7 @@ def settings_page(): corner_radius_choices=['sharp', 'rounded', 'pill'], active_tab='settings', build_version=BUILD_VERSION, + changelog_entries=load_changelog_entries(), ) @@ -4062,6 +4191,7 @@ BACKUP_ROOT_FILES = { 'settings.json': SETTINGS_FILE, 'hidden_paths.json': HIDDEN_PATHS_FILE, 'next_up.json': NEXT_UP_FILE, + 'favorites.json': FAVORITES_FILE, 'recent_views.json': RECENT_VIEWS_FILE, 'outline_config.json': OUTLINE_CONFIG_FILE, 'ignored_duplicates.json': IGNORED_DUPLICATES_FILE, @@ -4265,6 +4395,24 @@ def set_next_up_api(): return jsonify({'success': True, 'next_up': updated}) +@app.route('/api/favorites', methods=['POST']) +def set_favorite_api(): + """Add or remove a course from favorites.""" + data = request.json or {} + path = data.get('path', '') + favorited = bool(data.get('favorited', 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_favorited(target, favorited) + return jsonify({'success': True, 'favorites': updated}) + + @app.route('/api/next-up/bulk', methods=['POST']) def bulk_set_next_up_api(): """Add several courses to the Next Up queue at once.""" diff --git a/templates/_icons.html b/templates/_icons.html index 06c5314..65a70a3 100644 --- a/templates/_icons.html +++ b/templates/_icons.html @@ -5,4 +5,4 @@ platforms and clash with the app's vector logo. Import with {% import '_icons.html' as icons %} and call icons.icon('name', size). #} -{% macro icon(name, size=18) %}{% endmacro %} +{% macro icon(name, size=18) %}{% endmacro %} diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 07cb122..01f8f7c 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -728,6 +728,13 @@ color: white; border-color: var(--accent); } + .favorite-toggle-btn.active { + color: #e0a825; + border-color: #e0a825; + } + .favorite-toggle-btn.active svg { + fill: currentColor; + } #library-select-btn { border-radius: var(--radius); margin-left: 4px; @@ -1044,6 +1051,9 @@ + {% if has_note %} {{ icons.icon('file-text', 14) }} Download Study Guide {% endif %} @@ -1249,6 +1259,31 @@ {% endif %} + {% if favorites %} +
Version {{ build_version }}
+ + {% if changelog_entries %} +