Files
offlineu/OfflineU-project-summary.md
T
rmsitzandClaude Sonnet 5 0454834e62 Add Notes Hub, transcript search, stale nudges, study guide export, Next Up queue, activity heatmap
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>
2026-08-22 19:04:01 -04:00

331 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.75x2x),
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`).