Commit Graph
438 Commits
Author SHA1 Message Date
John O'Keefe 8c97f59453 feat(dashboard): move This Week stats into the library bar as a popover
Release / build-and-push (push) Successful in 3m11s
The "This Week" reading-stats strip leaves its full-width row below the
subbar and now lives inside the library bar itself, between the switcher
and the customize/refresh actions. Expanding it opens an absolutely
positioned popover that FLOATS over the collections — opening the stats
no longer shifts the page down.

- LibrarySwitcher gains an optional `center` component slot rendered
  between the left group and the actions group (nil = unused). Series
  and collections call sites pass nil and render byte-identical output.
- DashboardWeekWidget redesigned for the slot: compact inline summary
  (icon, "This Week", minutes; pages/sessions/books/streak reveal at
  md/lg/xl) and a card popover panel with the labeled grid, sync note,
  and the Full stats link moved in from the old summary row.
- Panel anchoring is responsive: below `sm` it anchors to the sticky
  bar itself (details goes static; bar-anchored left-4/right-4, i.e.
  screen-centered); at `sm`+ the details is relative again and the
  panel centers on the widget (w-[30rem], translate-x centering).
- On phones the summary collapses to the chart icon + chevron (bar row
  width is scarce); "This Week"/minutes reveal at `sm`, and the
  "Your Library" title hides below `sm` to keep the row from
  overlapping. Icon-only toggle carries an aria-label.
- web/src/dashboard.ts: click-outside and Escape close the popover,
  re-querying #week-widget per event because htmx replaces it on
  library switches. Rendering untouched (behavior-only island).
- id="week-widget" and all htmx wiring unchanged: week-refresh refetch
  + the /dashboard/week-widget fragment still drive library-scoped
  refreshes behind the switch fade; stats-off suppression now yields an
  empty slot via weekWidgetComponent instead of a hidden strip.
- web/static/style.css rebuilt for the new utilities.

Known behavior (unchanged or deliberate): the bar is sticky, so stats
stay visible while scrolling; a library switch re-renders the details
closed; scopes with no current-week reading show zeros (e.g. Manga).
2026-10-27 10:22:13 -04:00
John O'Keefe f6a73ab5aa feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

Ignore is the durable dismiss: a new ignored_at column hides the row
from the page list, dashboard badge, and per-device API while the sync
keeps refreshing its metadata (the upsert never clears the column).
A collapsible "Ignored" section lists dismissed rows with Restore and
Delete so a misclick is recoverable. Delete is the hard exit — the row
is gone, and honestly re-queued if the device syncs that file again
(the confirm dialog says so).

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
2026-10-26 14:58:50 -04:00
John O'Keefe afbf1029a9 feat(web): hold a Screen Wake Lock while reading or listening
Release / build-and-push (push) Successful in 3m12s
New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.

Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
2026-10-26 13:59:37 -04:00
John O'Keefe c645b8e207 feat(profile): keep_screen_awake user preference (default on)
New users column surfaced as an On/Off dropdown in the profile form.
The form and API carry "true"/"false" strings with the theme-style
""-guard, so JSON clients that omit the field can't clobber it;
anything else is rejected. GET/PUT /api/auth/profile expose
keep_screen_awake alongside the other profile fields, and the reader
route threads the preference into its template metadata.

Integration tests pin the schema default, both values, omission
semantics, and rejection — dedicated test user, never testuser.
2026-10-26 13:59:32 -04:00
John O'Keefe 4681e98766 CSP tripwire: source-scan test + AGENTS.md rule; drop last native onchange
templates/csp_source_test.go scans .templ sources and fails the build on
the three authoring patterns hash-mode CSP refuses silently: native on*=
handler attributes (quote and templ brace forms), inline <script>
content outside the sanctioned theme_flash_guard.templ, and htmx's
eval-dependent bindings (hx-on*, js:-prefixed hx-vals — 'unsafe-eval' is
not granted). Verified to fire on all four violation classes via a
scratch probe file.

The dry run immediately caught a live leftover: unlinked_books.templ's
onchange="updateSelectedCount()" — CSP-refused since the hash flip.
Removed; the count already updates through the document-level delegated
change listener that x-init="setupEventDelegation()" registers.

AGENTS.md gains a "CSP discipline" ALWAYS-apply section documenting the
hash allow-list, the sanctioned-guard invariant, the replacement
patterns, and the tripwire test.

Verified: go build/vet, templates+middleware+docs tests green; /unlinked-
books loads with zero console violations (no fixture rows to exercise the
checkbox; the delegated path is unchanged code).
2026-10-26 08:17:49 -04:00
John O'Keefe fb95b6d400 CSP: replace 'unsafe-inline' with a content hash for the theme flash guard
script-src now allow-lists the guard by sha256 of its rendered bytes
(middleware.ScriptHashThemeFlashGuard) instead of 'unsafe-inline' — the
guard is the app's only inline <script>. security_headers_test.go renders
templates.ThemeFlashGuard() and fails with the replacement hash if the
script text ever drifts.

