15 Commits

Author SHA1 Message Date
Harwinder Sekhon c0c626f61d Tier course contents by relevance, only sending video titles for matched courses
Split the relevant courses into two tiers: ~25 courses described in detail
(paths, tags, about), and ~6 of those get their full video titles when the
question points at them specifically. This lets the advisor answer what a
course covers without sending the entire library's 3,000 video titles every
time. Video titles now become searchable terms weighted alongside tags and
learning paths, so courses are found by their actual content.
2026-09-18 19:58:13 -04:00
Harwinder Sekhon 76d8f259b2 Move the course advisor into a floating panel and link courses in its prose
The advisor was a card inside the home page, so reaching it meant navigating
back to #/ and it was rebuilt on every render of that view. It is now a bubble
in the bottom-right corner of every screen bar the player and a quiz, opening
a chat panel that stays put as you move around — ask what to take next, follow
a recommendation, keep asking. It is built once into #overlay-root, outside
the view that gets replaced, and syncAdvisor() called from renderChrome() is
the single path that decides whether it is on screen; that covers every route
change and every loadMeta(), and makes the Settings toggle work with no change
to its handler.

Being permanent removes the reason for the routeSeq guards in ask(). An answer
that arrived after the user navigated was written to sessionStorage and then
dropped unrendered; now it lands, and the bubble carries an unread dot if the
panel is closed. showAuth() wipes the transcript and hides the dock, both of
which are new requirements: the dock is a sibling of #app-shell, so hiding the
shell no longer hides the advisor, and the widget now outlives a sign-out.

Courses named in the answer itself are links. advisorResolveCourses() is
lifted out of advisorValidateRecommendations() so an id written inline goes
through the same existence check and the same redirect to whichever duplicate
copy the learner has progress on — a sentence and the card under it can never
point at different copies. The map is keyed by the id as the model wrote it,
since that is what the browser has to find in the text. The client splits the
raw line before escaping rather than linkifying escaped output: esc() writes
an apostrophe as ', and the course-id pattern matches the #39 inside it.

Under 900px the panel is a full-height sheet, and the mobile toast strip lifts
clear of the bubble only while the bubble is actually on screen. The z-index
sits on the dock rather than #overlay-root, which would otherwise become a
stacking context and trap the confirm dialog behind the panel.

README's advisor section is brought back in line, including the parts left
stale by 05f0abc about sections and the whole library going out per question.

Claude-Session: https://claude.ai/code/session_01LPZRrwVj6L9ckoRtK1yHAN
2026-09-10 06:56:01 -04:00
root e6f60f7f20 Send the advisor a course index plus retrieved details instead of the whole catalogue
Every question used to carry the full catalogue (~39k tokens on this
library, 41% of it per-section sizes). Now the model gets one line per
course as a stable, cacheable index, and paths/tags/blurb only for the
~25 courses a local term match against the conversation picks out (plus
anything in progress or already cited). Typical prompts land at 7-10k
tokens and the model reads the relevant courses in depth.

Duplicate copies of a course (same folder key) are collapsed before the
prompt is built; progress on a dropped copy counts for the kept one and a
recommendation is steered back to the copy the learner is part-way
through. Finished courses become a single id list. Reasons are capped at
15 words / 120 chars, which also shrinks every replayed follow-up turn.

Thinking mode is off for advisor calls (ADVISOR_REASONING = 'off').

Claude-Session: https://claude.ai/code/session_011gkaBpxtBdCBwdLL3R6Pfo
2026-09-09 00:00:50 +00:00
Harwinder Sekhon 59fab56359 Seed the vendor catalogue into SQL and add an optional AI course advisor
Course metadata: a scan now gives every course a course_meta row from the
downloader's sidecar JSON when the folder has one, else from catalog_courses
matched by the folder name the downloader would create (folderKeyFor, a port
of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export
into seeds/ine_catalog.sql, an idempotent patch that creates its own table and
upserts by id, so it can be applied to the live server before or after the
code and re-run freely. Course pages show the blurb, difficulty, vendor and
tags; search matches tags and blurbs; Settings reports how each course got its
details. Against the live library 163 of 179 courses match.

Course advisor, off by default: api/advisor.php answers "which course should I
take to learn X / what comes before Y" with courses from the library, in
order, plus vendor courses not yet downloaded. includes/ai_client.php is a
small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by
default, no composer); the prompt is a byte-stable catalogue of the library so
the provider's prefix cache covers it. Every course id the model returns is
validated server-side, a bad JSON reply gets one retry then a plain-text
fallback, and a per-user daily cap with token counts lives in
advisor_requests. With AI_ENABLED false the app makes no outbound requests
and the box does not appear; users can also hide it per browser.

Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
2026-09-08 18:36:43 -04:00
root 04314327db Detect course roots per branch and add a category layer above them
The scanner no longer assumes courses sit at a fixed depth. It walks
each branch and marks a folder is_course when it holds media directly
or its children read as chapters (01_, 1. , Module 2, Week 3);
everything above that is a category, and every folder still gets a
course_id pointing at its nearest course for cheap rollups. An empty
.course/.category marker file overrides the heuristic per folder.

