Six more dashboard features, all built on the per-course progress-file
scanning infrastructure from earlier sessions:
- Notes Hub (/notes): every lesson note across the library in one place,
newest first, reusing the existing /recent/open cross-course jump route.
- Transcript search: opt-in checkbox on the Library search box, searching
inside .srt/.vtt files. Kept cheap by doing a raw substring match as the
filter step and only extracting a snippet for files that actually match.
- Stale course nudges ("Pick Back Up"): courses with progress that haven't
been touched in 14+ days.
- Study guide export: compiles a course's notes into one downloadable
markdown file, section/lesson structure preserved.
- Next Up queue: an ordered, persisted "what to tackle next" list with
up/down reordering and a queue button on every course row.
- Activity heatmap: a 90-day contribution-style calendar in the Library
Stats card.
_scan_library_activity() now does one walk over every course's progress
file per dashboard load; library stats, stale courses, and the heatmap all
derive from that single scan instead of three independent ones.
Also refactored the duplicated lesson-progress-key lookup (used in three
places now) into _resolve_lesson_progress_key(), and flagged a pre-existing
bug found along the way (not fixed here, kept out of scope): subtitle files
never actually attach to a Lesson object, so the video player's caption
track has never had anything to render.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
331 lines
20 KiB
Markdown
331 lines
20 KiB
Markdown
# OfflineU — Project Summary (for Claude Code handoff)
|
||
|
||
## What this is
|
||
A fork of the self-hosted course viewer [WhiskeyCoder/OfflineU](https://github.com/WhiskeyCoder/OfflineU)
|
||
(Flask app), customized for personal use. Deployed via Docker (Dockhand on a Ugreen NAS),
|
||
built locally from a private Gitea repo rather than pulling the upstream image.
|
||
|
||
## Deployment
|
||
- `docker-compose.yml`: `build: .` + `pull_policy: build` (forces a real rebuild every
|
||
deploy — without `pull_policy: build`, Compose can silently reuse a stale cached image).
|
||
- `network_mode: host`, `PUID=1000`, `PGID=10`, `TZ=America/New_York`.
|
||
- Volumes: courses at `/volume1/files/training` → `/app/courses`,
|
||
progress/settings data at `/volume2/docker/offlineu/data` → `/app/data`.
|
||
- Deployed from Gitea via Dockhand's "Deploy from Git" stack type.
|
||
|
||
## Files touched this session
|
||
- `offlineu_core.py` — all backend logic (Flask routes, settings, progress tracking)
|
||
- `templates/course_dashboard.html` — main dashboard / library browser
|
||
- `templates/lesson_view.html` — individual lesson page
|
||
- `templates/settings.html` — new settings page
|
||
- `templates/help.html` — new help page
|
||
- `static/theme.js` — shared script that applies persisted display settings
|
||
|
||
## Features added, roughly in order
|
||
|
||
1. **Library browser** — lazy, drill-down directory browsing instead of typing a full
|
||
path. `/library` lists one directory level at a time; a folder is treated as a
|
||
"course" once it (or 2+ of its immediate subfolders) contain video/audio files
|
||
directly. Starts collapsed at the top level.
|
||
|
||
2. **PDF preview fix** — lesson pages were fetching PDFs as text and dumping raw bytes.
|
||
Now PDFs (and HTML) render in an iframe; `.docx/.doc/.rtf` show a "no preview,
|
||
download instead" message rather than garbled text.
|
||
|
||
3. **Full-width, resizable layout** — removed a hardcoded 800px video cap and 1000px
|
||
page cap. Video player supports native corner-drag resizing (`resize: both` +
|
||
`object-fit: contain`), and the last-used size persists via settings.
|
||
|
||
4. **Settings system** (`/settings`, `/api/settings`) — server-side persisted
|
||
(`/app/data/settings.json`), covering:
|
||
- Theme (dark/light + 10 named presets: Dracula, Tokyo Night, Catppuccin Mocha,
|
||
Ayu Dark, GitHub Dark, Atom One Dark, Houston, Night Owl, Dainty, Matcha)
|
||
- Accent color (auto-syncs to a theme's signature color on switch, independently
|
||
editable after)
|
||
- Font family (system/sans/serif/monospace/Monaspace) and size
|
||
- Layout width, density, card style, corner radius
|
||
- Library root override (point the browser at a specific subfolder)
|
||
- Video player size
|
||
All applied via CSS custom properties, fetched once per page load by `static/theme.js`.
|
||
|
||
5. **Header/footer redesign** — branded gradient header, sticky footer holding
|
||
Settings + Help links (consolidated from several scattered links). Clicking the
|
||
"OfflineU" wordmark anywhere calls `/reset_course` (clears the loaded course)
|
||
rather than just `/` (which would keep showing the same course).
|
||
|
||
6. **Help page** (`/help`) — "How to Use" / "Supported File Types" moved here from
|
||
the cluttered main dashboard.
|
||
|
||
7. **Recently Viewed** — tracks the last 5 lessons viewed across *all* courses
|
||
(`/app/data/recent_views.json`), shown only on the no-course-loaded library
|
||
screen (not on an individual course's page). Clicking an entry loads that
|
||
lesson's course if it isn't already active, then jumps to the lesson
|
||
(`/recent/open`).
|
||
|
||
8. **Watch-progress tracking** — `Lesson.duration_seconds` added alongside the
|
||
existing `progress_seconds`. Lesson rows in the course tree and in Recently
|
||
Viewed show either a green checkmark (completed), an accent-colored
|
||
"NN% watched" badge + thin progress bar (in progress), or a plain "○" (untouched).
|
||
|
||
9. **Library curation** (Settings → Curate Library) — hide individual courses or
|
||
entire folders from the browser without touching anything on disk. Persisted in
|
||
`/app/data/hidden_paths.json`. Hiding a folder hides everything inside it.
|
||
`/library` (normal browsing) filters hidden items out; `/library/manage`
|
||
(used by the Settings UI) includes them flagged `hidden: true` so they can be
|
||
un-hidden.
|
||
|
||
## Rough edges fixed this session
|
||
- **Single-section course detection** — `_looks_like_course()` in
|
||
`offlineu_core.py` now also recognizes a course with exactly one section
|
||
subfolder, *if* that subfolder's name reads as a section label ("Section 1",
|
||
"Module 2", "Chapter 3", etc. — see `_SECTION_NAME_RE`). This deliberately
|
||
doesn't touch the general "publisher folder holding one course" case
|
||
(e.g. `Pluralsight/Docker Deep Dive/`), since that subfolder is named after
|
||
the course, not a section.
|
||
- **Empty folder after hiding all its courses** — added `_contains_visible_course()`,
|
||
used by `list_library_directory()` when `skip_hidden=True`, so a folder whose
|
||
every course has been individually curated out no longer appears as a
|
||
drillable (but empty) directory in normal browsing. The Settings curation UI
|
||
(`skip_hidden=False`) is unaffected — it still needs to show those folders so
|
||
courses can be un-hidden.
|
||
- **Matcha theme accuracy** — replaced the hand-approximated Matcha palette with
|
||
real values sourced from lucafalasco/matcha's published VS Code theme JSON.
|
||
- **Dainty theme swapped for Nord** — turned out "Dainty" (HotWordland/dainty-vscode)
|
||
is a Lab-space theme *generator*, not a fixed palette — there was no single
|
||
official hex set to verify against, so approximating it was never really
|
||
fixable. Replaced it with Nord, which has a well-documented, fully verifiable
|
||
official palette (nordtheme.com). Anyone with `theme: "dainty"` already saved
|
||
in `settings.json` falls back gracefully to the `dark` default on next load.
|
||
|
||
## Mobile UI overhaul + in-app renaming (this session)
|
||
- **Fixed the mobile text-overlap bug** — `.lesson-item`/`.tree-header` in
|
||
`course_dashboard.html` had no `min-width: 0` or truncation on their flex
|
||
children, so long course/lesson names (especially scene-release-style dotted
|
||
names like `Udemy.crash.course.electronics...Mimir` with zero whitespace to
|
||
wrap on) would overflow and visually overlap neighboring text on narrow
|
||
screens. Fixed with `min-width:0` + ellipsis truncation (desktop/tablet) and
|
||
a `@media (max-width: 600px)` block that stacks title/metadata into separate
|
||
lines with `overflow-wrap: break-word` (the dotted-name case needed this
|
||
specifically — plain `white-space: normal` doesn't create a break opportunity
|
||
in a string with no spaces).
|
||
- **Library browser redesigned** — replaced the nested-indent accordion
|
||
(`AI` → `ChatGPT` → course, each level eating horizontal space) with a
|
||
single-level drill-down + breadcrumb (`Library / AI / ChatGPT`), the standard
|
||
mobile file-browser pattern. Tapping a folder now replaces the list instead
|
||
of nesting under it. In-course lesson tree keeps its old expand-in-place
|
||
behavior (shallower, wasn't the reported problem).
|
||
- **Rename courses/folders from Settings** — "Curate Library" is now
|
||
"Manage Library": each row gets a ✏️ button alongside Hide/Show that renames
|
||
the directory in place on disk via a new `POST /api/rename-path`. Handles
|
||
path-traversal/collision validation, and rebases any `hidden_paths.json` /
|
||
`recent_views.json` entries nested under the renamed path (progress files
|
||
need no rebasing — they live inside the directory and are keyed relative to
|
||
it). If you rename your *currently loaded* course's folder, the app resets
|
||
to the library view rather than serving a stale path.
|
||
|
||
## Eight usability features (this session)
|
||
|
||
Asked for after brainstorming what could make the app nicer to use — see
|
||
`.claude/plans/dazzling-yawning-glacier.md` for the full scoping rationale.
|
||
|
||
1. **Course thumbnails** — `find_course_thumbnail()` looks for
|
||
`cover`/`folder`/`thumbnail`/`thumb`/`poster` (`.jpg/.jpeg/.png/.webp`)
|
||
directly inside a course folder; served via `GET /library/thumbnail`.
|
||
Shown in the Library browser and Recently Viewed/Continue Watching,
|
||
falling back to the emoji icon if there's no cover image.
|
||
2. **Library search** — `GET /library/search?q=...` recursively walks the
|
||
library (reusing `list_library_directory`'s hidden-path filtering) and
|
||
matches on course name. Debounced search box above the Library browser.
|
||
3. **Continue Watching** — reuses the Recently-Viewed history
|
||
(`MAX_RECENT_VIEWS` bumped 5→20) rather than a full library-wide progress
|
||
index; split into "in progress" vs. "recently touched" on the dashboard.
|
||
4. **Playback speed control** — `settings.playback_speed` (0.75x–2x),
|
||
persisted the same way video player size already was; speed buttons on
|
||
the lesson page, applied on load via the existing `/api/settings` fetch.
|
||
5. **Installable app (PWA)** — `static/manifest.json` +
|
||
`static/icons/icon-{192,512}.png`, linked from every template's `<head>`.
|
||
Install-only, deliberately **no service worker/offline caching** — this
|
||
app has no real "offline" mode (it's a thin client over the Flask
|
||
backend/NAS files), so a caching SW would just create stale-content bugs.
|
||
Also fixed: `lesson_view.html` was missing the viewport meta tag entirely,
|
||
so lesson/video pages weren't actually mobile-responsive until now.
|
||
6. **Sort in the Library browser** — client-side Name A→Z/Z→A only;
|
||
progress-based filtering (not-started/in-progress/done) was scoped out,
|
||
since it'd need the same expensive per-course scan as #3's "ideal" version.
|
||
7. **Per-lesson notes** — a `note` field alongside `completed`/
|
||
`progress_seconds` in each lesson's existing progress-file entry, via
|
||
`ProgressTracker.update_lesson_note()` / `POST /api/lesson-note`. Fixed a
|
||
real bug in `update_lesson_progress` while at it: it always overwrote the
|
||
entire lesson entry, which would have silently deleted a saved note on
|
||
the next routine playback-progress autosave.
|
||
8. **Mark course as watched** — `POST /api/course/mark-watched`, scoped to
|
||
whatever course is currently loaded (not an arbitrary library path, to
|
||
avoid re-validating/re-scanning an untrusted path). Button lives in the
|
||
course stats card, behind a confirm prompt.
|
||
|
||
## Search perf fix + five more features (this session)
|
||
|
||
Also caught: search on the real deployed library was taking 3+ seconds
|
||
(scanning ~6000+ files across the real course tree). Root cause:
|
||
`search_library_courses` walked via `list_library_directory`, which computes
|
||
a full recursive file count (`rglob`) and thumbnail lookup for *every*
|
||
course at *every* level, regardless of whether it matched. Rewrote it with
|
||
its own directory-only walk (`iter_all_courses` - now shared by search,
|
||
Recently Added, and library stats below) so the expensive per-course work
|
||
only runs for courses that actually match. Verified with a synthetic
|
||
6000-file library: effectively instant for narrow queries, same cost as
|
||
before only in the pathological "everything matches" case (unavoidable -
|
||
you need real data for every result you return).
|
||
|
||
1. **Recently Added** — dashboard card showing courses by folder mtime
|
||
(`get_recently_added_courses`), separate from Recently Viewed (which
|
||
tracks what's been *watched*, not what showed up on disk).
|
||
2. **Bulk find/replace rename** — `Settings → Manage Library → Bulk Rename`.
|
||
Preview (`GET /api/bulk-rename/preview`) before apply
|
||
(`POST /api/bulk-rename/apply`) is mandatory; apply takes the *exact*
|
||
list the client saw in preview (not a re-derived pattern match), and
|
||
continues past individual failures (e.g. a name collision) rather than
|
||
aborting the whole batch. Scans every directory in the library, course
|
||
and category/group folders alike (`_iter_all_directories`) - by user's
|
||
choice, since the naming problem isn't limited to course-level folders.
|
||
The single-item rename route (`/api/rename-path`) was refactored to
|
||
share the same validation/apply logic (`validate_new_name`,
|
||
`perform_rename`) - confirmed byte-for-byte identical error responses
|
||
after the refactor.
|
||
3. **Estimated time remaining** — added to `_calculate_completion_stats`;
|
||
only ever computed over lessons that have actually reported a duration
|
||
(i.e. been played at least once) - no data, no estimate shown, rather
|
||
than a misleading "0 remaining."
|
||
4. **Library stats overview** — total courses, lessons tracked, time
|
||
watched, daily streak. Reads each course's small progress JSON directly
|
||
rather than re-scanning course contents, so it stays cheap regardless of
|
||
how many files live inside each course.
|
||
5. **Backup/export** — `GET /api/backup`, a `Settings → Backup & Export`
|
||
button. Zips settings/hidden-paths/recent-views plus every course's
|
||
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
||
course files themselves.
|
||
|
||
## Outline integration (this session)
|
||
|
||
Push lesson notes to a self-hosted Outline instance
|
||
(`https://mikeline.michaelsitz.com`), organized by a "topic" the user picks
|
||
per note - each topic is its own Outline collection. Only notes with a
|
||
topic selected get pushed; everything else stays local-only, same as
|
||
before.
|
||
|
||
- **Credential handling**: the API token lives in its own
|
||
`OUTLINE_CONFIG_FILE` (`outline_config.json` in `DATA_DIR`), deliberately
|
||
*not* part of `DEFAULT_SETTINGS`/`load_settings` - those flow through
|
||
`GET /api/settings`, which `theme.js` fetches on every page load, and a
|
||
secret has no business riding along in that response. `GET
|
||
/api/outline/config` only ever returns `{configured, base_url}`, never
|
||
the token. Confirmed via a real (mocked-backend) test: token round-trips
|
||
through save but never comes back out on GET.
|
||
- **Topic chooser = live Outline collections**, not a locally-cached list -
|
||
`GET /api/outline/collections` proxies `collections.list` fresh every
|
||
time. Naming a new topic resolves it to a real collection **immediately**
|
||
(find-by-exact-name-or-create, `resolve_outline_topic()` /
|
||
`POST /api/outline/resolve-topic`) the moment you tab out of the "new
|
||
topic" field, rather than waiting until the note is pushed.
|
||
- **Why eager resolution, not deferred**: the first design deferred
|
||
collection creation to push-time (avoid an empty collection if the topic
|
||
was never actually used) - caught a real bug testing it: the push fires
|
||
via `navigator.sendBeacon()` on `pagehide`, which can't read a response,
|
||
so a page that fires `pagehide` more than once for the same load (a
|
||
browser back/forward-cache restore, for instance) would silently
|
||
re-create a *new* collection every time, since the client had no way to
|
||
learn the topic name had already been resolved. Fixed by resolving up
|
||
front and by making the deferred fallback path dedupe-by-name too
|
||
(`resolve_outline_topic`) - verified live (mocked Outline backend) that
|
||
firing multiple sequential `pagehide` events for the same lesson creates
|
||
the collection/document once and updates thereafter, never duplicates.
|
||
- **Push flow**: `POST /api/outline/push` - creates the Outline document on
|
||
first push, updates the same one (by the id stashed in the lesson's
|
||
progress-file entry, alongside the existing `note` field) on every push
|
||
after. No-ops if the note is empty.
|
||
- Verified end-to-end against a local mock Outline server (not the real
|
||
instance - no write-testing against live external services this
|
||
session), including Settings → Test Connection hitting the real HTTP
|
||
path.
|
||
|
||
## Outline integration follow-up fixes (this session)
|
||
|
||
- **Space bar bug**: the lesson page's global keyboard-shortcut handler
|
||
(space/arrows for play-pause/seek/volume) fired regardless of what had
|
||
focus, so typing a space in the notes textarea toggled the video instead.
|
||
Fixed with an `isTypingTarget()` guard that skips the shortcut handler
|
||
entirely when a textarea/input/select/contenteditable has focus.
|
||
- **Topic model redesign**: topics used to each spawn their own top-level
|
||
Outline *collection*. Changed so a topic is instead a *document* inside
|
||
one fixed, user-configured collection (Settings → Outline Integration →
|
||
Default collection, e.g. "Training Notes") - a lesson's note becomes a
|
||
child document nested under its topic document, matching how the user
|
||
actually organizes their real Outline instance. `list_outline_topics()`
|
||
fetches the collection's documents and filters to `parentDocumentId is
|
||
None` locally rather than trusting `documents.list`'s `collectionId`/
|
||
`parentDocumentId` request filters - both are marked deprecated in
|
||
Outline's own API spec with unclear recursive-vs-top-level semantics, so
|
||
filtering the response ourselves is the version that can't be wrong.
|
||
Verified end-to-end against a local mock Outline server: resolving a new
|
||
topic creates a top-level doc, pushing a note creates a child doc under
|
||
it, and the child never leaks back into the topic chooser as if it were
|
||
a topic itself.
|
||
|
||
## Known limitations still open
|
||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||
curation apply app-wide, not per-browser/per-user.
|
||
|
||
## Six more features (this session)
|
||
|
||
Building on `iter_all_courses`/per-course-progress-file scanning from
|
||
earlier: Notes Hub, transcript search, stale-course nudges, study guide
|
||
export, a Next Up queue, and an activity heatmap.
|
||
|
||
- **One shared library scan** — `_scan_library_activity()` walks every
|
||
course's progress file once; `format_library_stats()`,
|
||
`format_stale_courses()`, and `format_activity_heatmap()` all derive from
|
||
a single call in `index()`, instead of three separate full-library reads
|
||
on the same dashboard load.
|
||
- **Notes Hub** (`/notes`, `templates/notes_hub.html`) — every lesson note
|
||
across the whole library in one place, newest first, since a note was
|
||
otherwise only visible from its own lesson page. Reuses the existing
|
||
`/recent/open` cross-course jump route for navigation - no new route
|
||
needed there.
|
||
- **Transcript search** — opt-in checkbox on the existing Library search
|
||
box ("Also search transcripts"). `search_transcripts()` reads subtitle
|
||
file *contents* for a raw substring match as the cheap filter step, only
|
||
extracting a cleaned snippet for files that actually match - deliberately
|
||
scoped to avoid reintroducing the exact O(every file) perf problem fixed
|
||
earlier this session. Found along the way (flagged as a separate task,
|
||
not fixed here): subtitle files never actually attach to a Lesson object
|
||
today - `DynamicCourseParser._create_lesson_from_file` sets
|
||
`subtitle_file` then immediately returns `None` before ever constructing
|
||
the `Lesson`, so the `<track kind="subtitles">` element in
|
||
`lesson_view.html` has never had anything to show. Transcript search
|
||
works around this by matching a subtitle file to its lesson via same-stem
|
||
video/audio file lookup instead of depending on `lesson.subtitle_file`.
|
||
- **Stale course nudges** ("⏰ Pick Back Up") — courses with some progress,
|
||
not fully done, untouched 14+ days. Known limitation: "not fully done" is
|
||
judged only from progress-file entries (lessons never opened at all
|
||
aren't in that file), so a course with several never-touched lessons
|
||
alongside one marked-complete lesson can look falsely "finished" - getting
|
||
a true completion count would mean re-scanning every course's full file
|
||
tree, the exact cost this whole scanning approach exists to avoid.
|
||
- **Study guide export** (`/course/study-guide`) — compiles every note
|
||
written for the loaded course into one markdown file, section/lesson
|
||
structure preserved, skipping anything without a note. Stdlib only,
|
||
matching how `/api/backup` already works.
|
||
- **Next Up queue** — ordered, persisted list of courses to tackle next
|
||
(`next_up.json`, mirrors `hidden_paths.json`'s pattern but as an ordered
|
||
list, since order matters here). Reorder via ▲▼ buttons rather than
|
||
drag-and-drop - more reliable on mobile. "📌" button added to Library
|
||
browser course rows and the loaded-course stats card.
|
||
- **Activity heatmap** — GitHub-style 90-day contribution calendar folded
|
||
into the existing Library Stats card, using the same per-day counts the
|
||
shared scan already computes for the streak stat.
|
||
|
||
## Workflow that's been in use
|
||
Edit locally → `git add . && git commit -m "..." && git push` to the private
|
||
Gitea repo → redeploy the stack in Dockhand (which builds from the fresh
|
||
`git pull` + `pull_policy: build`).
|