Executing anti-XSS strict CSP surfaced three latent violations the
eval-removal sweep couldn't see (hash mode is stricter than eval mode,
because inline event handler ATTRIBUTES now count against script-src):

1. admin_archived / admin_library: 15 native onclick=/onsubmit= handlers
   (modal open/close, folder-browser picker, purge button) converted to
   Alpine @click/@submit component methods — openCreateLibraryModal,
   hideModal, pickBrowsePath added to the admin component;
   purgeArchivedItems promoted from its window global into the component
   and the (window as any) exposure removed.

2. dashboard/docs: onclick handlers converted — dashboard's
   event.stopPropagation() → @click.stop; docs' sidebar/search closers →
   closeDocsSidebar/closeSearchResults component methods.

3. 8 onerror= image-fallback attributes (cover placeholders app-wide +
   audiobook cover hide) replaced by data-fallback attributes handled by
   one delegated capture-phase listener in web/src/image_fallback.ts —
   works for htmx-swapped content without re-binding.

Verified: theme guard applies a saved theme on public pages under the
hash CSP; 14-page authenticated sweep clean; admin pages clean under the
promoted admin; admin-library modal open/close bindings exercised;
readers render error-free; go build/vet/middleware-test, tsc, vp
check/test (146), build:ts all green.
2026-10-25 20:54:09 -04:00
John O'Keefe 6a598786e9 fix(reader): highlight color picker + window-aware note popover under CSP build
- Highlight color dots carried a nested-ternary + comma-sequence
  expression that the CSP-safe Alpine evaluator cannot parse — the
  buttons were dead. Logic moved to pickHighlightColor(color): sets
  the color, saves changes for edit-mode, creates the highlight for
  create-mode.
- The selection popover (and its note area) rendered above the
  selection anchor with a fixed size, so on small windows or
  top-of-page selections it escaped the viewport. Placement is now
  window-aware: show-time clamping via estimated popover size, and a
  measure-and-shift pass when the note area widens the popover (the
  post-open adjustment runs well after the enter transition; mutating
  during it is what breaks compositor hit-testing).
2026-10-25 19:32:23 -04:00
John O'Keefe 3b66b981be fix(devices): copy buttons via component method (copyFromId) under CSP build 2026-10-25 18:14:44 -04:00
John O'Keefe 6d01bd7971 fix(web): convert remaining JS-global bindings for the CSP build; drop unsafe-eval
The strict CSP sweep surfaced a final class of bindings on the audio
reader and book detail pages that referenced browser globals
(Number, String, Math, window, document) — identifiers the CSP-safe
evaluator resolves only from component scope:

- audio reader: scrub input, volume slider/input, speed+skip active
  styles, genre tick styles, and the default-speed select now route
  through component methods (onScrubInput, setVolumeFromEvent,
  applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
  isSkipActive, isSpeedActive, isDefaultSpeed)
- book detail: cover-upload click and banner edit validation moved to
  component methods (triggerCoverUpload, bannerEditHasText)
- admin: Refresh-to-View-Books reload moved to reloadPage()
- reader: note edit validation moved to noteEditHasText()

CSP header: frame-src 'self' blob: (foliate renders book content in
a blob: frame) and blob: on font-src/img-src (EPUB-embedded fonts
and images load as blob: URLs inside that frame). 'unsafe-eval' is
gone: script-src is 'self' plus 'unsafe-inline' for the pre-paint
theme flash guard.

Also: header openPanel promoted to the root component (one open
panel across all sidebar panels, persisted in init), with the
sidebar shell reduced to themeMode state + methods; fmtTime restored
to the audioReader component (ripped by the unexport pass — time
displays were dead on the audio reader).
2026-10-25 17:39:30 -04:00
John O'Keefe 09545ca5e1 fix(web): complete the CSP-build migration across remaining surfaces
- audio reader: scrub/volume/speed/genre-tick bindings converted to
  component methods (onScrubInput, setVolumeFromEvent,
  applyDefaultSpeedFromSelect, volumeLabel, railTickStyle,
  isSkipActive/isSpeedActive) — these used Number/Math/String globals
  that the CSP evaluator cannot resolve
- fmtTime restored to the audioReader component: the unexport pass
  had ripped it from the Alpine.data object (time displays were dead)
- book detail: cover-upload click and banner edit validation moved to
  component methods (triggerCoverUpload, bannerEditHasText)
- admin/docs/index components: init() hooks for their x-init chains
- CSP header: 'unsafe-eval' dropped — the CSP build needs no eval
- e2e spec: replace test.info().annotate (unavailable in this
  Playwright version) with console logging
2026-10-25 17:22:40 -04:00
John O'Keefe eb57b7208b feat(web): switch to the Alpine CSP build; drop 'unsafe-eval' from CSP
Standard Alpine evaluates every attribute expression through
new Function(), which a strict Content-Security-Policy cannot allow.
Swap to the official CSP-safe build (@alpinejs/csp), whose parser
evaluates expressions against registered component scope without
eval. script-src now carries only 'self' and 'unsafe-inline' (the
latter still required by the pre-paint theme flash guard).