Also:
- Roll up progress for the whole library in two grouped queries
  instead of one prefix-matched query per course/folder card.
- Store folders/videos/quizzes paths as utf8mb4_bin so folders that
  differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a
  case-insensitive UNIQUE key.
- migrateLibrarySchema() brings an older install's tables up to date
  at scan time, so there's no separate migration step.
- Library UI gains a categories view alongside shelves/catalog; the
  continue-watching strip's play button now seeks to the saved
  position on open.

Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
2026-09-08 18:51:04 +00:00
Harwinder Sekhon 6a4c972c47 Show quiz exhibits, keep sign-ins alive, and close a batch of edge cases
- A quiz question can now carry the diagram it refers to: a Markdown
  image line anywhere inside the question block is parsed out of the
  text and rendered under it (referrerpolicy="no-referrer", lazy). The
  URL points at the course provider, so an offline install gets a note
  and a link instead of an empty box.
- New optional SESSION_LIFETIME (30 days in the example config) replaces
  the browser-session cookie plus php.ini's 24-minute idle limit, which
  signed people out over a long pause. Configs that predate it behave as
  before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie
  is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy
  no longer costs the flag.
- Error responses no longer echo the exception message unless DEBUG: a
  PDOException names the DB user, host and tables, and that handler also
  runs for visitors who never signed in.
- The .htaccess denials are anchored to a path segment rather than the
  root, so videos/ and includes/ stay unreachable under a prefix such as
  /coursehub/ instead of silently opening up.
- A rescan takes a MySQL advisory lock; a second one answers "already
  running" rather than racing the first and flagging its rows missing. A
  file swapped in at the same path with a different size drops its
  cached duration so the probe reads the new length.
- stream.php discards output buffering and zlib compression (both break
  the promised Content-Length) and answers HEAD with headers alone.
- A progress save with no duration keeps the length already stored
  instead of nulling it, and the folder view builds its rollup once.
- In the SPA, a view whose data arrives after the route moved on is
  discarded — previously a slow load could paint over a newer screen or
  start a heartbeat for a video nobody was watching. Also: the quiz
  submit button can't be double-fired, leaving a video during the
  autoplay countdown no longer jumps ahead later, the final position is
  always saved on 'ended', the playlist scrolls the current entry into
  its own box, the login field takes focus, views start at the top, and
  a stray "%" in the hash no longer throws.
- Dropped the unused requireLogin(); registerUser() answers "taken"
  instead of a 500 when two people race for the same name.

Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
2026-09-06 12:31:21 -04:00
Harwinder Sekhon 66e4905796 Let the login throttle see real client IPs behind Cloudflare
Behind a proxy REMOTE_ADDR is the proxy, so the per-IP limit would lump
every visitor together. New optional CLIENT_IP_HEADER in config.php
(e.g. 'CF-Connecting-IP') tells the throttle which header carries the
real address; invalid or absent values fall back to REMOTE_ADDR, and
configs that predate the setting behave as before. Only for origins
unreachable except through the proxy — documented in the README, which
also covers the mod_remoteip/real_ip alternative.

Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
2026-07-30 21:17:46 -04:00
Harwinder Sekhon e3a054e109 Harden auth, add subtitles, vendor all assets locally
- Session cookies are HttpOnly + SameSite=Lax (Secure on HTTPS), with
  strict session-ID mode; the CSRF token from GET api/auth.php must now
  accompany every state-changing request (X-CSRF-Token header, or a csrf
  body field for the sendBeacon progress save), rotated at login.
- Failed sign-ins are throttled per username and IP via login_attempts
  (created lazily, so existing installs need no migration): 5 failures a
  username / 20 an IP inside 15 minutes answer 429 with Retry-After.
- A video's sibling .srt now appears as a CC button in the player;
  captions.php converts it to WebVTT (comma timestamps, legacy encodings,
  and the already-VTT files with out-of-spec 6-digit fractions).
