Commit Graph
447 Commits
Author SHA1 Message Date
John O'Keefe 4c43caa639 fix(search): dropdown dead on htmx-less pages; Alpine-side debounce; auto bundle stamps
The "top search bar doesn't work" report had TWO stacked causes, found
by reproducing with real keypress-by-keypress typing instead of
Playwright's fill() (which fires a single input event and masked both):

1. htmx.min.js was included PER PAGE. The header's live search is
   htmx-driven on every page, but five pages rendering @Header —
   series, admin, admin hash-conflicts, admin processing-issues,
   browse-detail — never loaded it: the input's hx-* attributes were
   inert (htmx-internal-data absent), so typing produced no request at
   all. The include now lives in the header partial itself (classic
   script, before the module bundle); the sixteen per-page copies on
   @Header pages are removed (double-processing guard). Non-header
   pages (login/register/setup/reader/index) keep their own.

2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event
   (htmx.trigger) loses the request entirely under rapid successive
   triggers — every re-fire canceled the pending timer and the last
   never issued, so fast typing (the normal kind) never opened the
   dropdown while one-shot fill() worked. The debounce moved into the
   Alpine component (client-local state, where it belongs); htmx now
   executes the bare `search` event immediately.

Also made bundle staleness structurally impossible: each bundle's
script stamp is a content hash computed at startup (templates/
bundles.go), so a rebuild that changes bundle bytes changes its cache
key with no manual ?v=N bump — the manual stamp was how the first
dropdown fix stayed invisible for a deploy. The reader bundle (never
stamped at all) is covered too. HTML pages additionally send
Cache-Control: no-cache (they are dynamic), so a stale page can no
longer reference a stale stamp; /api, /static and /uploads keep their
native caching, pinned by a middleware test.
2026-10-27 23:33:52 -04:00
John O'Keefe fd9292e6f2 fix(series): Date Added respects the direction toggle; bump bundle stamp
Two follow-ups from live testing:

- "recent" is BOTH a base toolbar key and a full registry key (where it
  means desc). ResolveSeriesSort's full-key passthrough ran first, so
  sort=recent&dir=asc silently resolved back to desc — the direction
  chevron flipped but Date Added could never sort ascending. "recent"
  now composes with the direction before the passthrough, unknown bases
  fall to the default entirely (a hostile dir can't ride an unknown
  sort), and the composition table is unit-pinned plus an HTTP-level
  row in the series filter test.

- The header search "still broken" report was a stale-cache artifact:
  the fixed code shipped but main.js kept its ?v=7 stamp, so browsers
  with a cached copy never refetched (fresh-profile e2e passed while
  the user's browser didn't — heuristically cached module). Stamp bumped
  to ?v=8 in header + docs; the e2e suite re-verified against the
  rebuilt server.
2026-10-27 22:55:11 -04:00
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00
John O'Keefe 96c1228805 feat(series): multi-field search, sort-direction toggle, apply placement
Toolbar feedback round: the series search box now matches whatever the
main search matches for a group — series NAME, any book's AUTHOR, and
any book's TITLE inside the series (WHERE EXISTS per group-row, so
counts stay true); the separate author input is gone from the toolbar
(the author= param stays live for the JSON API and deep links). The
sort control becomes a base-key select (Date added/Name/Author/Books/
Progress/Last read) plus a chevron direction toggle that flips and
resubmits; sort/dir compose through ResolveSeriesSort (full registry
keys keep working for back-compat, unknown base keys fall to recent).
The search/apply button moved next to the search box as requested, and
default URLs stay clean (sort/dir only emitted once the user chooses).
2026-10-27 19:05:23 -04:00
John O'Keefe eaf1b2c8dd feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars
Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
2026-10-27 15:36:43 -04:00
John O'Keefe 61e5f47ec0 feat(search): viewer progress + completed badges; dropdown to templ
Search results now show where you are in a book: a completed check at
the 0.999 threshold (same cut as the filter status arms), a partial
progress bar along the cover while mid-read, nothing when you never
opened it. Rendered in BookCard (any grid whose adapter loads viewer
progress) and in the header-search dropdown rows.

The progress rides a LEFT JOIN reading_progress on the SAME user_id
param SearchMediaItemsUnified already binds for library visibility —
one requester param, no new wire inputs, row-neutral join (the table
is UNIQUE per media_item+user). SearchMediaItemsUnifiedRow and BookInfo
gain the keys additively (viewer_percentage null when unknown,
library_name empty on feeds that don't load it); contract_pins.md
carries the changelog entry and the pin tests widen in lockstep.

The header dropdown itself migrates off the innerHTML string renderer
in search.ts (a rendering-ownership violation: a templ twin existed via
the HX-Request branch). The input now hx-gets layout=dropdown and swaps
the new templ SearchResults partial — titles/authors highlighted
server-side (escape-first, safe by construction), rows with the same
progress overlays — while a slim Alpine component owns only
client-local state (min-length gating, open/close, keyboard selection,
htmx lifecycle). web/src/rendering-allowlist.txt drops the search.ts
baseline to zero accordingly.

Integration test pins the per-user join (fresh user sees 1.0/0.42/null
on the fixture; the shared admin sees null everywhere — read-only) and
the dropdown partial's overlays/count/row count.
2026-10-27 15:11:49 -04:00
John O'Keefe d1f9ad3b25 feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).

Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).

Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.

Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
2026-10-27 14:53:45 -04:00
John O'Keefe 248bb7816d feat(issues): Processing Issues page retry UI with a sequential client queue
Per issue card: a Retry button — error-severity issues previously had no
action at all (only warning/info got Dismiss). Right of the Errors stat
card: Retry all, which builds a queue of every issue currently on the
page and runs them strictly one at a time through the per-issue retry
endpoint. The browser is the queue — resource use is bounded to a single
book in flight, and no batch endpoint exists to maintain.

While the queue runs, the button shows an n/m counter and cards tint via
client-local state classes (the sanctioned island shape; the cards
themselves stay templ-rendered). After the queue drains, the page swaps
in the new /admin/libraries/:id/issues/content templ partial — the
unlinked-books htmx.ajax pattern with a reload fallback — so resolved
issues drop off server-side while failures remain listed, with a
summary toast and per-item error toasts carrying the real failure
reasons.

CSP discipline holds throughout: Alpine @click component methods, no
inline handlers, no js:-prefixed hx-vals.
2026-10-27 13:22:54 -04:00
John O'Keefe 0f1d0abbb0 feat(libraries): scope a library scan to one registered folder
POST /api/libraries/:id/scan accepts an optional {"folder_path": "..."}:
the scan then covers exactly that one registered folder's subtree instead
of the whole library. The path is validated against the library's
registered folders — anything else is a 400, never trusted. An omitted
body keeps today's full-library behavior, and coalescing (409 with the
active job) is unchanged.

The LibraryPanel folder rows get a per-folder Scan button: pure htmx
attributes posting to the same scan endpoint, with hxValsFolderScan
emitting plain eval-free JSON for hx-vals alongside the existing
hxValsFolderDelete precedent. Progress surfaces through the existing
active-scan polling; no new client code.

Safety: the scanner's archive-reconciliation guard (previous commit)
skips partially-covered libraries, so a one-folder scan cannot mark the
library's other folders' items missing.
2026-10-27 13:22:41 -04:00
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