Bindings rewritten out of the unsupported expression shapes:
- 21 multi-statement sequences across admin, audio_reader, docs,
  header, index, and reader became single component methods
  (init/loadWatchStatus hooks, commitScrub, pickSkip, pickSpeed,
  pickDefaultSpeed, chooseSleepOption, chooseSleepClockMode,
  toggleFxInvert, openPageThumbs, submitNote, clearSearch,
  pickReadingTheme)
- the header sidebar's inline x-data (openPanel/themeMode IIFE)
  became a registered sidebarShell component; openPanel itself moved
  to the header root so every sidebar panel shares one open state
- the appearance-panel  moved into the component init()
- header init() now also seeds openPanel from localStorage (or the
  admin section on /admin pages)

Alpine magics (, , , ) remain available —
the CSP evaluator injects them into scope.

The Playwright suite covers the converted surfaces end to end.
2026-10-25 16:58:28 -04:00
John O'Keefe a730a62280 refactor(ui): promote generic publication fields to their own editor section
Age Rating, Imprint, Scan Information, and Metadata Notes are
format-agnostic columns (the schema marks age rating 'applies to all
formats'; imprint examples span comics and books) — but they rendered
inside the Comic/Manga section, showing a manga dropdown on
audiobooks and hiding useful fields behind comic gating.

Split the editor into:
- Publication Details: Age Rating, Imprint, Scan Information,
  Metadata Notes — always visible for every media type (empty for
  non-comics sources: no standard metadata carries these outside
  ComicInfo.xml; editable and persistent for all).
- Comic/Manga (gated by ShowComicMetadata, unchanged): Manga Type,
  Reading Direction, Story Arc, Black & White.

Pure presentation move — identical form field names, no handler or
schema changes.
2026-10-25 12:50:37 -04:00
John O'Keefe d41c3e4774 fix(ui): hide Comic/Manga editor section for non-comics media
The metadata editor's Comic/Manga section (Manga Type, Reading
Direction, Age Rating, Story Arc, Imprint, Scan Info, Metadata
Notes, B&W) rendered for every item — an audiobook showed a manga
dropdown, which is nonsense.

MediaDetail now carries LibraryTypeName (resolved via
GetLibraryType in the /media/:uuid handler) and a
ShowComicMetadata() gate: comics and manga libraries always show the
section; other media only when the scanner recorded comic-style
values (fixed-layout manga-style EPUBs get those scanner defaults
deliberately — when present they stay editable).
2026-10-25 12:08:26 -04:00
John O'Keefe 8631aa00bf refactor: remove dead code across server, templ pages, and client modules
Deadcode-verified removals (RTA reachability from main, cross-checked
with -test and textual xrefs):

Go server:
- whole files: middleware/transaction.go, middleware/error_handler.go,
  middleware/password_validator.go (live registration machinery kept),
  handlers/sync.go, sync/offline.go
- unrouted endpoints: auth UpdateUsername/UpdateEmail, timezone
  settings handler, OPDS RegisterOPDS, Kobo LibrarySync handler,
  ReaderHandler.ShowReader, JSON-data twins on collections/library
- duplicate book-matching handlers (book_matching.go Handler twins,
  matching.go MatchingHandler twins), superseded helpers
  (router.parseUUID, handlers.ValidateDeviceToken, normalizePath,
  userExists, generateLockID)

Templ/client:
- never-routed pages: api_explorer.templ, queue.templ and their
  client modules api-explorer.ts, api-explorer-docs.ts, queue.ts
- fully orphaned wrapper modules events.ts, dom.ts
- broken clearSyncQueue (called a nonexistent endpoint) and the
  hidden, never-populated sync-queue section on the devices page
- 166+ unused exports demoted to private (knip-verified); ~85 truly
  dead declarations removed via tsc noUnusedLocals fixpoint
- stale 'interactive API Explorer' claims dropped from docs

The docs drift-gate package (internal/docs), templ-bound modules
(dashboard, stats, header, index, login, profile*, bookshelf,
collections, toast-error), sqlc-generated code, and web/static are
intentionally untouched.
2026-10-25 11:06:08 -04:00
John O'Keefe 8733e83f1f style: apply gofmt across Go sources
Repo-wide gofmt sweep: const-block alignment, doc-comment spacing,
statement-after-brace fix in media.go, trailing blank lines. No
semantic changes — whitespace-insensitive diff is empty for every
file in this commit.
2026-10-25 11:05:37 -04:00
John O'Keefe da0bae94bb docs(ui): scroll the sidebar so the active (bold) link is in view on load
Deep-linking into a low doc (e.g. ratings/get_ratings from the overview
page) opened the right menus but sat the highlight below the sidebar's
visible scroll area. scrollSidebarToActive() runs at docs Alpine init:
finds the link for the current path, and if it's outside the sidebar's
scrollport, moves ONLY the sidebar's own scrollTop to clamp it into the
nearest edge — never the page scroll. Skips the off-canvas mobile
drawer. (Same behavior as Docusaurus/MkDocs on deep links.)