- Plyr and Inter ship in assets/vendor/ instead of coming from CDNs, and
  anime.js is gone along with all scripted animations and the motion
  setting — the app now runs with no internet access at all.
- .htaccess denies direct access to videos/, includes/, config.php and
  schema.sql, so video URLs cannot bypass login on Apache.

Claude-Session: https://claude.ai/code/session_01SePf61iazhCLJBefdRLBDm
2026-07-30 21:14:07 -04:00
Harwinder Sekhon 3c5d883f6c Fix blank page from stale cached assets; surface errors
A browser or CDN holding an assets/app.js from before the shell's element
IDs were renamed runs it against the newer index.php, where it grabs
btn-logout/btn-rescan/nav-user/nav-username/app — all now null — and throws
before it can unhide #app-shell. The result is a blank page whose only clue
is a TypeError in the console.

- index.php: asset() stamps local css/js with the file mtime, so a deploy is
  a new URL and a stale copy can't be paired with a newer shell.
- index.php: install an error handler ahead of every other script (capture
  phase, so failed resource loads are caught too) that paints uncaught
  errors, rejections and load failures into a #boot-error panel. It works
  even when app.js itself is what failed, which is this case.
- includes/db.php: add a register_shutdown_function fatal handler, since
  set_exception_handler never sees parse errors, exhausted memory or a
  failed require — those were ending requests with an empty body. Both
  handlers flush partial output and answer in JSON; display_errors stays off
  so a warning can't corrupt a JSON body.
- New optional DEBUG config flag adds exception type, file:line and trace to
  those responses. config.php is gitignored, so installs without the setting
  default to false.

Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
2026-07-27 14:22:23 -04:00
Harwinder Sekhon 91edba56d6 Remove course thumbnails and the ffmpeg dependency
Course cards go back to the colour-block art that previously sat under the
captured frame, so the UI is unchanged apart from losing the photo.

- Drop thumb.php, generateThumbnails(), captureThumbnail() and
  ensureThumbnailColumns(), plus the first-video tracking the scanner only
  kept to pick a frame source.
- Drop the ffprobe fallback in probeDuration(); the pure-PHP mvhd parse is
  now the only scan-time probe, with the player's report on first play as
  the remaining fallback.
- Drop THUMBNAILS_DIR/THUMBNAIL_SEEK_PERCENT from config, the
  thumbnail_* columns from schema.sql, the thumbnail counters from scan
  output, .thumb img from the stylesheet, and the matching README parts.

Existing databases keep two now-unused folders.thumbnail_* columns; they
are inert and can be dropped by hand if wanted.

Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
2026-07-27 14:06:06 -04:00
Harwinder Sekhon c4c88e52da UI improved 2026-07-27 13:58:47 -04:00
Harwinder Sekhon 45e7465f7b Probe video durations at scan time
Parse the mp4 mvhd box in pure PHP (ffprobe fallback) for videos with no
stored duration, so folder listings show durations before first play.
2026-07-11 11:38:41 -04:00
Harwinder Sekhon c6a970880c Add quiz support: parse *_quiz_solved.txt files, take and grade quizzes
Scanner indexes quiz files found next to videos ([&] questions, "- "
options, "-- Correct" markers; _notsolved twins ignored). Quizzes appear
in the folder content list interleaved with videos by filename order.
New api/quiz.php serves questions without answers and grades attempts
server-side; per-user best/last score and attempts stored in
quiz_progress. Folder/course progress now aggregates videos + quizzes
with partial credit for partial scores.
2026-07-11 11:24:37 -04:00
Harwinder Sekhon 13856e35bc Fix HTTP 500 on PHP < 8.2: drop standalone true union type
registerUser() declared `: string|true`; the bare `true` type requires
PHP 8.2, so including auth.php was a fatal error on PHP 8.0/8.1 — every
login/API call returned 500 while install.php (which doesn't include
auth.php) worked. Use string|bool instead. Also install a JSON exception
handler so future server errors surface a message to the SPA instead of
a blank 500.
2026-07-11 11:11:30 -04:00
Harwinder Sekhon 3ae6c1ae2e CourseHub: AJAX video course player (PHP + MySQL + Bootstrap + Plyr)
Scans nested course folders for mp4s, tracks per-user resume position
and per-folder progress. SPA frontend with hash routing over a JSON API;
range-request streaming via stream.php.
2026-07-11 10:25:35 -04:00