Live: x-init wired on docs pages, function in the shipped bundle,
make test green, 146 vitests.
2026-10-24 21:57:03 -04:00
John O'Keefe 73e8cdef4b docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior
Simplification per spec: the menu tracks state through the URL alone.
Land on /docs: fully closed, headers only (an explicit home rule now
overrides the contains-URL rule, since the root overview doc lives in
Getting Started and would otherwise auto-open it). Land on any doc page:
its containing section + group render open at first paint, current link
bold — no JS, no jump, arrows consistent. Navigate elsewhere: old
closes, new opens.

Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write +
collect/persist helpers in web/src/docs.ts, data-key attributes in the
sidebar templ, and the first-section-open fallback (docs home used to
force-open the first section — spec'd as closed).

toggleCollapsible remains for in-page peeking; purely ephemeral.
The [data-collapsed=true] CSS from the previous round stays — it is what
makes the server-rendered state actually hide sections at paint (the
original bug was that this CSS never existed).

Tests: TestBuildNavigationNothingCurrent now asserts zero sections open
on unmatched paths; TestBuildNavigation asserts Getting Started closed
on a device page (no site-wide pin). make test green; 146 vitests; live
verified: home 0 expanded, doc pages exactly 2 (section+group).
2026-10-24 21:17:55 -04:00
John O'Keefe a8f8c4def9 docs(ui): cookie-rendered sidebar state, data-collapsed CSS, working overview links + link-checker gate
Three problems, one root cause each:

1. Sidebar jump: the server rendered its own default (current section
   open, rest collapsed) and restoreDocsNav corrected it after paint —
   open-then-close. Fix: persistence moved server-side. toggleCollapsible
   now writes a docs_nav cookie (strictly-necessary UI state, no
   identifiers, SameSite=Lax); ApplyNavOverrides merges it into the nav
   before render, so first paint IS the visitor's state. The post-paint
   restore is deleted.

2. Arrows out of sync / double-click: two visibility mechanisms
   disagreed. There was NO data-collapsed CSS at all — collapsed sections
   were only ever hidden by JS after paint, and the arrow rotation came
   from a different (server) state. Now: style.css hides on
   [data-collapsed=true] (single source of truth, server-rendered),
   applyCollapsed sets only the attribute, arrows rotate from the same
   merged value. Live check: 37/37 collapsibles arrow-consistent.

3. api-reference.md links: all 20+ quick links were directory-style
   (authentication/ etc.) that 404 on the docs site, plus stale targets
   (books/, ../collections-api.md, websocket 'coming soon'). Rewritten to
   real per-file links — all 9 unique targets return 200 live. Same rot
   fixed in admin/hash-conflicts.md, development.md, android-app.md,
   create_media_item.md, koreader-protocol.md; docs/index.md redirected
   from the deleted collections-api monolith (stale 530-line duplicate of
   the gated api/collections docs — deleted, per the WO2 doctrine).

New gate: TestDocsRelativeLinks walks docs/developer/** and fails make
test on any relative link that is directory-style or unresolvable —
overview links can never silently rot again.
2026-10-24 20:20:07 -04:00
John O'Keefe f1a6de7cd7 ui: fix admin/docs theme flash + persist sidebar open/closed state
Theme flash (admin pages, docs, stats, progress — anywhere SSR'd):
the JWT middlewares built the context user WITHOUT the Theme column,
so every page rendering theme-{user.Theme} from c.Get("user") painted
an empty class (:root tokyo-night default) and only settled on the real
theme after initializeTheme -> loadUserTheme round-tripped the server.
Both middlewares now carry liveUser.Theme into the context user;
verified live: /admin /stats /progress /docs /docs-deep /dashboard
/collections all SSR theme-hackerman.

Defense in depth for the same bug: loadTheme() no longer re-applies
localStorage over a server-rendered body theme (applyTheme records even
the default, so any signed-out visit baked tokyo-night into localStorage
and every signed-in page flipped to it post-paint). Public auth/landing
bodies dropped their hardcoded theme classes so ThemeFlashGuard's
pre-paint html class actually governs them; docs theme <select> now
syncs from the body class and re-syncs after the profile fetch.

Sidebar state (the unpleasant part): the app sidebar's openPanel was
reinitialized from the pathname on every load; it now restores from
localStorage ('sidebar-open-panel') with the pathname default as
fallback and -persists every toggle. Docs sidebar sections/groups
carry data-key attributes and toggleCollapsible persists collapsed
state per key ('docs-nav-state'); restoreDocsNav() applies it at init —
server-rendered defaults still govern first visit.

make test green; rendering ownership OK (no new inline scripts); 146
vitests pass; live-verified SSR classes on 7 pages.
2026-10-24 19:44:34 -04:00
John O'Keefe 84cd6328f4 feat(docs): standalone docs site chrome + category/subcategory navigation
Docs pages no longer embed the app Header (whose hx-post login form silently
degraded to a native GET, leaking credentials into the URL — this page never
loaded htmx). New docs-owned topbar: search, theme picker, Open App; anonymous
visitors get a plain /login link. Optional-auth middleware (hand-rolled —
echojwt's ErrorHandler return ends the chain, producing empty 200s) populates
the user for signed-in visitors.

Navigation rebuilt from the docs directory tree: 6 sections, subcategory
groups, server-rendered expansion and active states. Search results are a
templ partial (/docs/search/partial); code highlighting is server-side chroma
(previously dead hljs CSS); prose is theme-aware via typography DEFAULT vars
(prose-invert painted unreadable text under light themes). /docs renders the
index directly; standalone 404 without the tailwind CDN.

Tests: navigation routing/expansion (internal/docs), render pins asserting no
login form/password field/app sidebar ever return on docs pages.
lint-rendering: exempt *.test.ts fixtures (jsdom DOM building); docs.ts
baseline 5 -> 1.
2026-10-24 14:31:36 -04:00
John O'Keefe 9d0ae2bcc4 feat(web): sync-conflict banner at top of media item page + end-time help row
Release / build-and-push (push) Successful in 3m9s
- A progress sync conflict used to be flagged only inside the progress
  card, far down a long page. An unresolved conflict now also renders
  a full-width warning strip at the very top (under the app bar) that
  opens the same resolution modal on click. Rendered from the same
  ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
  cycle total → book left → chapter left (§10's toggle was
  undiscoverable).
2026-10-23 12:30:12 -04:00
John O'Keefe f8de17723a feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
2026-10-23 11:06:42 -04:00
John O'Keefe 4769e832c4 fix(web): favicon missing on the audiobook listening page
audio_reader.templ's <head> was the only page head without the
favicon link — the tab icon vanished the moment you opened a
listener. Standard <link rel=icon> added, matching every sibling
page.
2026-10-23 10:43:10 -04:00
John O'Keefe 03c3e151dc feat(audio-player): gear-menu default speed + split shortcuts into Mouse/Touch (plan §12)
Release / build-and-push (push) Successful in 3m13s
The Player settings popover gains a 'Default playback speed' row
beside autoplay — a compact rate select, always visible (the Speed ▾
footer hides when current == default, which made the setting
undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts
dialog splits its pointer section into MOUSE (click/drag/shift/wheel)
and TOUCH (drag/pull-away) — the bar has two scrub systems and the
sections now say so.
2026-10-22 15:39:14 -04:00
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00
John O'Keefe 3325b64463 fix(audio-player): precision rail rendered transparent
Alpine :style string bindings REPLACE the element's style attribute —
the rail's static background/border/z-index were wiped the moment the
binding evaluated, leaving bare text over the transports. Fold all
declarations into the single :style binding.
2026-10-22 14:10:42 -04:00
John O'Keefe a9430f3c06 feat(audio-player): end-time label toggles total / book-left / chapter-left (plan §10 web)
Clicking the end time cycles the three display modes, persisted per
device in localStorage (audio_time_display). Pure helpers (mode list,
normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback
to book remaining) + vitest; aria-label announces the current mode and
the next. Remaining values track the scrub cursor mid-drag. 140 unit
tests; 15 Playwright checks on KJV + Gardens.
2026-10-22 13:11:16 -04:00
John O'Keefe 96fd2aad42 feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
2026-10-22 13:05:10 -04:00
John O'Keefe 1f30602c23 feat(audio-player): progress bar overhaul — ticks, buffer, wheel, tooltip, precision rail (plan §1)
The scrubber becomes a layered bar: the native <input type=range> stays
on top for a11y (focus, arrow keys, aria — its track goes transparent
via static page CSS) while the layers beneath render the state:

- Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px
  apart) so an 1189-chapter 86-hour book gets ~100 readable marks
  instead of a solid mass; the current chapter's tick is tinted.
- Buffered span: the ACTIVE track's buffered-ahead end mapped into the
  global bar (buffers are per-track and never span the whole bar).
  bufferedAheadEnd reads the range containing the playhead — a huge
  m4b's moov index at the file tail is its own buffered range and must
  not read as 'the whole book is buffered'. Rides progress + the
  events that demonstrably fire for Range-served files that go
  network-idle while paused (timeupdate/canplay/loadedmetadata/playing).
- Wheel over the bar: ±5s nudge, Shift = ±60s.
- Hover tooltip: time + chapter title under the pointer.
- Precision rail: while dragging, a ±5-minute window floats below the
  bar (fixed anchor; release and re-grab to travel far) and the drag
  maps at minutes-per-pixel instead of hours-per-pixel — the whole
  point for 80h+ books. Click snaps to a chapter line within ~5s
  (snapToChapter).

Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel
+ snaps, captured moves remap through the rail, release seeks. The
native drag default is prevented so the two mappings never fight;
keyboard arrows keep their native step and seek via change.

Time labels get reserved width — the bar no longer resizes when the
elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail
drag check measuring a 25% mapping drift).

Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap,
tick density, per-track buffer→global span, rail window/mapping/ticks.
Unit: 16 new tests including both book shapes (86h/1189ch and
26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of
the Moon (single-file m4b); zip books share the folder manifest shape
(no zip fixture exists in the dev library). Probe user deleted.
2026-10-22 09:49:02 -04:00
John O'Keefe 18bed79d3a feat(audio-player): settings popover + autoplay-on-open (plan §4)
Header gear (beside the keyboard icon) opens a 'Player settings'
popover — one option row today, structured for more: 'Autoplay on
open', a role=switch toggle persisted to localStorage audio_autoplay
('1'/'0', default off) via the normalizeAutoplay one-source helper.

maybeAutoplay() runs strictly AFTER restorePosition resolves: the
?loc=audio:<seconds> deep link keeps winning on position; the setting
only decides whether playback starts. A browser autoplay-policy
rejection (or any play() failure) is swallowed silently — the user
just taps play; the unhandled-rejection trap in init would otherwise
shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so
Esc closes the panel with the other surfaces.

Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default
off stays paused, toggle + persistence, reload autoplays with no
gesture, deep link lands at 600s global while autoplay starts, off
stays paused, and a stubbed NotAllowedError proves the silent
swallow; probe user created and deleted.
2026-10-22 09:04:03 -04:00
John O'Keefe fe7eab91e8 feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.

audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.

End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.

sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.

Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
2026-10-22 08:40:54 -04:00
John O'Keefe 295c4b0664 feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).

audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.

Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).

SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.

Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
2026-10-22 08:15:06 -04:00
John O'Keefe db96ea91cd refactor(audio-player): one source for the speed list (plan §5)
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
2026-10-21 21:08:27 -04:00
John O'Keefe fa5b952c54 fix(audio-reader): valid doctype — page loaded in quirks mode
audio_reader.templ was the only template emitting <!DOCTYPE html/>
(trailing slash is a parse error), which put the audio player page in
quirks mode — document.compatMode was BackCompat while every other
page is CSS1Compat. Quirks layout rules are a standing hazard for the
player's h-screen/flex page even where it happens to render. Drop the
slash.
2026-10-21 20:21:58 -04:00
John O'Keefe ff17d14694 fix(book-detail): hero backdrop — templ double-escapes style values
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal &#39; text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.

Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
2026-10-21 20:21:33 -04:00
John O'Keefe 807899b137 fix(reader): arrows stay page turns while the progress slider holds focus
Clicking the bottom progress slider left it focused, and the reader's
keydown guard treats INPUT focus as typing — so arrow keys stepped the
range input instead of turning pages. With step="any" each press moved
~1% of the book and key repeat crossed the whole book in seconds; the
debounced progress saves then persisted every wild landing (positions
scattered from page 5 to 285 to 100%, "progress no longer saves
correctly after page turns").

Two layers:
- reader.templ: all three progress sliders blur on pointerup, so a
  mouse scrub no longer leaves focus behind.
- reader.ts handleKeydown: ArrowLeft/ArrowRight on a focused progress
  slider preventDefault, blur, and fall through to the page-turn
  handlers; every other key still belongs to the control, so a
  keyboard user who Tabbed to the slider keeps arrows for it.
2026-10-21 20:21:07 -04:00
John O'Keefe 9b5defcb31 feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)
processing_issues only recorded cross-library move rejections; files
that failed to import vanished into an error counter and stdout. The
ledger now records every failure class and heals itself:

Schema (in place, pre-release boundary):
- media_item_id NULLABLE + file_path TEXT (deliberately unbounded —
  an over-long path is itself a failure mode; a VARCHAR(500) ledger
  column died on the exact 22001 it existed to report — caught by the
  new test). Row-less failures key on (library, file_path); item-backed
  on (item, type). Widened dedup = COALESCE-expression unique index;
  the old table UNIQUE stays (NULLs pairwise-distinct for row-less).
- Conflict-target upsert: repeat failures UPDATE the same row and
  REOPEN resolved ones — never duplicates.

Wirings:
- CreateMediaItem failure (single-file + multifile createAudioBookItem)
  → row-less import_failed (error).
- Extraction hard-fail with fallback import → item-backed
  metadata_extraction_failed (warning).
- Change-path re-extraction failure (the ev/sidecar guard) → item-backed
  metadata_extraction_failed; LogProcessingIssue keeps its signature, a
  row-less helper joins it.

Auto-resolution:
- Successful import → ResolveOpenProcessingIssuesForPath (library,
  file_path).
- Successful (re)extraction → ResolveOpenProcessingIssuesForItem
  (single-file change path, multifile editorial apply).
- Unchanged-skip scans do NOT resolve: extraction never re-ran, so the
  degraded metadata is still degraded — the page heals exactly when the
  book does.

Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR
page): explicit out-of-set library id → 403, never silent. This
tightens one pre-lens behavior — a nonexistent library id returned
200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md).
List is LEFT-JOINed so row-less rows appear (media_item_id "",
title falls back to the path).

Tests: full lifecycle state machine through real scans (fail → appears,
repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape);
family lens 403s with a fresh non-shared admin; all 7 pre-existing
issue suites green (two expectations widened to the lens, changelogged);
adjacent suites (CrossLibraryMove, Audiobook, Calibre,
ExtractorVersion, SchemaIdempotency) green; unit suites green.
2026-10-20 19:38:02 -04:00
John O'Keefe 4c03116ec7 fix(filters): value menu flash-on-click, empty state, sticky field filter (user report)
- The flash was a race: on localhost the suggestion fetch resolved
  between the focus (mousedown) and the click event, so the just-opened
  menu was closed by its own click-outside handler — the input sits
  outside the menu div. Clicks anywhere inside the value cell now
  never close the menu; outside clicks do.
- The menu opens immediately in a loading state; an empty browse
  renders 'No matching values' instead of nothing. Plain free-input
  fields (page_count etc.) open no menu at all.
- Field menu: the filter input moved outside the scroll region — it
  sticks while the grouped list scrolls beneath it.
2026-10-20 15:36:31 -04:00
John O'Keefe c747f0a76c feat(filters): per-row AND/OR (Excel-style chips) + browse-on-click value menus (user report)
- Per-rule connectives: the chip before each rule flips that rule
  between AND and OR — OR-joined neighbors merge into an invisible
  OR-block, AND pulls them back out. The tree stays the single source
  of truth (implicit blocks are a client marker the server ignores),
  so deep links, saved filters, and the composed engine are untouched.
  Group-mode pills are gone; explicit groups remain as pure structure
  (+ group). Compile limit, same as Excel: flat chips express
  AND-of-OR-groups; a top-level OR of AND-blocks still needs nesting.
- Browse-on-click: clicking an empty value box now fetches the field's
  top values UNDER the other rules (blank typed = browse, not a
  similarity filter) — 'assign an author, click Publisher, see their
  publishers' without typing. Server: the composed suggestion engine
  trims the typed query; client: the >=2-char guard no longer blocks
  the empty query, browse limit 15 vs 8 while typing.
- Tests: the browse case pinned (blank sentinel + rules → the field's
  values under those rules, zero-match values absent).
2026-10-20 15:25:57 -04:00
John O'Keefe 6f98ca4c22 fix(filters): group mode reads AND/OR, connective labels toggle it (user report)
The ALL/ANY pills required mapping ALL→AND, ANY→OR that nobody should
have to learn. The pills now say AND / OR directly (wire values stay
all|any — server contract untouched), and the AND/OR text between
sibling rules became a clickable toggle for its parent group, so the
switch lives exactly where you're looking.
2026-10-20 15:09:44 -04:00
John O'Keefe 04b36f0563 fix(filters): builder UX round one — incomplete rules, group removal, field menu (user report)
- Root bug: incomplete rules (the ready rule, every new group's first
  rule) were sent to the server — the live-count fetch toasted a 400
  and every suggestion fetch failed silently (why author typing showed
  no dropdown). Fix: completedTree() prunes incomplete rules and empty
  groups; the count fetch skips when nothing is complete, suggestion
  fetches send only the other COMPLETED rules, and serialization wires
  only completed rules (an unfinished rule was never a filter).
- Group remove button was x-show'd away on depth-0 groups — exactly
  where user groups live. Always visible now.
- Field menu: filter input at the top (Enter prevented), no-match
  state; group headers restyled as flush-left tinted sticky bands with
  options tabbed in beneath (user's layout pick).
- toast.ts: filter-count fetches join the suggestion-fetch exemption
  (a failed background hint never interrupts).
2026-10-20 15:04:19 -04:00
John O'Keefe 4ee24e7152 feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields —
  every book column, file/lifecycle facts, the three HOLDING
  STRUCTURES (library, collections with excluded-aware membership,
  series with FULL primary+alternate_info membership), and the whole
  user-relative class (status, progress, last read, my rating, review,
  notes/highlights presence, note/highlight text, time spent, pages
  read, collections, added by) reading the VIEWER's rows. Operators
  are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
  over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
  shape), the field-aware suggestions, and a true-COUNT live count.
  Mandatory WHERE head everywhere (family lens + integrity facts).
  Values are always bound parameters — no user string reaches a
  field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
  fields/operators/malformed values are 400); qf wins over flat params
  on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
  field-values autocomplete upgraded in place — any registry field
  key, qf binds the OTHER rules (symmetric under-filter suggestions,
  zero-match values never offered); classic six without a tree keep
  their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
  markup, Alpine tree state, sanctioned island; grouped field menus,
  operator menus with red negations, field-aware value menus, live
  count, chips per rule with red negations, URL rehydration, smart
  serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
  numbers/dates/empty), every user-relative field class with the
  my-data weld (two readers, different worlds), note-text substring
  pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
  403s), suggestion-under-rules exactness, qf validation + precedence,
  flat-vs-tree equivalence; registry completeness unit test.
2026-10-20 14:49:50 -04:00
John O'Keefe ebb7f4a5b0 feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)
- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
  FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
  The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
  drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
  tags/narrator/series/language) treats library_id as OPTIONAL —
  absent aggregates every VISIBLE library (family lens in-query);
  explicit out-of-set library_id is now 403, never a silent pass.
  The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
  (console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
  Libraries pins the fix (200 + cross-library aggregation, family
  lens, 403). Contract-pins changelog entry added.
2026-10-20 12:20:51 -04:00
John O'Keefe f52bb6f073 feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.

- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
  listable facets in a single round trip: each arm omits its OWN
  facet's filter block and carries every other registered facet's
  block (copied mechanically from SearchMediaItemsUnified) — classic
  faceted semantics: with filters active each facet counts under the
  others; with none, blocks are neutral and the counts are the v1
  global-per-library ones. One query serves both cases. The 14
  per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
  ({facets: {param: [{value, display?, count}]}}), filters bound via
  the shared ParseMediaSearchParams, library_id optional (the
  All-Libraries view, family lens still enforced); facet=X keeps the
  pinned single-facet shape (now routed through the same query with
  neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
  value lists — registry order, Batch D library-type scoping
  (FacetsForLibraryType: manga_type on manga, reading_direction on
  manga+comics, narrator on audiobooks; All view = union), prettified
  genre labels + language display names, active values marked. Family
  lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
  apply with the CURRENT filter query string; a value click toggles
  that facet's comma-joined filter (hidden input where the drawer has
  no field — same as deep links) and re-applies the form; chips and
  URL stay in sync. Values are server-rendered (rendering doctrine);
  bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
  implementation (services' display enrichment shares it; templ wraps
  it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
  registry facet's param, the bools as pgtype.Bool.

Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
2026-10-20 08:24:36 -04:00
John O'Keefe bbedbfb13f feat(facets+ui): clickable facet values everywhere, genre prettify, active-filter chips
Frontend half of the faceted-navigation Phase 2. 'Everything filterable
should be clickable': every displayed facet value links to the bookshelf
pre-filtered by its raw stored value; display forms are prettified at
render, never in the DB.

- templates/facet_links.templ: FacetLink (inline links) + FacetBadge
  (badge links) to /bookshelf?<facet>_filter=<value>; the raw value
  rides the param while the shown text is prettified, so matching and
  display can never disagree. Series/tags keep their dedicated pages
  by design; the card media-type icon stays unlinked (a derived kind,
  not a column value — deep-linking it would send wrong values).
- book_detail.templ: publisher, language, edition, genre, imprint
  cells + narrators line + age-rating/story-arc badges are now links.
- prettifyGenre (templates/utils.go + unit tests): title-cases only
  entirely-lowercase values; small words stay down except first
  position; & preserved. TS port for chip labels documents the twin.
- bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine
  renders chips from client-local form state (sanctioned island);
  one bubbled htmx:afterRequest hook keeps chips + shareable URL in
  sync; removeFilterChip clears the facet and re-applies. URL deep
  links now accept all 18 facet params (hidden inputs where the
  drawer has no field).

Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering
ownership guard (make lint-rendering) green.
2026-10-19 18:59:36 -04:00
John O'Keefe 7537063cb7 feat(filters+nav): author navigation end-to-end — arrays on every media response, deep-linkable bookshelf filters, clickable authors
Completes the A2c acceptance scenario: click an author on a book
detail page -> land on the bookshelf filtered to everything that
author wrote (including their share of co-authored books).

- Media detail + unscoped list resolved maps expose subtitle,
  explicit, authors, publishers (the library-scoped raw branch and
  the search endpoint already did — raw rows follow the SELECT).
- SearchMediaItemsUnified SELECT carries the new columns; the search
  endpoint serializes the row directly, so clients get them there too.
- book_detail.templ renders each author as a link to
  /bookshelf?author_filter=<name>.
- Bookshelf honors filter params in the URL on load (author_filter,
  series_filter, genre_filter, publisher_filter, tags_filter,
  narrator_filter, language_filter): pre-fills the form, applies it to
  the first grid load. Comma-joined values are multi-select via the
  server-side string_to_array split. Filtered views are shareable.

Pins widened: search rows (+7 keys), resolved keys (+4); changelog
entries in docs/contract_pins.md. Android follow-up plan written to
~/.opencode/plan/bookhoard-app-v2-compat.md.
2026-10-19 16:16:58 -04:00
John O'Keefe f94e7472b4 feat(filters+ui): per-element author/publisher filters, playlist toggles, Up Next API, reorder UI, dashboard badges
Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.

Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.

Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.

Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.

Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).

AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.

Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
2026-10-19 15:43:59 -04:00
John O'Keefe 646477afad feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
2026-10-19 14:17:50 -04:00
John O'Keefe a8641ab343 chore(ui): bump the main.js cache-bust stamp to v6
web/static/*.js is gitignored and built at image-build time, so the
hardcoded ?v= on the module script tag is the ONLY mechanism that
evicts a stale bundle from browsers. Shipping the series twin deletion
without a bump would let cached main.js (with the deleted renderer)
keep running on prod, making the paginator fix appear not to work.
Found while shipping; easy to miss because the bundle never appears in
git status.
2026-10-19 12:40:31 -04:00
John O'Keefe 1ce66b2213 chore(templ): warn against change handlers on the bookshelf library select
The bookshelf select (#library-select) sits inside the htmx filter
form and the SSR route honors ?offset= — wiring a change handler or
hx-trigger="change" on it would let header.ts's synthetic restore
event re-fetch the grid at offset 0, the same stomp class that broke
the /series paginator. Comment-only guard; regenerated
bookshelf_templ.go carries it into the emitted markup.
2026-10-19 12:40:13 -04:00