Compare commits

...
142 Commits
Author SHA1 Message Date
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 bdb9132b85 chore(auth): drop dead UpdateProfileRequest; rename shared struct
The combined profile handler (self-edit PUT /api/auth/profile and
admin PUT /api/auth/profile/:id) binds one struct for both paths —
and it's the admin-named one. Its twin UpdateProfileRequest had zero
references left. Delete it and rename AdminUpdateUserRequest to
ProfileUpdateRequest so the name matches who actually binds it; the
comments describing field semantics now stand on their own instead of
pointing at the deleted twin. Wire format (json/form tags) unchanged;
docs schema registration follows the rename.
2026-10-26 13:59:22 -04:00
John O'Keefe 649f821850 docs nav: correct casing of 'Searching and Filtering' sidebar label
Release / build-and-push (push) Successful in 3m50s
docTitleOverrides now pins the label with lowercase 'and' (the naive
titleCase fallback capitalized every word); only the sidebar label was
affected — the page h1 comes from the markdown heading and was already
correct. Test pin added to TestDocTitle.
2026-10-26 08:26:53 -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 56dbca37d0 docs nav: move Administration above Devices in sidebar order 2026-10-25 21:15:47 -04:00
John O'Keefe 27f42bf703 docs nav: promote Devices and Administration out of User Guide
Follows the developer/api → API Reference carve-out: placement-only, no
file paths or URLs change. placeDoc routes user/devices/* to a new
top-level "Devices" section (icon: device) and user/admin-* to a new
top-level "Administration" section (icon: shield); User Guide keeps its
direct guides with no groups. Sidebar order: Getting Started, User
Guide, Devices, Administration, API Reference, Developer, Operations,
Contributing.

Verified live: on kobo-setup the Devices section opens with the three
device guides; on admin-guide Administration opens with both admin
guides; on user/dashboard User Guide opens as before. Tests
(TestPlaceDoc, TestBuildNavigation) updated to pin the new placement.
2026-10-25 21:10:53 -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 ab2a5568ae fix(scanner): split comma-joined ABS sidecar genres into separate tags
Audiobookshelf-style exports join genres into one comma-string
(["Epic, Action & Adventure, Military, Sword & Sorcery"]). The
smart-genre picker already split candidates for the genre column,
but the tags assembly used the raw unsplit array — so the whole
string landed as ONE element of media_items.tags and rendered as a
single tag everywhere.

Use pickSmartGenre's split candidates for tags too, exactly as the
function's own comment documents ('split parts included stay in
tags'). Commas inside a single subject heading remain legal within
one tag (EPUB dc:subject path unchanged).
2026-10-25 12:05:14 -04:00
John O'Keefe ecfd7276ab test(e2e): add Playwright system smoke and regression suite
Covers: health, auth guards, register/login/token flow, every
protected page render with per-page uncaught-error capture, devices
modal wiring, stats tabs, docs pages, theme switch persistence,
reader links against real library content, security header presence,
and explicit regression assertions for the functions removed in the
dead-code cycle (Clear Queue button, queue endpoints).
2026-10-25 11:24:25 -04:00
John O'Keefe b6eba66801 fix(web): restore Alpine theme members and allow eval for Alpine expressions
Two regressions from the cleanup cycle, caught in E2E:

1. The unexport pass stripped loadTheme, loadUserTheme, and
   updateThemeIndicators from the Alpine.data("theme") registration —
   the member-removal pass could not tell export-block lines from
   Alpine component object lines. Theme dropdowns on docs and
   non-app pages stopped reflecting the active theme. Members
   restored (functions were kept, only their scope registration was
   lost).

2. The new Content-Security-Policy shipped script-src without
   'unsafe-eval'. Alpine evaluates every @click / x-init / x-show
   expression through new Function(), so the CSP silently disabled
   ALL Alpine interactivity app-wide: sidebar categories stuck open,
   modals rendering unhidden and unresponsive, dropdowns inert.
   script-src now includes 'unsafe-eval' with a comment explaining
   the Alpine requirement.
2026-10-25 11:24:25 -04:00
John O'Keefe 4ab6a64bd8 docs: drop stale interactive API Explorer references
The explorer page was never routed and its client module is removed
in the preceding refactor; the endpoint docs render examples
statically.
2026-10-25 11:06:27 -04:00
John O'Keefe dc0e3573a6 chore(web): add knip and remove unused npm dependencies
Drops highlight.js, lunr, autoprefixer, and @types/markdown-it —
all verified unreferenced (markdown-it ships its own types). Adds
knip.json configured for the templ/htmx architecture: main.ts and
reader/reader.ts entries, vitest tests as entry points, and an
ignore list for the ten modules whose exports are bound by templ
Alpine attributes — string bindings that static analysis cannot see.
2026-10-25 11:06:27 -04:00
John O'Keefe a716b386a1 fix: wire security headers, graceful shutdown, and settings invalidation
Lifecycle (internal/app + main.go):
- App.OnShutdown hooks run after the HTTP server drains, in
  registration order: Worker.Shutdown (background jobs now drain
  instead of dying on SIGTERM), DeviceAuthMiddleware.Stop (releases
  the rate-limiter ticker), Handler.CloseScanner (fsnotify watcher
  and scan log files)

Security headers:
- SecurityHeadersMiddleware (survivor of middleware/security.go,
  renamed security_headers.go) is now on the echo stack: nosniff,
  frame-deny, HSTS, CSP (unsafe-inline required by the sanctioned
  theme flash guard + Alpine), referrer and permissions policy

Scanner settings cache:
- settings saves (UpdateScanSettings + ApplySetting for the three
  scan keys) now invalidate MediaScanner's 30s settings cache via
  SystemSettingsHandler.SetScanSettingsChanged, so changes apply on
  the next poll instead of after the TTL
- MediaScanner.Close closes the scan logger, fixing a file-descriptor
  leak on every per-item rescan (fresh scanners never closed logs)

Client fix:
- custom-section-builder's add/remove-selection buttons used inline
  onclick attributes referencing module-scoped functions, which are
  not global in a bundled ES module: the buttons threw ReferenceError.
  Functions restored and exposed on window via declare global

Also fixes a pre-existing vet error (saveCoverImage copied
echo.Context by value, mutex included) and drops the per-key
SettingsCache.InvalidateKey variant left callerless by the
full-flush invalidation wiring.
2026-10-25 11:06: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 dd2ab4ea96 docs(ui): center the active nav link in the sidebar instead of edge-clamping
The clamp put deep-linked highlights flush against the sidebar's bottom
edge. Centering is simpler AND handles the max-scroll case for free:
scrollTop carries the item's center to the scrollport center, and the
browser clamps to the scroll extent — items near the nav's top/bottom
land as centered as the content allows (scroll pinned fully up/down),
which is the requested fallback.
2026-10-24 22:04:06 -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 a1ac5b1541 docs(ui): canonicalize .md URLs — sidebar now opens when arriving from overview links
Root cause of 'clicked from overview, bold but menus closed': cross-page
markdown links point at area/file.md; the handler happily loads .md
URLs, but the sidebar keys on the URL path — contains-URL expansion and
the active-link styling compare against extension-less nav URLs, so a
.md path rendered the page with its menu closed and the current item
unhighlighted. (My curl checks had used clean URLs, which is why it
worked in testing but not from the overview page.)

Fix: ShowDocumentation 301-redirects .md paths to the clean canonical
form (query preserved; browsers re-attach fragments), and
renderDocPage normalizes the path for nav expansion/grouping as a belt-
and-braces. Live-verified: .../get_ratings.md -> 301 ->
clean page with both containing menus open and the get_ratings link
carrying the active class.
2026-10-24 21:54:06 -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 983980d07a bruno+docs: status asserts on generated mirrors, OpenAPI spec, safe negative scenarios
Three follow-ups from the smoke run, all additive:

1. Status asserts: bruno_gen.go parses each doc's documented success
   status (## Response (2NN), 'Success is **201**', '**204 No Content**')
   and emits a runtime tests script (expect(res.status).to.eql(N)) on
   148 of 191 mirrors. GETs default to 200 (uniform across this API);
   mutating verbs stay assert-less unless documented — no guessed
   asserts. Verified live: asserts execute and fail loudly (deliberate
   999 probe), pass on real responses.

2. OpenAPI: the same -update run now also writes
   docs/developer/openapi.yaml (141 paths, tags from docs areas,
   security schemes per auth label, request examples literal, no
   invented schemas — the docs site remains the shape reference).
   Verify mode fails when the file is missing.

3. Scenarios: 7 safe negative scenarios rebuilt (auth registration
   matrix 409/403/400, invalid-uuid 400, unknown 404 x2, invalid range
   400) — all no-mutation, repeatable, run via bru: 7/7 tests pass.
   These replace a fraction of the deleted broken suite; mutations-
   heavy scenarios stay out until a design for setup chains exists.

created_at RFC3339 deliberately NOT done — it is the one item that
would break things (Android parses current formats; plan reserves it
as a compat decision).bru run of mutating mirrors also proven to touch
live data (a 'To Read' collection was created and deleted in testing);
only id-less GET mirrors and negative scenarios are safe for blanket
runs.

make test green; generation idempotent (byte-identical re-runs);
no stray smoke rows in users.
2026-10-24 19:20:13 -04:00
John O'Keefe 0c45137f5b docs(users): live-smoke correction — GET /api/auth/users emits sqlc ListUsersRow, no password_hash
Curated smoke run of the generated collection against the live dev
server (37/37 after fixes): registration matrix (409 duplicate email,
403 admin-creation-with-admins-present, 400 weak password), non-admin
403s, device-token/opds 401s, collection CRUD incl. the documented 201
update quirk and 204 delete, stats routes, scanner, jobs, malformed-uuid
400s. Testuser untouched (password test uses a guaranteed-wrong current
password -> 401); the smoke user was admin-deleted in cleanup and its
token verified dead.

Correction the smoke run forced: the batch-8 'password_hash leak' was a
false alarm — my registry pin ([]database.Users) never matched the
handler, which returns []database.ListUsersRow (sqlc join with
device_count). Live response confirmed: id/email/username/theme/names/
role/max_devices/created_at/updated_at/device_count — NO hash. Doc
example, field table, and registry pin all corrected; leak note
replaced with the live-verified statement.

Also verified via the real Bruno CLI: 9 safe generated requests
(4 shown passing incl. stats/collections/devices; password-rules
public) execute against the server with collection-level auth.
Full-collection bru run deliberately not attempted (mutations in
static bodies would touch live data).
2026-10-24 18:58:39 -04:00
John O'Keefe cfcd95b25e bruno: replace Python generator with Go golden-writer in the drift test
The generator moves into the Go toolchain per repo preference:
internal/docs/bruno_gen.go emits the OpenCollection mirrors (same output
contract — gate-verified example-request bodies, env-var params,
device-token headers, login/register/refresh token-capture scripts) and
TestBrunoMirrorsDocs grows a -update flag: verification by default,
regeneration on demand:

  go test ./internal/docs -run TestBrunoMirrorsDocs -update

Generator and checker are now the same code — they cannot drift apart.
scripts/gen_bruno_from_docs.py deleted; opencollection.yml README points
at the go-test command.

Also fixed vs the Python pass: mirror placement follows the docs tree
folder (RelPath), not the area frontmatter (several docs — highlights,
notes, ratings, users, progress — carry a different area); docs blocks
emit the collection's mapping form (content + type), not a bare scalar;
JSON body sanity is checked at generation time.

Idempotent: back-to-back -update runs are byte-identical.
make test green (9 pkgs).
2026-10-24 18:47:56 -04:00
John O'Keefe acd82fff0a bruno: regenerate collection from canonical docs + permanent docs↔Bruno parity gate
WO4. The old collection was the drifted artifact AGENTS.md warned about:
bodies pre-truncated at their first {{var}}, routes renamed server-side
but not here (/api/notes/:media_id, /api/book-matching/search,
/api/collections/:id/rules/test), an orphan universal-progress suite for
routes that no longer exist.

scripts/gen_bruno_from_docs.py emits one OpenCollection request per
frontmatter'd endpoint doc (191 requests): gate-verified example-request
JSON as body, {{env}} params per the environment file, device-token
routes get an explicit Bearer {{device_token}} header, login/register/
refresh keep the token-capture after-response scripts that chain the
collection. Old 1:1 mirrors deleted (128); hand-authored NewDevDBSetup,
environments, and edge-case suites kept. 30 scenario files with
truncated bodies (the documented corruption) deleted — they could not
run.

internal/docs/drift_test.go gains TestBrunoMirrorsDocs: every
frontmatter'd doc must have a Bruno request with matching method+URL
shape — the collection can never silently drift from the docs again.
opencollection.yml README block replaced with the real run instructions.

make test green (9 pkgs, parity test included in internal/docs).
2026-10-24 18:35:51 -04:00
John O'Keefe 6fc501b35a docs(stats): Claude-side pinning — all 4 stats endpoints + UNVERIFIED sweep to zero
Stats docs written handler/service-first (summary/this-week/book-stats
new; library-usage frontmatter'd — its body was already accurate).
Registry: 4 entries on services.UserSummaryStats/WeekSummaryStats/
LibraryUsageStats/BookStats. Family-lens note: library-usage 403s an
explicit library_id outside the visible set (431fec0 rule).

UNVERIFIED sweep (7 -> 0), each resolved against the handler:
- device-usage takes NO date params; popular-books takes only ?limit
  (the 'standard analytics date-range params' claim was wrong)
- link_book media_item_id: no validate tag but service-always-written
- push_stats book-ref field set documented (uuid/sha256/title/authors)
- filter_count: confirmed ParseMediaSearchParams machinery + 400 on bad
  filter + 403 visibility
- facets values element shape pinned: {value, display?, count}
- get_config: dynamic key set IS the contract (reworded, no marker)

make test 9/9.
2026-10-24 18:25:12 -04:00
John O'Keefe e21e1facde docs(media-items,collections,koreader,admin): Beam batch 11 — final backfill
All remaining endpoint docs frontmatter'd: media-item bulk ops + audio
surface, collections book-listing/preview, koreader rating/review/stats
plugin protocol, hash-conflicts JSON list.

Scope call (Claude): resolve_hash_conflict.md DELETED — the resolve POST
renders an htmx fragment (renderResolved), a web-admin flow under rule 3;
only the GET list is a JSON API. update_media_review skipped by Beam
(correct): ratings/upsert_media_review.md already covers the PUT.

Registry: 16 entries. koreader rating/review/stats all bind
koreaderBookRef (query or body) — odd=half-star rating scale; audio track
endpoint serves raw bytes (ServeContent, Range) — response nil. Fixed
drift: deleted_annotations doc's secondary restore/purge fences
unlabeled (gate validates labeled blocks against the ONE frontmatter
route); chapters example labeled. UNVERIFIED: 1 (koreader book-ref
fields, push_stats). make test 9/9.
2026-10-24 18:21:17 -04:00
John O'Keefe 18084de7a6 docs(book-matching,collections): Beam batch 10 — 15 endpoints
WO3 sync internals resolved: the book-matching docs ARE the sync surface
(books/query, link-book, bulk/auto-link, unlinked-books, suggestions,
file-alias CRUD) + collections rule/test/bulk-assign/shelf-mapping
surface.

Dedupe resolved (Claude call): search_books.md declared
GET /api/books/match but its BODY documents POST /api/sync/books/query —
both routes exist and are distinct (query-param device protocol vs JSON
body plugin protocol). Repointed search_books.md frontmatter to the POST
route; get_book_matches.md keeps the GET.

Claude registry: 15 entries (one pre-existing link-book entry deduped).
Drift fixed by gate: auto-link request {confidence_threshold, limit} not
{device_id, threshold}; bulk-link link objects {unlinked_book_id,
media_item_id, confidence_score} not {device_id, book_id, ...};
responses {results,total,successful,failed}/{auto_linked,results}; added
missing example-responses (bare delete, remove-rule is_smart-after
semantics, bulk-assign per-book results). make test 9/9.
2026-10-24 17:59:27 -04:00
John O'Keefe 7a7b2ab82a docs(highlights,notes,opds,progress,analytics): Beam batch 9 — 13 endpoints
Highlights + notes CRUD (media-item scoped, jwt), progress delete,
reading-stats analytics, and the OPDS trio frontmatter'd. OPDS docs are
the first XML entries: example-response intentionally omitted (Atom
responses), gate contract honored via nil-Response registry pins.

Gate fix: group .Use() middleware now parsed (opds.Use(DeviceAuth...
Authenticate) previously resolved to auth 'none'). Registry: 13 entries.
Drift fixed by gate: reading-stats key names (total_reading_time_minutes,
most_active_day_of_week, daily_reading_minutes — draft invented
reading_time_hours/books_completed); notes/highlights list endpoints
return BARE arrays (no {notes:[]}/{highlights:[]} wrapper); create-note
request has no percentage_location/epubcfi_location. New
/tmp/merge_arrays.py handles array-example merges.

make test 9/9.
2026-10-24 17:47:07 -04:00
John O'Keefe cc5677eb17 docs(auth,users,devices,progress,matching): Beam batch 8 — WO5 backfill complete
14 endpoints backfilled: devices pending/approve/reject; users
password/profile/profile-delete self-service (admin variants in prose);
admin list_users + update_user_max_devices; progress history; book
matching; new password-rules doc.

Gate fix: authLabel missed JWT middleware written inline as
createJWTMiddleware(cfg) (uppercase 'JWT') — reported auth 'none' for
/api/auth self-service routes and would have for any wrapper-inlined
group. Labeled 'JWTMiddleware' + fallthrough 'jwt' case.

Claude registry: 12 entries. Drift fixed by gate: list_users on-device —
handler returns raw []database.Users (bare array, full model incl.
password_hash — real leak the API emits; pinned + documented; earlier
draft had invented {users,total,limit,offset} wrapper). max-devices
response is {message} only. books/match is GET /api/books/match
{matches, action}. progress history sessions = ReadingHistory rows
(pgtype fields). make test 9/9.
2026-10-24 17:33:32 -04:00
John O'Keefe 2c56ae4b9d docs(scanner): Beam batch 7 — all 10 scanner endpoints backfilled
Scanner surface frontmatter'd (scan/start/stop/cancel/status/active/
active-summary + watch start/stop/status) with provenance comments;
active-summary on a non-admin group.

Claude registry: 10 entries. Fixed handler drift Beam missed: scan_library
request has no "recursive"; scanner/start binds folder_paths (not
invented interval_seconds); scanner/stop binds no body; response is
{message, job_id, status} with 409 coalescing variant
{error, active_job_id}.

Gate fix in drift_test seed: registerScannerRoutes group seeds used bare
"jwt"/"admin" tokens invisible to authLabel; and silently passed before
only because the seeded set was unchecked. Seeds now 'jwtMiddleware'/
'AdminMiddleware' markers. Also: cleanup of a stale-merge-script residue
in collections/update_rule.md example (extra null keys). make test 9/9.
2026-10-24 17:20:46 -04:00
John O'Keefe 25ef0d12ba docs(kobo,koreader,collections): Beam WO5 batch 6 — 12 endpoints
Kobo emulation + KOReader calibre-compatible surface backfilled with
device-token auth; collections reorder + update_rule created.

Claude registry: 12 entries. kobo/koreader emulation endpoints all bind
arbitrary protocol POST bodies (markup/bookmark/gettests/sync-from-server,
koreader progress/bookmarks) — registered as AnyJSONObject, not bodyless as
first drafted. Initialization returns the library sync payload, not the init
response.

Contract correction in update_rule.md: the route is SetRuleEnabled ({enabled}
required -> {collection_id, is_smart}); Beam's draft invented rule-field
updates. Reordered response/status: {item_ids} -> {status: reordered}.

make test 9/9.
2026-10-24 16:25:00 -04:00
John O'Keefe b77eab7ffd docs(libraries): Beam WO5 batch 5 — 11 endpoints backfilled + pilot recovery
Beam backfilled 11 library docs (CRUD, folders, visibility, browse, stats,
media-items) and re-attached frontmatter to create_collection.md — which had
silently lost its pilot version in cc893f75: the negative-test
'git checkout --' restored the OLD file because the pilot was never staged,
and a frontmatter-less doc is invisible to the gate. Claude restored the full
corrected pilot content (priority higher-wins, time.String() note, 409).

Claude registry: 11 entries + one inverted catch — DELETE /:id/folders DOES
bind {folder_path} (handler reuses AddLibraryFolderRequest); registry first
draft said bodyless. GET /:id/visibility returns joined
GetUserVisibleLibrariesRow (type_name etc.), not bare Libraries.

Gate-driven merges fixed create/get library response shapes (sqlc row keys:
created_by_admin_id, library_type_id, updated_at; dropped invented type/
folders/is_visible). Fixed invented request fields (user_id in
set-visibility; library_type_id in update). make test 9/9.
2026-10-24 16:18:48 -04:00
John O'Keefe 6f13dc2b63 docs(media-items core): Beam WO5 batch 4 — 12 endpoints backfilled, registry pinned
Beam backfilled list/get/create/update/delete media-item, search, ratings
CRUD, progress GET/PUT. Beam correctly identified create/update/delete as
jwt+admin (admin group in the router — verified).

Claude registry: 12 entries. Notable pins:
- get_media_item: hand-built ~44-key map pinned verbatim from the handler
- create/update return the full database.MediaItems row (74 columns) —
  examples merged up to the real shape via gate-driven merge script
- search returns []SearchMediaItemsRow (sqlc row — Real type pinned)
- rating scale is 1-10 int (odd = half-star); GET returns {rating: null} when
  absent; update reuses the create handler (201)
- progress GET: 25 base keys + 4 CONDITIONAL position-authority keys
  (css_selector/anchor_href/char_offset/context_text — added by the handler
  for convertible formats with a valid CFI); PUT progress binds
  sync.SaveProgressRequest and returns database.ReadingProgress
- Beam's examples were missing entirely on 4 files (list/search/progress
  GET/PUT) — relabeled existing fences or inserted contract-shaped blocks

Gate-driven merge scripts (object + array variants) brought all examples to
the exact handler key sets. make test 9/9.
2026-10-24 16:09:38 -04:00
John O'Keefe ae9588f1c6 docs(auth,devices): Beam WO5 batch 3 — 15 endpoints backfilled, registry pinned
Beam backfilled auth core (login/register/refresh/logout/profile) and devices
core (list/update/delete/shelf CRUD/sidecar/register flow) with frontmatter,
provenance, and labeled examples. All claimed shapes verified against
handlers: AuthResponse {access_token, refresh_token omitempty, token_type,
expires_in, user}, update device {device_updated, device}, shelf wrappers,
DeviceAuthStatusResponse.

Claude registry verification: 15 entries pinned by type (LoginRequest,
RegisterRequest, AuthResponse, RefreshTokenRequest/Response, UserProfile,
DeviceListResponse, DeviceUpdateRequest, AddToShelfRequest, SidecarConfig,
DeviceRegistrationRequest/Response, DeviceAuthStatusRequest/Response).
Removed revoke_device.md — name-only duplicate of delete_device.md (same
DELETE /api/devices/:id route; was the last audit-orphan doc).

Gate catches this round (subtle Go serialization facts, now pinned in docs):
- uuid.UUID arrays never serialize as omitted: register/status response
  ALWAYS carries device_id (all-zeros while pending) despite omitempty
- update device request binds device_name, not name
- register response includes device_id alongside registration_id
- list devices response is a {devices, total} wrapper
2026-10-24 15:48:18 -04:00
John O'Keefe eacc33bfac docs(conflicts,queue): Beam WO5 batch — 13 endpoints backfilled, registry pinned
Beam added frontmatter + provenance + labeled examples to the 7 conflict and
6 queue docs, fixing real field drift on the way (resolve response keys,
bulk result shape, dismiss-all deleted count, queue stats counts).

Claude registry verification found TWO Beam catches of Claude's own draft:
- queue routes use :device_id / :item_id params (registry first draft said :id)
- resolve_conflict DOES take a body (ConflictResolutionRequest) — registry
  first draft said nil
Both fixed; gate then surfaced one real Beam defect: example-request used a
nonexistent "resolution" field in resolve + bulk-resolve (real contract:
winner=<source|manual> with required manual_data for manual; strategy=
most_recent|highest_progress|manual). Fixed in the docs.

Pinned by type: ConflictListResponse, ConflictDetailResponse,
ConflictResolveResponse, BulkResolveRequest/Response, QueueStatsResponse,
ConflictResolutionRequest. dismiss-all deleted is an int count.
2026-10-24 15:37:45 -04:00
John O'Keefe fa00d6d3cb docs(saved-filters,dashboard,system,series): Beam WO3 batch 5 — 15 endpoints + dedupe
Beam drafted 15 frontmatter'd docs (saved-filters CRUD, dashboard
sections/preferences/restore, system config/settings, series list + books);
Claude verified shapes against handlers and pinned the registry:
- saved-filters: SavedFilterResponse pinned by type (RFC3339 timestamps here,
  unlike most of the API); create/update share CreateSavedFilterRequest
  (resource_type oneof media-items|collections|devices)
- dashboard: preferences GET pin {hidden_collections, collection_order,
  items_per_section}; PUT returns full UserDashboardPreferences row; sections
  wrapper {sections}
- system: GET+PUT /api/system/config are admin-defined key/value maps — new
  AnyJSONObject sentinel (gate shape-checks without key comparison; request
  side too). UpdateSystemConfiguration returns {status, message} — verified,
  not guessed. GET /api/system/settings returns []SettingEntry.
- series: {series[{name, library_id, book_count, total_in_series,
  cover_paths, last_entry_at}], total, limit, offset}; books by
  ?name=&library_id= with family-lens 403

Dedupe: deleted dashboard.md, system/config.md, system/settings.md — each
covered the same endpoints as Beam's per-endpoint files (content merged by
the gate-verified shapes; old prose recoverable from git).

Beam clarifications confirmed: scanner routes are WO5 (docs exist);
progress/books-match are WO5.
2026-10-24 15:29:24 -04:00
John O'Keefe 7471ca99e8 docs(analytics,jobs): Work Order 3 batch 4 — device usage, popular books, job create/status
DeviceUsageResponse / PopularBooksResponse / services.JobResult pinned by
type; jobs create uses an inline anonymous request struct (RawMessage pin).
Two UNVERIFIED markers recorded (analytics date-range params, facet value
element shape).
2026-10-24 15:08:18 -04:00
John O'Keefe 4a6fb033cb docs(media-items): Work Order 3 batch 3b — management endpoints (8)
New frontmatter'd docs:
- download (ServeFile — raw bytes, family-lens enforced; corrects the old
  download doc's claim that no download route is registered: ServeFile IS
  routed, DownloadBook is the dead code)
- set_audio_speed (speed range 0.25-4.0, NaN/Inf rejected)
- rescan (abridged example over the full 70+-column row; write-after-success
  guarantee noted)
- purge-archived (destructive, admin), unarchive (204), merge (SHA-256
  precondition, per-user collisions keep active copy)
- facets (two modes: facet+values, field autocomplete counts)
- filter_count (family lens)

Registry: RawMessage pins for map/abridged responses; Schema{} for non-JSON
body routes (download = raw bytes; unarchive = 204). Audit now:
missing docs down to ~55 (from 112), 0 orphans.
2026-10-24 15:06:23 -04:00
John O'Keefe ed0ce5d077 docs(media-items): Work Order 3 batch 3a — reader-adjacent CRUD (11 endpoints)
New frontmatter'd docs with real shapes (sqlc models pinned directly):
- bookmarks: get/create/update/delete (sync-aware rows: dedup_key,
  last_modified_*, tombstones; delete is 204)
- review: upsert (empty string DELETES the review) / get (200 with
  {review:null} when absent) / delete
- get single note + highlight (full sqlc row shapes)
- annotation history: restore (POST, kind via query or body) and purge
  (DELETE, kind via required query; permanent)

Gate hardening forced by this batch:
- required-field walk now guards non-struct Request types (RawMessage pins)
- update_media_bookmark needed a full example block (no cross-reference
  shortcuts — the gate checks every doc standalone)
2026-10-24 15:03:36 -04:00
John O'Keefe 85cdaf7196 docs(libraries): Work Order 3 batch 2 — 10 missing endpoints + array-response gate support
New frontmatter'd docs with handler-compatible shapes:
- get_library_types.md (public; []LibraryTypes)
- list_libraries.md (admin; {data: ListLibrariesRow})
- get_library_folders.md (admin; []LibraryFolders)
- list/get stats/resolve/delete processing issues (admin; family-lens 403s
  documented)
- scan_library.md (202 + 409 coalescing with active_job_id)
- get/update scan-settings (ScanSettingsResponse)

Gate improvements forced by this batch:
- Array responses: registered slice -> doc example must be a JSON array; key
  set compared against the element type
- RawMessage pins may now be arrays
- update scan-settings uses a RawMessage pin: handler always sets Message but
  the struct's omitempty would reject correct docs via zero-value marshal

Registry: sqlc models pinned directly (LibraryTypes, LibraryFolders,
ListLibrariesRow — json tags verified in the generated code).
2026-10-24 14:57:45 -04:00
John O'Keefe c4b62af213 docs(devices): Work Order 3 batch 1 — 6 missing device endpoints + gate entries
New frontmatter'd docs with handler-compatible examples:
- devices/get_device.md (DeviceInfo shape)
- devices/regenerate_device_token.md (token + sync_urls)
- devices/get_device_mappings.md (wrapper + DeviceMappingSummary)
- devices/update_device_mapping.md (UpdateDeviceMappingRequest)
- devices/download_sidecar_config.md (sidecar config JSON file)
- devices/link_book.md (LinkBookRequest; one UNVERIFIED marker on required-tag)

Schema registry: 6 new entries; DeviceMappingSummary hoisted to package
scope. Gate passes (the UNVERIFIED marker is logged, not a failure).

Excludes /api/devices-page (htmx page route, per ground rules).
2026-10-24 14:46:22 -04:00
John O'Keefe 50282895f5 fix(docs): Work Order 2 — correct stale paths (11 docs + 3 Bruno)
Fix all wrong-path artifacts identified by the audit:
- Kobo sync endpoints (init/bookmark/markup/sync-from-server/analytics): add
  missing :token device segment (6 doc files incl. kobo-protocol.md)
- Highlights/notes update+delete: add missing /:id media-item prefix
- media-items/download_media_item.md: removed (static /uploads route, not an
  API endpoint; info about the file route is recoverable from OPDS docs)
- Bruno: /libraries/visible -> /visibility, /collections/books missing id,
  /audio/0 -> track_index
- operations/troubleshooting.md: same /libraries/visible -> /visibility fix
- media-items/get_audio_track.md + get_audio_chapters.md: /audio/0 examples fixed
- Deleted monolithic docs/developer/api-reference.md (duplicate of split docs)
- Fixed 4 stale entries in docs/developer/api/api-reference.md (split index)

Audit re-run: orphan docs 0, orphan Bruno 6 (all scenario files with literal IDs).
2026-10-24 14:44:19 -04:00
John O'Keefe cc893f758e feat(docs): drift gate — docs frontmatter validated against real routes
Every API doc that declares frontmatter (method/path/auth/area) is now
checked by a unit test against three sources of truth:

1. Route registration: internal/router/*.go is parsed the way echo resolves
   it (group prefixes concatenate; scanner.go's parameterized groups seeded).
   Doc paths that no longer exist fail. (This is the check that would have
   caught the /api/api/admin/hash-conflicts split.)
2. Auth: frontmatter auth label must match the middleware chain
   (none|jwt|jwt+admin|device-token|optional-jwt).
3. Shapes: internal/docs/schema.go registry — example-request decoded with
   unknown fields DISALLOWED (typo guard), required-by-validate fields must
   appear (staleness guard), example-response top-level key set compared
   both directions against the marshaled handler type (or a RawMessage pin
   for hand-built map responses).

Five collections docs are the pilots with real handler shapes; notable
corrections they pin: update returns 201 (not 200), list excludes smart
collections unless include_auto=true, rule priority is 1-10 higher-wins
(docs claimed 1=highest), created_at is Go time.String() not RFC3339,
hide_finished/show_up_next toggles documented. CollectionResponse hoisted
out of GetCollections/GetBookCollections as CollectionSummary /
BookCollectionSummary so the registry can pin real types.

The gate fails on missing registry entries by design — that is the
maintenance contract. Known pre-existing (not this commit): vet copy-lock
warnings in media.go.
2026-10-24 14:41:02 -04:00
John O'Keefe 994e0f86c4 chore(web): drop unreferenced highlight-dark.min.css
Docs highlighting is server-side chroma since the docs chrome rewrite; the
hljs stylesheet had no remaining referents.
2026-10-24 14:33:37 -04:00
John O'Keefe 11b179b91f fix(admin): hash-conflict routes served at sane path — resolve buttons were dead
The admin group already sits under the /api prefix, so registering
"/api/admin/hash-conflicts" produced /api/api/admin/hash-conflicts while the
hash-conflicts page's htmx buttons posted to /api/admin/hash-conflicts/:id/
resolve — a 404 that htmx swallows silently (no swap, no error), so duplicate-
content resolution has been broken since the feature shipped (03cb4c78).

Verified live: sane path 401 anon -> 200 with admin token; doubled path 404.
Caller audit found no other consumers (Android app, koplugin, web reader,
tests, Bruno: no references). Docs already describe the sane path and are now
correct without edits.
2026-10-24 14:33:37 -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 408a972665 fix(audio-player): playback speed silently reverted to 1x on every reload
restorePosition() swaps the <audio> element's src (manifest track for
multi-file books, whole file for single-file, plain URL on offline
start), and loading new media resets playbackRate to the element
default. init() had applied the persisted speed before any src
existed, so after the swap every load actually played at 1x while the
Speed pill still showed the stored value — pick 1.5x, reload, and
audio was back at 1x.

The seekTo() src-swap path already re-applied the rate; the initial
load paths now do the same (manifest branch + offline-catch branch).
Surfaced by the phase-1 speed-list browser check: button label and
audio.playbackRate disagreed after reload.
2026-10-21 21:08:06 -04:00
John O'Keefe 6508abc00f docs(agents): the doctrine — tests never run against a real server
Codify the standing rule at the top of the Tests section: the dev DB is
the test environment, and the invariant every test must uphold is the
testuser experience — its settings, scans, and reading data must be
exactly as the suite found them. The helpers exist to enforce this;
when a test needs protection the helpers don't provide, expand the
helpers rather than hand-rolling.

New known-gotchas from the 2026-10-21 incident cluster:
7. scanner re-extraction is write-after-success — audio_metadata is
   guarded on both writers; new scan-owned column writes need the same
   guard.
8. templ double-escapes style attribute values (sanitizer + EscapeString)
   — emit quote-free CSS via unquoted url() tokens through SafeCSS.
9. vp test runs the vite config — build-hook plugins fire in the test
   context too; outDir-mutating plugins need an "am I a build?" guard.
2026-10-21 20:25:26 -04:00
John O'Keefe 3ed852515b test(helpers): setSystemSettingRestored — leave shared settings as found
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.

setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
2026-10-21 20:24:44 -04:00
John O'Keefe f62b46fb15 test(integration): route regression tests for the audio_metadata guards
Three tests in scanner_audio_guard_test.go pin the routes the wipes
traveled; each drives a real scan/rescan/edit over ffmpeg-rendered
fixtures and asserts the payload survives with duration and manifest
intact:

- TestRescanMediaItemPreservesAudioMetadata: folder book through the
  focused payload writer.
- TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident
  class verbatim — single-file m4b (no layout key) routes through the
  generic updater; fixture rendered with ffmpeg AAC/ipod
  (writeTestTrackM4B).
- TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's
  full-row save through PUT /api/media-items/:id.

Verified to bite: with the handler fix stashed, the editor test fails
with "metadata edit must never NULL audio_metadata"; with it, green.
2026-10-21 20:24:09 -04:00
John O'Keefe 35d8ba32e5 fix(media): metadata editor save no longer wipes audio_metadata
PUT /api/media-items/:id (the metadata editor) writes the FULL row via
UpdateMediaItem, whose query sets audio_metadata unconditionally — and
audio_metadata has no editor field, so the params literal left it nil
and every save NULLed the payload. A plain metadata edit (genre tweak,
title rename) dropped any audiobook back to "1 second long" with no
track manifest: the same incident class as the scanner wipe, sitting
on the most-used admin path in the app.

Carry existing.AudioMetadata through the write. Write-after-success:
only the audio chain (probe-backed extraction /
UpdateMediaItemAudioBookPayload) ever replaces that column.
2026-10-21 20:23:42 -04:00
John O'Keefe 80bfc1a77b fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).

Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
  extraction carries no audio payload, keep existing.AudioMetadata.
  Single-file m4b books route here, and a failed/absent extraction
  leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
  tracks, no chapters, no duration) over a populated row is a failed
  extraction — refuse the write with a loud warning and let the next
  scan retry.

audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
2026-10-21 20:22:57 -04:00
John O'Keefe 970beee4b8 fix(pwa): add the manifest icons the manifest always referenced
manifest.json has referenced /static/icons/icon-{192,512}.png since
April, but the directory was never committed — ~100 404s per browsing
session and a broken PWA install. Rendered both sizes from the
existing favicon book glyph on the Tokyo Night palette
(#1a1b26 background, #7aa2f7 stroke).
2026-10-21 20:22:28 -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 6791d611f0 fix(vite): guard cleanStaleChunks — vp test deleted every built chunk
The cleanStaleChunks plugin lives in the shared vite config, so its
closeBundle hook also fired under `vp test` — where generateBundle never
runs, produced stayed empty, and the sweep deleted every hashed chunk
in web/static (28 files → 3). The entries still referenced their old
imports, so every page 404'd and Alpine never started: the whole reader
and media system appeared dead after merely running the test suite.

Guard: skip the sweep when produced.size === 0 — not a build run, no
build output to clean. Verified: build → 28 files, test → 28 files;
back-to-back builds stay byte-identical.
2026-10-21 20:20:41 -04:00
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00
John O'Keefe 86c4940426 style(toast): warning toast uses the /90 opacity variant
Release / build-and-push (push) Successful in 3m6s
Matches the error/success/info entries visually. bg-yellow-500/90 is
generated by the Tailwind scanner from this file itself (content globs
include web/src/**/*.ts), same as the sibling bg-*-500/90 classes — no
safelist entry needed.
2026-10-21 14:46:26 -04:00
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00
John O'Keefe 77cd268f4e style: format web/src with oxfmt defaults (via vp fmt)
One-time mechanical sweep, isolated from any functional change.
- oxfmt defaults: double quotes, semicolons, 2-space indent, LF,
  printWidth 100, trailing commas, bracket spacing, arrow parens.
- vite.config.ts gains an fmt.ignorePatterns block for web/static:
  vendored/minified assets (htmx.min.js, compiled css) must never
  be reformatted.
- Verified: rebuild after the sweep produces byte-identical JS/CSS
  bundles; only sourcemaps changed (they embed sourcesContent).
2026-10-21 13:18:24 -04:00
John O'Keefe 8aeecaa0ef Experiment: migrate web build to vite-plus 1.3.0
- vp migrate rewrote scripts (vite build -> vp build), aliased vite to
  @voidzero-dev/vite-plus-core 1.3.0 with an overrides pin, and switched
  the config import to vite-plus.
- Manual one-line fix migrate missed: minify "esbuild" -> true (Vite 8
  dropped bundled esbuild; oxc minifier is the replacement).
- Widened cleanStaleChunks sweep from .js/.js.map to .css/.mjs too:
  Rolldown renames emitted css chunks, and two stale vite-7-era css
  files (annotation_layer_builder, text_layer_builder) survived the
  old js-only regex.
- Verified: both custom plugins survive; prod/dev/watch builds OK;
  two consecutive builds idempotent at 289 files; vendor/pdfjs assets
  byte-identical; entry names (main.js/reader.js) unchanged.
2026-10-21 09:27:59 -04:00
John O'Keefe dd4cf1235c test: pin scan_extractor_version at the start of ev-sensitive tests
Release / build-and-push (push) Successful in 3m20s
The extractor/force-rescan tests assert manifest stamps against the
default version (1), but the setting lives in the SHARED dev DB — any
prior run (or a manual admin-UI bump) can leave it at 2, failing every
stamp assertion through no fault of the code. Each ev-sensitive test
now pins the setting to 1 at start and restores 1 on cleanup; proven
against a deliberately bumped setting (tests pass, row restored).
2026-10-20 22:19:29 -04:00
John O'Keefe a42c6d4f75 feat(scanner): force rescan = original scan; feat(filters): the manually-edited filter (faceted plan Phase 13)
Release / build-and-push (push) Successful in 3m7s
Force rescan contract change (owner-directed): the editorial-keep skip
is retired. Force rescan now re-derives EVERY field on EVERY book from
all sources — multi-file books included (the folder/zip flows ran the
full chain on create and on manifest-flagged edits, but skipped the
sidecar apply under force). metadata_overrides is the only protection:
ApplyMetadataOverrides re-applies manual values inside the writer, so
manual edits survive; everything else re-derives.

- A force pass also STAMPS each re-derived book's manifest with the
  current parser version (success-only — same truthfulness rule as
  Phase 10) and closes its open processing issues (Phase 11 self-heal).
  Force rescan and the ev-bump trickle now converge: either leaves the
  library fully current; the deploy order is bump-first, then Rescan
  All (documented in the plan and contract_pins).
- Single-file force rescan already ran the full chain — unchanged,
  now pinned.

NEW filter field manually_edited ("Manually edited", boolean):
cardinality(mi.metadata_overrides) > 0 / = 0. Composed-engine (qf)
only — the builder's Book facts group gains it with is true/false;
no value menu, no suggestion surface; family lens inherited.

Tests: multifile force applies sidecar metadata + stamps + override
protection; single-file pin; the filter both directions scoped to the
test library; adjacent suites (Audiobook, Calibre, ExtractorVersion,
CrossLibraryMove, ProcessingIssues, FilterBuilder, SchemaIdempotency)
green. The extractor mechanism itself is untouched — the owner bumps
scan_extractor_version manually via the admin UI (plan Phase 13 §3).
2026-10-20 22:05:42 -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 19b9c7c9b8 fix(tests): wire settings registry into the test auth handler — unstick TestAdminCreateUser
setupTestServer built the settings registry (and wired it into
systemSettingsHandler) but never called authHandler.SetSettings, unlike
main.go:112. The auth handler therefore ran with a nil registry and
defaultTimezone() took its nil-fallback branch, returning "UTC" for every
Register/AdminCreateUser in tests regardless of the system default_timezone
setting — so the 740213f0 inheritance pin in TestAdminCreateUser could never
pass and failed every full-suite run.

Also replace the TestAdminCreateUser "stable-ish" uniquifier (MaxConns is
pinned to 1 by the harness, so it produced the same user every run and
depended on the next run's cleanup sweep) with a real uuid suffix.
2026-10-20 19:02:26 -04:00
John O'Keefe 91b0c45dd4 feat(scanner): extractor-version (ev) mechanism — parser fixes reach old rows without a force rescan (faceted plan Phase 10)
Parser fixes (7e4c97f6, genre model v2) never reached rows ingested
under an older parser: the sidecar never changed, so every fingerprint
said 'unchanged'. The manifest JSONB now carries the reserved 'ev'
stamp — the parser version that produced the item's row.

Semantics (all pinned by tests):
- ABSENT ev decodes as 1 ('v1-or-unknown'); stamping is truthful-only:
  create paths + post-extraction stores stamp the setting's current
  value; deploy-day backfill and the extract-failure guard stay UNSET
  (a row never claims a version its data can't prove — a mid-backfill
  bump self-heals next poll); repoint reconciliation preserves the
  stored ev (stat-only refresh re-extracted nothing).
- NEW setting scan_extractor_version (seeded + registry + getter,
  int default 1, category scanner): a manifest whose ev != the setting
  re-extracts through the SAME change path (full extraction →
  updateMediaItemWithMetadata → fresh stamped manifest). Sidecar-less
  books get an entries-less manifest as their ev home (create: stamped;
  first-sighting: unset).
- Catch-up bounded: evReextractBudgetPerScan=100, atomic (watcher pass
  can overlap a poll scan), reset per ScanFolders — amortized +
  resumable across polls; no force rescan, no flood.
- Multifile books included: the ev gate rides multifileSidecarChanged's
  shared ladder; ev-driven manifestChanged drives the editorial apply
  + stamped re-store.

Settings API: the unified GET/PUT /api/system/settings now lists the
key (registry-driven, auto-surfaces in the admin UI). Test harness:
the registry is now Loaded + wired into the SystemSettingsHandler
(main.go parity) — settings writes through the API were 503s before.

Verified: unit (ev normalization, budget CAS, GetExtractorVersion
cache paths, registry clamping); integration state machine —
create-stamp ev=1 → equality skips (corruption probe persists) →
wiped scan-state backfills UNSET without re-extract → bump re-extracts
ONCE (sidecar value restored, ev=2) → stable (second corruption
persists); multifile twin green; adjacent suites (Audiobook, Calibre,
CrossLibraryMove, SchemaIdempotency) green; live app restart re-scans
1198 files with 0 changed (deploy-day rule), setting listed at
/api/system/settings.
2026-10-20 18:24:08 -04:00
John O'Keefe 43987262a6 fix(filters): operator table sent labels as wire values — every non-self-named operator was broken (user report)
The client's operator map passed the display string as BOTH the map
key and the OpMeta.key, so the menu clicks transmitted 'doesn't
contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server
rightly rejected them (unknown operator → 400), the count fetch's
catch hid the counter, and flipping away from such an operator never
came back. Only self-named ops (contains, is, has, between…) worked.

- Map keys now DERIVE from meta.key (Object.fromEntries over the op
  list) — wire value and label structurally cannot diverge again.
- New scripts/filter_builder_harness.js: a playwright harness that
  drives the real drawer in headless Chromium and sweeps every author
  operator, capturing filter-count responses and console errors — the
  first UI-level regression net for the builder (the server tests
  could never see a client-side mis-key).
- Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 →
  is empty 2 → refill 8; zero console errors across the sweep.
2026-10-20 17:14:26 -04:00
John O'Keefe 68701d6b94 fix(filters): live count follows the top library picker (user report)
The count fetch reads the library select at fetch time, but nothing
re-triggered it on a scope change — the number stayed stale (8 across
all books) after switching to a library where the rules match nothing.
The picker's change event now reschedules the count, same as rule
edits do.
2026-10-20 16:52:08 -04:00
John O'Keefe ab713e2feb fix(filters): blank-typed browse 500 on id-valued fields (library/collection/added_by)
The browse-on-click blank sentinel was trimmed to empty (correct), but
the typed query had already been bound unconditionally — the id-source
suggestion query has no score CASE to reference it, leaving an unused
parameter Postgres rejects (42P18). Every click of an empty library /
collection / added-by box answered 'No matching values' via the error
path.

- ComposedFieldValues binds the typed query ONLY in branches that
  reference it and only when non-blank; the score expression is
  '0 AS score' for browse (no param) and the similarity CASE when
  typing. composedIDSourceValues takes the raw typed string and binds
  it only when the name filter is built. userBind already followed
  this discipline; audited every bind site in the file (fuzzy helpers,
  composed search, count) — all interpolate immediately.
- Tests: library browse under rules (exactly the surviving library,
  count pinned), series browse blank, added_by browse blank (no
  error), collection browse blank (offered with count) and under a
  zero-match rule (never offered). Verified end-to-end against the
  live dev data: author+Sapkowski AND narrator+Kenny → Audiobooks (8).
2026-10-20 16:44:50 -04:00
John O'Keefe 9c006a9a2f fix(filters): browse with no rules works, popup under the input, no sticky headers (user report)
- Blank typed queries rode the STATIC autocomplete path for the classic
  six fields, where a single space similarity-matches into nothing —
  clicking an empty value box with no other filters said 'No matching
  values' in a library full of values. Blank = browse now always rides
  the composed engine (which trims and answers the top values); static
  keeps its pinned contract for real typed queries. Regression-pinned:
  browse with no rules lists the field's values.
- The value popup now drops BELOW the input (top: 100%), not over it.
- Field-menu group headers lost position:sticky — the search box alone
  stays pinned.
2026-10-20 16:01:10 -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 6152137156 fix(tests): repair stale pins surfaced by the Phase 6 full-suite run
Two suite failures, neither caused by scanner code (the Phase 6 diff
touches no handler):

- search_test invalid_library_id expected the pre-family-lens 404;
  7e586df4 shipped the AGENTS-mandated 403 for every out-of-set id
  (existence must not be enumerable) and docs/contract_pins.md:195
  documented it — this one subtest's expectation was missed. Pin
  widened to the shipped 403.
- TestWebSocketPingPong deleted: it asserted a text-level ping→pong
  exchange no server code has ever implemented and no client has ever
  sent; it passed vacuously through its read-timeout path until
  interleaved scan_progress broadcasts from the live server's scans
  reached the socket first. Keepalive is the standard protocol-level
  ping the hub already sends. Never-built behavior is not a pinnable
  contract.

Both recorded in docs/contract_pins.md. Full container suite is green
except the documented pre-existing TestAdminCreateUser red.
2026-10-19 22:33:08 -04:00
John O'Keefe 2c5ad014c0 feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6)
Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.

- Change fingerprint gains the manifest compare in both processors:
  changed := size differs || track payload differs || manifest drifted.
  The ladder runs against the bulk-loaded map entry (stat pairs only,
  zero extra queries); NULL stored manifest backfills without
  re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
  editorial columns (updateMediaItemWithMetadata, overrides protected)
  and re-runs the playlist hook. The apply runs BEFORE the focused
  payload write: the generic update's format-group pass derives mime
  from the sentinel (none) and chapters from metadata.ChapterCount
  (unset) — the payload write must win (rescanMultiFileAudioBook's
  ordering). Track-only changes keep the existing editorial-keep
  update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
  sentinel — the exact representative the extractor reads — via
  detectSidecarChangeWithRepresentative, so manifest and extractor can
  never disagree; a directory-basename cover is ignored by the
  extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
  folder path always did (a returned zip book stayed archived
  forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
  non-nil empty &MediaMetadata{} fallback bypassed the update path's
  filename-title guard (preExtracted == nil only) and would blank the
  title on a failed extraction; the row is now left untouched and the
  manifest refreshed. Empty-success extraction (sidecar removed from a
  tag-less book) now passes nil so the create-equivalent chain (tags →
  filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
  they were previously uncounted.

Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
2026-10-19 22:33:01 -04:00
John O'Keefe 5245f292ca feat(scanner): quiet scans, enqueue coalescing, watcher probe, sibling cache
Part 2 of the faceted plan, Phase 5 (steps 5-6): the scan that used to
print ~4 lines per unchanged file now prints ONE summary line, and a
poll tick landing mid-scan no longer stacks a second full scan.

- Quiet scans (§4): the walk loop is silent — inserts, changes, and
  errors log inside processMediaFile; the duplicate
  'Successfully processed' branch (both branches printed it) and the
  per-file 'processing / already exists / same size, skipping' chatter
  are gone. Summary carries the trigger and a changed counter:
  'Scan of <folders> (trigger: poll): N files, N changed, N new, Xs,
  N errors'. Triggers threaded through every enqueue site: poll
  (startBackupScan), watcher (dirty-dirs flush), admin (scan API),
  initial (startup scan).
- Enqueue coalescing (§5): Worker.HasActiveScanForLibrary (JobResult
  gains additive job_type so only SCAN jobs coalesce — an import
  sharing the library id never blocks one). enqueueLibraryScan skips
  silently; the admin scan API answers 409 with the active job id.
  Enforces the one-scan-per-library invariant the bulk scan-state map
  leans on.
- Watcher capability probe (§4): ONE Add on a root folder; failure
  disables the watcher with a single startup line (replaces the
  per-directory warning spam on inotify-broken deployments). The
  watcher stays an optional latency reducer over scheduled scans.
- Per-directory sibling cache (§6): hasSiblingBookFileCached caches
  the ReadDir per directory for one scan — loose-image manga re-stats
  thousands of images sharing a handful of directories.

Tests: TestHasActiveScanForLibrary (unit — running/pending coalesce,
terminal jobs don't, non-scan jobs don't, empty library never, nil
worker safe). Full scanner suite green in the container.
2026-10-19 20:35:07 -04:00
John O'Keefe 6750e9e0dc feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.

- Sidecar manifest fingerprints (media_scanner_manifest.go): the
  manifest stats exactly the names the extractors consume (metadata.opf,
  metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
  manifest and extractors can never disagree. Shape v1:
  {v, entries:[{n, s, m (unix nanos — second granularity would hide
  edits landing in the backfill scan's own second), h (sha256 — covers
  included: the repoint ladder needs a baseline hash for any stat-
  drifted sidecar; a hashless cover turns harmless mtime drift into a
  re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
  ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
  reconciliation, stat pair refreshed, no re-extraction; differing =
  changed); NULL stored manifest → backfill only, NO re-extraction
  (deploy-day rule: the first scan after release must not look like an
  mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
  through updateMediaItemWithMetadata (preExtracted — metadata_overrides
  stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
  a reading-order entry added to an EXISTING book now creates its
  collection; add-if-missing is idempotent), store a fresh full
  manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
  change path does not consume sidecar extractors, so gating them would
  flip books 'changed' without picking anything up (plan correction —
  the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.

Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
2026-10-19 20:17:57 -04:00
John O'Keefe 693053a9f5 perf(scanner): bulk scan-state load — one query per library scan
Part 2 of the faceted plan, Phase 3 (steps 1-2). The periodic scan
issued one GetMediaItemByFilePath per scannable file (~3,094 queries
per scan in prod, nearly all answering 'unchanged, skip') plus one
GetLibraryByFolder per file for folder resolution.

- NEW media_scan_state table: the scanner's per-item fingerprint
  cache. Deliberately NOT a media_items column — the library-scoped
  list emits raw sqlc rows as JSON (7537063c), so scanner bookkeeping
  must never leak into API payloads nor churn the model or pins.
  Manifest JSONB shape v1 documented in the schema (n/s/m/h; ev
  reserved for the Phase 4+ extractor-version mechanism).
- GetScanStateByLibrary: lean bulk projection (id, file_path,
  file_size, archived_at, missing_scan_count, file_sha256, manifest).
  archived/hash columns ride along so the skip path's archive-restore
  and hash self-heal checks stay DB-free too (refines the plan's
  4-column sketch; same goal, narrower invariant).
- ScanFolders: per-run reset, per-folder library cache, one bulk load
  per distinct library. processMediaFile: map hit answers existence +
  size + flags with zero DB reads; force-rescan and size-change
  branches re-read the full row (the change path may be expensive);
  write-through on create/size-change keeps later passes honest.
- Multifile folder/zip books keep their full-row lookup: it is one
  query per BOOK ROOT (not per track) and needs audio_metadata for
  the duration-probe check. They join the map in Phase 4, when the
  manifest compare replaces their fingerprint check.
- nil map (watcher's scanDirectory path, bulk-load failure) falls
  back to per-file lookups — behavior-preserving; the failure case
  logged once per scan.

Test: TestScanStateBulkLoad (unchanged rescan leaves updated_at
stable; API-created rows are seen by the bulk load; size changes
still update in place). Container runner.
2026-10-19 19:52:43 -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 7e586df48a feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.

- Nine new filter params on SearchMediaItemsUnified: story_arc,
  imprint, edition (scalar-fuzzy), age_rating, format_group,
  manga_type, reading_direction (exact), explicit, abridged
  (presence-bound bools). Comma = OR-within multi-select on every
  text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
  genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
  pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
  > empty (unknown-only stays tags-only). Type-words (Manga, Comics,
  Graphic Novel) join the demote list. Both 'adventure' and 'action &
  adventure' are shelves — provider data uses both and the verified
  traces pin them.
- Language filter UX: comma elements normalize through the ingest
  language map, so display names ('English') match rows stored as ISO
  codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
  driven value+count listing (14 static per-column queries, family-lens
  join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
  403 on the search endpoints (was a silent empty set) — JSON branch
  and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
  search JSON endpoint and the HTMX grid partial; the HTML branch
  previously dropped publisher_filter and narrator_filter silently.

Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
2026-10-19 18:59:23 -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 1dbb0ce406 feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7).

Auto-playlist collections (§2.4): the scanner turns obviously-playlist
sidecar series (explicit '(Reading Order)'/chronological/publication/
in-order/timeline markers only — precision over recall) into curated
static collections: upsert by view_settings.imported_from_series then
name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a
stay unpositioned rather than guessing), membership add-if-missing,
never removed or re-ordered by rescans, provenance in description +
view_settings, show_on_dashboard true (dashboard-eligible by design),
'scanner.auto_playlist_collections' setting (default on, Settings
Registry + schema seed).

Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST
then added_at ASC (was added_at DESC — walked playlists backwards);
PUT /api/collections/:id/items/order applies full-order rewrites
(owner-only, service-gated).

hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard
gains viewer_user_id + hide_finished (LEFT JOIN reading_progress,
COALESCE percentage < 0.999) and viewer_percentage rides the row for
card graying; GetCollectionUpNext serves the collection's reading-queue
head with the family-lens visible-library array enforced.

Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT —
the show_on_dashboard gate (never writable for user rows since the
column shipped) is gone from GetUserCollectionsForDashboard; hiding
stays dashboard-prefs (hidden_collections) territory; hide_finished
filtered-everything now emits a Complete section instead of silently
vanishing; sections JSON carries is_playlist/complete.

Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by
title ASC — NULL/tied numbers previously returned heap (scan) order,
which is what the user saw.

Frontend custom-section-builder note: the builder has sent
show_on_dashboard: true since its creation; the API never bound it.
User-row visibility is now intentionally prefs-governed, so the field
stays unbound by design (documented in code).

Pins widened (docs/contract_pins.md): collection detail + up_next;
dashboard sections + is_playlist/complete; new reorder endpoint
recorded.

Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar
series split, collection auto-creation with position 7, rescan
idempotency, owner-only reorder, finished-filter + completion marker +
Up Next drain) plus the full affected integration sweep green.
2026-10-19 15:25:40 -04:00
John O'Keefe 7e4c97f639 feat(scanner): fix ABS sidecar series parsing; three-way series mapping; smart genre; language codes; title-derived series
Phase 2a of the metadata overhaul (plan §2.1-2.3, §2.5-2.9).

The headline bug: ABS writes sidecar series as an array of STRINGS
(["The Witcher #3", ...]); the parser only accepted {series, sequence}
OBJECT arrays, so every ABS-matched book silently lost its series (found
live: 9 sidecars, zero series ingested; only folder-structure Malazan
survived). parseSeriesCandidates now normalizes string arrays, object
arrays and Libation plain strings.

Three-way series assignment (plan §2.2):
- primary bibliographic series -> series/series_number columns
- additional real series (Drizzt: Icewind Dale #1 = Legend of Drizzt #4)
  -> alternate_info JSONB, comic-established shape
- ordering constructs ('The Witcher (Reading Order)') -> PlaylistSeries
  candidates; NEVER the series column. Detection is explicit-marker-only
  ((reading order|chronological|publication order|in order|timeline) as
  parenthetical/suffix) — precision over recall; a series merely
  containing 'timeline' mid-name stays a series.

Sequence policy: leading-number extraction ('Book 2' -> 2, '2,
Dramatized Adaptation' -> 2, '1a' -> 1); lossy extractions preserve the
raw text in alternate_info.series_sequence_raw.

Smart genre (deterministic lexicon): compound Audible blobs split
('Epic, Action & Adventure, Military, Sword & Sorcery' -> 4 candidates),
alias-normalized, format noise demoted ('Audiobook' never wins),
most-specific canonical genre takes the genre column, all candidates
stay in tags.

Language: display names normalize to ISO 639-1 on ingest; ROOT-CAUSED
the update-path drop — CreateMediaItem call site never set Language
(explicit NULL over the column default), the update path did; create
now carries it.

Title-derived series fallback (§2.7): 'Baptism of Fire: The Witcher,
Book 3' 2-level folders split conservatively (only when the post-colon
tail matches ', Book N'), subtitle parsed for series+number, blanks-only
precedence, matching-name number fill, zero display mutation. Works
with no sidecar and no network.

Ingest fixes: subtitle/explicit land in their columns (audio tag
subtitle stops joining into title); full authors[] array (was [0]-
only); sidecar descriptions sanitized via bluemonday UGC policy
(vendored).

metadata_overrides: verified scan-update path already applies
ApplyMetadataOverrides; subtitle/explicit/authors covered by the Phase 1
override adaptation.

Tests: new series_parsing_test.go (string-array sidecar e2e, Drizzt
alternate_info, lossy raw preservation, playlist classification,
title-derived fallback incl. no-mutation + blanks-only, genre lexicon,
language map); audio-tag subtitle pin updated to column behavior;
calibre/ABS 2.5-series_index pins widened per float change (changelog'd
in phase 1).

Deferred to 2b: auto-created playlist collections (§2.4) — settings
toggle, collection upsert, ownership.
2026-10-19 14:34:42 -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 f7dc8189a1 feat(css): cross-document view transitions
Release / build-and-push (push) Successful in 3m15s
@view-transition { navigation: auto; } lets Chrome 126+ / Safari 18+
crossfade same-origin navigations — sidebar links and the series
page's library-switch navigation get a subtle fade with zero
JavaScript, softening the switch to full SSR navigation. Pure
progressive enhancement: Firefox and older browsers simply navigate
instantly. style.css regenerated via build:css:prod.
2026-10-19 12:40:44 -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
John O'Keefe 18f52d240b docs(agents): record the dashboard sections rendering exception
renderDashboardCollections/renderSectionHTML re-render dashboard
sections from /api/dashboard/sections JSON on library switch, settings
save, and refresh — deliberately kept in TypeScript to preserve the
switch fade on the app's most interactive page. First-paint section
markup remains templ (Dashboard handler in internal/router/frontend.go).

Recorded in the Rendering ownership section as a sanctioned exception
so it is not silently 'fixed' without restoring a templ-partial fade
path, and so the pattern is not cloned to new pages. (User-directed:
the dashboard keeps its fade; every other page stays templ-rendered.)
2026-10-19 12:40:02 -04:00
John O'Keefe 220d97cfe0 test(series): pin the SSR pagination contract under tied timestamps
New integration test mirroring a prod bulk import: a dedicated test
library with 30 series whose items share ONE identical created_at
(single-transaction NOW()), the exact data shape that made prod
pagination look broken.

Pins, via the real SSR /series route:
- page 1 renders exactly 24 deterministic series (01-24) with chip
  "Page 1 of 2", no Previous, and a Next href carrying library_id and
  page (templ escapes & as &amp; inside attributes)
- page 2 renders the remaining 6, disjoint from page 1, chip
  "Page 2 of 2", no Next, Previous href correct
- missing page param defaults to page 1; page=0/-3/two clamp to page 1

Guards both the SQL tiebreaker and the /series handler contract;
would fail on any return of the stomp or an unstable sort.
2026-10-19 12:39:47 -04:00
John O'Keefe 38efbff7e1 fix(db): total ordering for series OFFSET pagination
GetDistinctSeries ordered by MAX(created_at) DESC alone. Bulk imports
inserted in a single transaction share one NOW() timestamp, leaving
large numbers of (library_id, series) groups tied on MAX(created_at);
Postgres does not guarantee tie order between executions, so
LIMIT/OFFSET pages could repeat or drop series across page loads.

Add the GROUP BY keys (library_id, series) as tiebreakers so the sort
is total and deterministic. Comment documents why the tiebreaker is
required so it is not dropped as noise. sqlc regenerated (interface
unchanged; the comment flows into querier.go docs).
2026-10-19 12:39:32 -04:00
John O'Keefe f4414aa5b6 style(series): plain-text page indicator instead of the chip pill
"Page N of M" now renders as small secondary-color text on the page
background rather than an accent chip (user preference; the old TS
twin happened to render this way and the chip was only being seen
after JS took over). With the twin deleted, this templ markup is the
only paginator rendering, so the look is stable.
2026-10-19 12:39:20 -04:00
John O'Keefe 3daa834a94 refactor(series): drop the TS rendering twin; library switch navigates SSR
web/src/series.ts carried a full TypeScript twin of the templ page —
cards and paginator rendered via innerHTML from /api/series JSON,
always hardcoded to page 1. Combined with the header restore dispatch
(previous commit) it made every /series load stomp server-rendered
pagination back to page 1, which is the paginator bug reported on prod
(9 pages of series, Next kept landing on page 1, per-library only).

Rendering ownership (AGENTS.md): this page now has NO client renderer.
- grid and paginator are rendered only by templates/series.templ
- a library switch is a plain SSR navigation to
  /series?library_id=...&page=1, keeping the URL the single source of
  truth for both library and page state
- drops the duplicate /api/series fetch that ran on every page load

rendering-allowlist.txt: series.ts baseline entry removed — the file
no longer builds HTML anywhere.
2026-10-19 12:39:09 -04:00
John O'Keefe 202a090811 fix(ui): library restore no longer re-fires change on synced dropdowns
restoreLibrarySelection() ran on every page load via the header's
x-init and always dispatched a synthetic `change` on #library-select
after setting its value. On SSR pages the dropdown already renders the
stored library (setSelectedLibrary keeps the cookie in sync and
resolveLibrary renders from it), so the synthetic event re-ran the
page's switch handler, whose fetches are hardcoded to page 1 /
offset 0 — stomping freshly rendered content.

Visible impact: the /series paginator snapped back to page 1 on every
navigation whenever a specific library was stored (All Libraries
short-circuits restore, which is why it only failed per-library).
Silent impact: dashboard and collection-detail pages re-fetched their
content on every single load.

The restore now re-fires `change` only when the dropdown genuinely
drifted from storage; the common synced case is a visual no-op.
2026-10-19 12:38:50 -04:00
John O'Keefe ee98c0fb3b docs(dev): test-running policy + media-fixture rules; ignore testdata
Release / build-and-push (push) Successful in 3m29s
Integration tests require the containerized dev DB and bind-mounted
paths — a bare host `go test ./cmd/server/tests` fails on database
auth on every machine not running the compose stack (the old doc
prescribed exactly that flow). development.md now documents the
container runner and the iteration rule: run ONLY your own tests via
a filtered container invocation while working, and reserve full-suite
runs for pre-commit validation — several tests read live shared state
(system_settings, real library folders, the periodic backup scan's
websocket broadcasts) and flap on data or timing a change never
touched.

Binary media fixtures are never committed: internal/services/
testdata/ is gitignored by policy (the audio metadata tests read
real-format binaries that ffmpeg cannot synthesize — Audible freeform
atoms — so they live untracked on the machines that run the suite).
The new "Media fixtures" section carries the ffmpeg recipes for every
supported container, tag writing, embedded covers and ffmetadata
chapters, plus the manual end-to-end recipe for the live system
(one book per subfolder — a directory with ≥2 audio files ingests as
one folder book).
2026-10-19 07:54:55 -04:00
John O'Keefe 79ce5ab89d feat(web): multi-track audiobook player — per-track src switching, global-seconds position
The audio reader pointed <audio src> at ONE file — correct for m4b but
impossible for folder-of-tracks and zip books (the element holds a
single source). The player now generalizes to a track list built from
the /chapters manifest: single-file books are exactly one track whose
url is the whole file (zero behavior change — same element, same URL
flow), multi-file books swap src at track boundaries.

Mapping helpers (exported, unit-testable): trackIndexFor resolves a
global position to the owning track, localOffsetFor/globalSecondsFor
convert between global seconds — the coordinate system of chapters,
bookmarks and audio_offset_seconds — and (track, local offset). Every
consumer keeps global semantics: scrubber, skip ±N, bookmark saves,
?loc=audio: deep links and progress pushes are byte-identical payloads
server-side; the server contract does not move.

Mechanics: setActiveTrack swaps src and queues the position for
loadedmetadata (setting currentTime before the new metadata loads
resets it); ended advances to the next track and keeps playing;
timeupdate reports globalPosition(); playbackRate/volume re-apply
after swaps; src is deferred until the manifest loads (data-file-url
is a directory/zip root for multi-file books, not playable) with the
catch branch falling back to it for offline single-file starts.
2026-10-19 07:54:45 -04:00
John O'Keefe 6216bd9bcf feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2026-10-19 07:54:28 -04:00
John O'Keefe 24228e7147 test(scan): cancellation endpoint semantics and mid-run safety
- endpoint semantics: non-admin cancel is 403, unknown job is 404
- mid-scan safety: start a scan of 1200 unparseable .epub files and
  cancel it, then pin the invariant that no media item is marked
  missing or archived. The canary item's file is lexically LAST in
  the folder, so an abort that (incorrectly) ran the archive pass
  would treat it as unvisited and trip the assertion. Assertions
  hold under every interleaving - whether the cancel lands mid-walk
  or the scan already finished - so the test cannot flap against
  timing.
- re-cancelling a terminal job is a 409

The test library is created through the shared
createTestLibraryWithFolder helper, which registers API-delete
cleanup so runs leave no residue in the dev database.

Worker-level unit tests live in internal/services/worker_cancel_test.go
(previous commit).
2026-10-18 18:02:45 -04:00
John O'Keefe e1153b2c4b feat(admin): cancel buttons for in-flight scans + cancelled state UI
- per-row Cancel button in the scan-progress card; listeners are bound
  after the TS island renders its rows (module scope has no inline
  handlers), and the button is removed once its job turns terminal
- cancel-all icon button in the card header: resolves the server's
  active job list first, so it also covers scans this page never
  started (e.g. started before a refresh), then cancels each one
- polling treats cancelled as terminal, averages overall progress
  only over jobs that can still advance (so the bar can reach 100%),
  and the results panel reports cancelled scans instead of claiming
  "Scan Complete!"
- sidebar spinner resets on the scan_cancelled websocket event (same
  quiet handling as scan_error)
- setup wizard shows "Cancelled" as a terminal status in its poller

No new innerHTML assignment sites - rendering-ownership baseline
unchanged.
2026-10-18 18:02:34 -04:00
John O'Keefe 5b81e19f2f feat(scanner): POST /api/scanner/cancel/:jobId (admin)
Cancels a pending or running scan job. Responses: 200 with the job id
and status on acceptance, 404 for unknown jobs, 409 for jobs already
in a terminal state (completed/failed/cancelled).

Additive route in the existing admin scanner group; no contract pins
affected.
2026-10-18 18:02:25 -04:00
John O'Keefe 30ecdd4bf3 feat(scanner): ScanFolders honors its context; cancelling never archives
ScanFolders never checked its context, so a cancelled job's walk ran
to completion no matter what the worker's cancel did.

- ctx checks at every stage boundary (file-counting pass, per folder,
  before and during the archive pass) and inside the walk callback
  (fs.SkipAll): the file currently being processed finishes, then the
  scan aborts with context.Canceled, which processJob classifies as a
  cancelled job rather than a failed one
- the archive/purge pass is skipped entirely when the scan was
  cancelled. It only visits what the walk visited, so after a partial
  walk the unvisited (but on-disk) files would be treated as missing,
  archived, and eventually purged. Cancelling is a UI convenience and
  must never destroy library data.
2026-10-18 18:02:19 -04:00
John O'Keefe b2220756e2 feat(worker): real per-job cancellation for the background job pool
CancelJob existed but was dead code: it only flipped a status in the
in-memory results map - it never cancelled anything, and processJob's
unconditional completion write overwrote the marker anyway.

- derive a per-job context in processJob (parent is the handler ctx
  for manual scans, Background for auto-scans) and register its
  cancel func under the job id; previously every manual scan shared
  the handler's process-lifetime context, so no targeted cancel was
  possible without killing everything
- skip execution at dequeue for jobs cancelled while still queued
- record a pending JobResult on BOTH enqueue paths (Enqueue used by
  auto-scans, EnqueueJob used by the API): queued jobs were invisible
  to status polling (404 until a worker picked them up) and could not
  be cancelled before execution started
- cancelled jobs keep their last real progress instead of a fake 100%
  and drop the meaningless "context canceled" error text
- broadcast the new scan_cancelled websocket event for cancelled scan
  jobs instead of scan_complete, so clients reset their scanning
  indicators rather than reading the abort as a finished scan
- sentinel errors (ErrJobNotFound, ErrJobNotCancellable) so HTTP
  handlers map outcomes to 404/409 without string matching

Unit tests (worker_cancel_test.go) pin: queued-cancel never executes,
running-cancel invokes the context, error mapping, pending recording
on both enqueue paths.
2026-10-18 18:02:11 -04:00
John O'Keefe d74db03cf3 feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.

- Ingest: the KOReader sync loop, on an unresolvable file, upserts
  unlinked_books (one row per device + content id; re-syncs bump
  last_seen_at and refresh device-reported metadata) instead of
  dropping the file. Schema gains the (device_id, content_id) unique
  index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
  the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
  — devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
  when the caller's queue is non-empty (hidden-unless-needed), linking
  to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
  verifies device ownership and target-item visibility (403s) and
  creates the device file alias, so the next sync matches by path.
  SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
  — triage candidates, not part of the live flow.
2026-10-18 16:45:48 -04:00
John O'Keefe 9e831085ef fix(ui): derive Library Type dropdowns from library_types — audiobooks was missing
The audiobook library type shipped backend-complete (LibraryTypeAudiobooks
constant, allowed extensions, MIME map, schema seed row, GET
/api/libraries/types) but neither create-library form offered it: the
admin Create Library modal and the setup wizard's add-library step both
hardcoded their <option> lists to the original three types
(ebooks/comics/manga), so an audiobook library could not be created from
the UI.

Both dropdowns now render from the same DB-backed library_types table
the create API resolves types against — a future type appears without
template edits (the exact drift the rendering-ownership rule warns
about):

- templates/utils.go: OrderedLibraryTypes pins the historical dropdown
  order (ebooks, comics, manga, audiobooks) and appends unknown future
  types by name; LibraryTypeDisplayName renders labels with a
  capitalize-the-name fallback for types added without a label
- templates/admin_library.templ: new LibraryTypeOptions fragment used
  by the modal's select; AdminLibrary takes the types list
- templates/setup.templ: Setup takes the types list, same fragment
- internal/router/helpers.go: libraryTypeOptionsFor fetches via
  LibraryService.GetLibraryTypes, falling back to the built-in type
  names on DB failure so the forms stay usable (CreateLibrary still
  resolves the name against the DB, so the fallback is never
  authoritative)
- internal/router/frontend.go, internal/router/setup.go: pass the list
  into the two pages

Tests:
- templates/utils_test.go: pure unit tests for ordering + labels
- cmd/server/tests/admin_library_page_test.go: SSR regression —
  /admin/library must offer all four library types, audiobooks exactly
  once (options come from the DB loop, not a hardcode)
2026-10-18 16:38:03 -04:00
John O'Keefe 327fc2c0f9 feat(matching): match results as a scoped templ partial + enriched matches
- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
2026-10-18 16:00:04 -04:00
John O'Keefe a31da89df5 feat(ui): View Transitions crossfade for library switches
Where the browser supports it (Chrome 111+, Safari 18+, Firefox 139+),
switchWithTransition now uses the native View Transitions API: the old
shelves are snapshotted, the fetch + swap happen behind the dim, and the
two snapshots crossfade — no blank phase, no manual opacity
choreography. The dim + spinner is hidden inside the update so it blends
away as part of the transition. Browsers without the API keep the
existing dim + fade sequence unchanged. Zero dependencies — the
crossfade is the browser's own.
2026-10-18 16:00:04 -04:00
John O'Keefe a11b4a9cc4 fix(ui): library-switch overlay was fully opaque — the 'new page' wipe
The switch overlay intended a 50% dim (bg-opacity-50) but set its
background with an inline style — Tailwind's bg-opacity variable does
not apply to inline styles, so the overlay rendered fully opaque. Every
library switch wiped the material area to a solid sheet (chrome stays
visible: the sidebar sits at z-60 above the overlay's z-50) and
hard-cut back when the overlay dropped — reading exactly as a new page
instead of a fade. The dashboard showed it worst because its switch
holds the overlay longest (sections fetch + awaited week-widget refresh
behind it).

- overlay background is now theme-aware translucent
  (color-mix 55% bg-primary over transparent); the dead bg-opacity-50
  class is gone
- the dim lifts before the fade-in starts, so the new content's fade
  plays fully visible rather than being hard-cut mid-transition
2026-10-18 13:16:35 -04:00
John O'Keefe 040c10ac76 feat(matching): family-lens scoping for book matching and linking
/api/books/match returned media metadata from ALL libraries regardless
of visibility: the resolver lookups (GetMediaItemBySHA256 and friends)
are global, so match results leaked hidden-library titles, covers and
ids to any authenticated user, and LinkBook accepted a link target from
a hidden library. Both now enforce the family-lens rule:

- QueryBooks post-filters the cascade by the requester's visible-library
  set (absent-from-results — a hidden library's book simply never
  matches), before determineAction so the suggested action reflects the
  filtered set. BookQueryRequest gains a server-side-only
  VisibleLibraryIDs field; nil keeps legacy callers unfiltered.
- LinkBook verifies the device belongs to the caller
  (DeviceExistsForUser) and the target item's library is visible —
  403 otherwise. Linking was previously unscoped on both axes.
- GetUnlinkedBooks likewise verifies device ownership (its service is
  still a stub, but the route no longer answers for other users'
  devices).

Also: the collections books-grid partial uses a light loader —
requested library validated against the visible set directly instead of
the page-only library resolution (folder reachability, counts,
dropdown payload), removing the extra queries that made library
switching hold the spinner longer.

And the dashboard library switch now folds the This Week widget's
htmx refresh behind the spinner (refreshWeekWidget awaits the widget's
swap, with a safety timeout) instead of letting it pop in after the
fade-in — the two-stage update introduced with the widget's
library-scoped re-fetch.

Guardrail: the rendering lint now counts all innerHTML assignments (not
just template-literal ones) — the old pattern undercounted 20 files at
80 sites, including .join("") builders like renderDashboardCollections.
Baseline rebalanced accordingly.
2026-10-18 13:10:57 -04:00
John O'Keefe 4a68fe7cab fix(conflicts): the status filter, select-all, and bulk actions actually work
They never did: filterConflicts/toggleAllConflicts/updateBulkActions
were referenced in the templ but defined nowhere, and the bulk buttons
called bulkResolve()/bulkDismiss() with no arguments against functions
expecting (strategy, conflictIds).

- conflict cards carry data-status; the status dropdown filters them
  client-side (pure client state — allowed to be JS), applied on page
  init and re-applied after every partial refresh, with a
  'no conflicts match this filter' note when everything is filtered out
- select-all toggles only the visible cards; the bulk bar shows the
  selected count and resolve/dismiss now gather the checked ids and the
  chosen strategy (the bar's own buttons use resolveSelected()/
  dismissSelected() wrappers)
- dead onchange= globals became Alpine @change handlers on the server-
  rendered controls; partial swaps re-bind via Alpine.initTree (already
  in refreshConflicts) and re-apply filter + bulk state

Visible change: the dropdown's stated default (Unresolved Only) now
actually applies on load, so resolved conflicts are hidden until the
filter is changed.
2026-10-18 12:42:50 -04:00
John O'Keefe cb4e892b10 test(templates): pin the rendering partials — standalone render, OOB markers, empty state 2026-10-18 12:30:55 -04:00
John O'Keefe 506a04b55d chore(web): library-admin inline script moves to admin.ts; rendering guardrail
The library admin page carried an inline <script> block of global
functions (toggleLibraryPanel, openEditModal, openDeleteModal,
openFolderBrowser plus the delete-modal close listener). They move into
the admin Alpine component as typed methods; the page body gains
x-data="admin" and the call sites switch onclick= -> @click.
Behavior unchanged.

Guardrail: make lint-rendering (wired into make test) fails when a
web/src file builds more HTML template literals than its recorded
baseline (web/src/rendering-allowlist.txt, seeded at 18 sites across 9
files post-twin-deletion) or when a .templ file gains an inline script
block. theme_flash_guard.templ is the only sanctioned inline script;
web/src/reader is a sanctioned island.

theme.ts: Array.from(classList) — DOMTokenList spread needs
lib.dom.iterable, which the tsconfig does not include.
2026-10-18 12:28:50 -04:00
John O'Keefe f1844591b4 feat(web): templ partials replace the last UI twins (collections grid, conflicts list)
Both pages had a templ render (initial load) and a TypeScript twin
(innerHTML rebuild on refresh) of the same fragment. The twins are
deleted; the server-rendered markup is now the only markup.

- collections: the library switcher swaps in the new books-grid partial
  (GET /collections/:id/books-grid) instead of re-rendering cards from
  the /api/collections JSON. Book loading is extracted into
  Config.loadCollectionBooks, shared verbatim by the page and the
  partial (smart / dashboard-query / manual branches, family-lens
  scoping via resolveLibrary + UserVisibleLibraryIDs) so the two cannot
  drift.
- conflicts: resolve/dismiss refreshes swap the new list partial
  (GET /conflicts/list) with the stats bar as an out-of-band swap,
  instead of the TS twin — which rendered a degraded card (no
  checkboxes, no timestamps), pointed at a window.showResolveModal that
  does not exist, and updated stats via element ids the SSR never had.
- shims.d.ts: window.htmx typing (ajax/process) for TS callers.

Visibility note: the books-grid partial inherits its family-lens
scoping from the shared loader (identical to the detail page); a
dedicated hidden-library pin test for the partial route is still worth
adding in the next test pass.
2026-10-18 12:28:25 -04:00
John O'Keefe 1fdf306f2d docs(agents): rendering-ownership rule — templ renders, JS decides what shows
Codifies the standing rule discussed for the templ/JS split: content
derived from server data is rendered by templ and refreshed via templ
partials (htmx.ajax), pure client-local state may render in TS/Alpine,
web/src is TypeScript-only, and inline <script> in templ files requires
a pre-paint justification (theme_flash_guard.templ only). Lists the
sanctioned JS islands (reader, Chart.js, toasts, password validation).

Also refreshes gotcha #1: the 'harmlessly masked' aside is stale — the
quoted-attribute sweep fixed every occurrence; the note now points at
templates.JSONArg for free text.
2026-10-18 12:28:25 -04:00
John O'Keefe b89eac22e3 fix(templ): stop emitting literal { expr } text into attributes and body classes
Release / build-and-push (push) Successful in 3m31s
templ does not interpolate { expr } inside quoted attribute values — the
braces ship as literal text (AGENTS.md gotcha #1). Swept every templ
source for the pattern and fixed all of it:

Body theme classes: 22 page bodies rendered class="theme-{ user.Theme }"
literally, so every page first-painted the :root default (Tokyo Night)
and flashed when main.js applied the real theme. All now render
class={ "theme-" + user.Theme } server-side.

Attribute interpolations (26 across 8 files):
- api_explorer: initAPIExplorerDoc path/body/response and the Copy
  Request/Response buttons (invalid JS, silently dead)
- collections: book-selection :checked/@change/@click ids and four
  hx-get search URLs (collection_id={ collection.ID })
- conflicts: Resolve button id
- devices: Kobo sync-url/auth-token element ids (copy lookups only
  worked because the id and the lookup were the same literal)
- unlinked_books: search-matches/manual-link clicks and container ids
- queue: status/priority badge classes never matched their CSS
- dashboard: Restore-section tooltip text
- collection_rules/collections: collection icon color

Free text goes through the new templates.JSONArg (json.Marshal) —
templ.JSONString in this templ version returns (string, error) and
cannot compose in attribute expressions; uuids via device.ID.String().
Includes regen-only drift from running templ generate (error-path
normalization) on files with no source change.
2026-10-18 12:08:20 -04:00
John O'Keefe d5bb874651 feat(themes): Omarchy catalog, 12 light themes, Dark/Light Appearance picker
Expand the catalog from 11 themes (10 dark, 1 light) to 37 (24/13):
all 18 remaining Omarchy defaults (palettes from each theme's
colors.toml) plus 8 canonical light themes (Tokyo Night Day, Solarized
Light, One Light, Ayu Light, GitHub Light, Gruvbox Light, Everforest
Light, Kanagawa Lotus). Lupine leads the light list as the default
light theme; the app default stays tokyo-night.

Appearance panel: Dark/Light segmented filter (SSR-initialized from the
user's current theme), scrollable theme list, Bookshelf section pinned;
active row revealed on open. Light themes carry color-scheme: light and
the soft-shadow token set. Public-page theme selects get Dark/Light
optgroups. New moon/sun icons.

tailwind.config: safelist the new theme ids — Tailwind v3 purges custom
@layer base class selectors that appear in no scanned content file, so
an unsafelisted id silently ships without its CSS (found the hard way).

theme.ts: applyTheme now swaps only theme-* classes (changing theme
from the login select no longer wipes min-h-screen) and keeps the class
in sync on <html> for the new ThemeFlashGuard — a pre-paint inline
script on the four public pages that applies the saved theme before
first paint.

Kanagawa's UI accent uses crystalBlue (#7e9cd8) instead of Omarchy's
foreground-as-accent so links stay distinguishable from text.
2026-10-18 12:08:07 -04:00
868 changed files with 52118 additions and 22313 deletions

No files matched your search

+5
View File
@@ -82,3 +82,8 @@ session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
# Binary media fixtures: never commit (generate with ffmpeg at test time,
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
test-results
+154 -11
View File
@@ -4,6 +4,75 @@ Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
JavaScript is kept minimal. The rule that keeps it that way:
> **JavaScript decides what shows. templ decides what it looks like.**
- Content derived from **server data** (DB rows, search results, match
lists, conflict cards, any list/card/badge/table) is rendered by
templ. When it changes client-side, fetch a **templ partial** from an
SSR route and swap it in (`htmx.ajax(...)`), never rebuild it from a
JSON payload with `innerHTML` string templates.
- **Pure functions of client-local state** (open/closed panels, tabs,
password strength from a typed value, toasts) may render in
TS/Alpine — no server round trip needed.
- `web/src` is **TypeScript only** — no new `.js` source files.
Vendored third-party and vite build output under `web/static` are
exempt.
- Inline `<script>` in a `.templ` file needs a reason a bundled module
cannot do the job (pre-paint execution). Currently sanctioned:
`theme_flash_guard.templ` only.
Sanctioned JS islands (engines, not UI renderers — exempt from the
above): the reader (foliate-js + `web/src/reader/`), Chart.js graphs in
stats, toasts, password validation. Anything else that renders app UI
in TypeScript is a defect; if a templ twin of it exists, one of the two
must be deleted.
**Documented exception — dashboard sections**: `renderDashboardCollections`
/ `renderSectionHTML` in `web/src/dashboard.ts` re-render dashboard
sections from `/api/dashboard/sections` JSON (library switch, settings
save, refresh button) to preserve the switch fade/transition on a page
with heavy interactivity. First-paint section markup is still templ
(Dashboard handler in `internal/router/frontend.go`). This exception is
deliberate; do not clone it to new pages, and do not "fix" the dashboard
without restoring a templ-partial fade path first.
## CSP discipline (hash-allow-listed script-src) — ALWAYS apply
script-src carries a sha256 hash, not `'unsafe-inline'` (and never
`'unsafe-eval'` — the Alpine build is `@alpinejs/csp` for exactly this
reason; keep it in lockstep with alpinejs). The browser therefore
refuses, **silently at build/test time**, two authoring patterns:
- **No inline `<script>` content outside `theme_flash_guard.templ`.**
The guard is the app's only inline script; its bytes are pinned by
`middleware.ScriptHashThemeFlashGuard` + the drift test. Editing the
guard means recomputing the hash (the failing test prints it); adding
a *second* inline script means hashing it too and extending
`cspSanctionedInlineScripts` in `templates/csp_source_test.go`.
Everything else goes in a bundled `web/src` module.
- **No native `on*=` handler attributes** (`onclick=`, `onerror=`,
`onsubmit=`, `onchange=`, … — quote AND templ brace forms). Inline
handlers never go through the hash path; hash-mode CSP refuses them
unconditionally. Use Alpine `@click`/`@change`/… bound to component
methods (see the 2026-10 CSP-build conversion commits for the
pattern), or delegated listeners for htmx-swapped content.
- **No htmx eval-dependent bindings** (`hx-on*`, `js:`-prefixed
`hx-vals`) — `'unsafe-eval'` is not granted.
`templates/csp_source_test.go` is the tripwire for all three (it
scans the `.templ` sources; it runs in seconds on host). `style-src
'unsafe-inline'` remains by design (htmx-injected styles, Alpine
`:style`); `'self'` still trusts the origin not to serve
attacker-supplied scripts — don't let upload paths become
script-executable. Remember: `templ` does not interpolate `{ expr }`
inside quoted attribute values (gotcha #1 below), so dynamic
`@click={ "fn('" + id + "')" }` attribute values must BE expressions.
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
@@ -34,6 +103,16 @@ were name-only across ALL libraries. Fixed; do not regress.
## Tests
- **THE DOCTRINE — tests are never run against a real/production
server.** The dev DB is the test environment, and the invariant every
test must uphold is: **leave the testuser experience alone** — its
settings, scans, and reading data must be exactly as the suite found
them. The helpers exist to enforce this; if a test needs protection
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
system_settings — exists because a hardcoded ev restore could clobber
a deliberate dev bump, and the live dev server obeys that setting on
its next poll).
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write
@@ -57,6 +136,41 @@ were name-only across ALL libraries. Fixed; do not regress.
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
true` destroyed the live reader's stats data on every suite run
until scoped to the test-sync users (cascade via user delete).
- **RUN ONLY YOUR OWN TESTS while iterating**: integration tests
require the containerized dev DB — a bare host `go test
./cmd/server/tests` fails on auth on every machine not running the
compose stack. Use the filtered container runner
(`docker compose -f docker-compose.yml -f
docker-compose.dev.yml --profile tests run --rm tests go test
./cmd/server/tests -run 'TestYourPrefix' -v`) and save full-suite
runs for pre-commit validation; several tests read live shared
state (system_settings, real library folders, the periodic backup
scan's websocket broadcasts) and flap on data/timing your change
never touched. Full recipes: docs/contributing/development.md
→ Testing.
- **NEVER COMMIT BINARY MEDIA** (mp3/m4b/flac/... fixtures):
`internal/services/testdata/` is gitignored by policy; generate
media at test runtime with ffmpeg instead (test images ship it).
ffmpeg cannot write Audible freeform atoms — those paths are unit-
tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures.
## Web (TypeScript) tests
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
the markdown security suite test nothing (verified empirically; jsdom is the
documented fallback for exactly this reason).
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
`npx vp fmt web/src` to apply formatting.
- Tests are never bundled (build entries are main.ts/reader.ts only). When
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
produce byte-identical hashes, and no test-only strings may appear in
web/static.
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
parser-level html escaping). Loosening any assertion there is a security
decision, not cleanup.
## Timezones
@@ -96,22 +210,29 @@ of a server bug: stop and flag the user.
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
shipped a request to `/api/collections/{ collection.ID }` found by
the request logs; the whole attribute value must BE an expression:
`x-init={ "f('" + col.ID + "')" }`. Note
`class="theme-{ user.Theme }"` has always been literal (harmlessly
masked by the JS theme engine).
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
attribute interpolations — was swept and fixed in 2026-10; when
adding attributes with dynamic values, the whole value is an
expression, and free text goes through `templates.JSONArg`.)
2. **SettingsRegistry caches**: writes must go through the settings
API (Reload on save). Direct SQL writes to system_settings leave
the cache stale until the next API-path write — never "preview"
rule changes via psql and expect them applied (2026-10-14: a live
curl test flip looked ignored until the API path was used).
3. **User collections are not dashboard sections**: nothing sets
collection show_on_dashboard for user rows, and GetDashboardSections
skips empty user sections. Collection detail (JSON + SSR) must NOT
route user rows through the dashboard-section path — a 7ce7321
regression did exactly that (static detail without library_id
returned zero books until 0a7fbd6). Only real system query types
(continue-reading etc.) route there; `query_type='filter'` is the
schema DEFAULT on every user row, not a query type.
3. **User collections are not dashboard query types** (amended 2026-10-19,
A2c): user collections ARE dashboard sections by default now —
GetUserCollectionsForDashboard no longer gates on show_on_dashboard
(which remains system-section provisioning state; hiding is
dashboard-prefs `hidden_collections` territory). GetDashboardSections
still skips genuinely empty user sections, but hide_finished
collections emit a Complete section instead of vanishing. Collection
detail (JSON + SSR) must still NOT route user rows through the
dashboard-section path — a 7ce7321 regression did exactly that
(static detail without library_id returned zero books until 0a7fbd6).
Only real system query types (continue-reading etc.) route there;
`query_type='filter'` is the schema DEFAULT on every user row, not a
query type.
4. **End-of-day arithmetic**: anchor the END bound to the START of the
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
instant's clock time into tomorrow — a range "ending today"
@@ -144,3 +265,25 @@ of a server bug: stop and flag the user.
test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions.
7. **Scanner re-extraction is write-after-success** — a failed/empty
extraction must NEVER clear scan-derived columns. `updateMediaItem`
(generic full-row path) preserves `existing.AudioMetadata` when the
new extraction carries no audio payload, and `updateAudioBookItem`
refuses empty payloads over populated rows (both guarded 2026-10-21
after an ev-gated re-extract NULLed every multi-file audiobook's
audio_metadata — books displayed as 1 second long and lost their
track manifests). New scan-owned column writes need the same guard.
8. **templ double-escapes style attribute values** — the codegen wraps
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
character (even inside `templ.SafeCSS`) reaches the DOM as literal
`&#39;` text and the CSS never parses (2026-10-21: every book-detail
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
`cssURL()` in book_detail.templ.
9. **`vp test` runs the vite config** — plugins with build hooks fire
in the test context too. `cleanStaleChunks` guards on
`produced.size === 0` precisely because a bare sweep deleted every
hashed chunk in `web/static` when run under vitest (2026-10-21:
`npm run test` gutted the build; every page 404'd its imports).
Any future outDir-mutating plugin needs the same "am I a build?"
guard.
+4 -1
View File
@@ -59,7 +59,10 @@ RUN cp -r /go/pkg/mod /app/go-module-cache
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl poppler-utils
# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag
# fallbacks, embedded covers, cue probing) must be exercised in tests
# exactly as it runs in production.
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
WORKDIR /app
+28 -1
View File
@@ -47,9 +47,36 @@ help:
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
test: lint-rendering
go test ./internal/... -v -short
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). *.test.ts files are excluded: jsdom fixtures
# build DOM in tests, they don't render shipped UI. Shrink baselines as
# fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/' | grep -v '\.test\.ts$$'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
violations="$$violations $$f ($$count > $$allowed)"; \
fi; \
done; \
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
case $$f in *theme_flash_guard*) continue;; esac; \
violations="$$violations $$f (inline script)"; \
done; \
if [ -n "$$violations" ]; then \
echo "rendering-ownership violations:$$violations"; exit 1; \
fi; \
echo "rendering ownership OK"
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Admin Library
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/admin/library'
auth: inherit
body:
type: none
docs: |-
## Get Admin Library Page
Retrieves the admin library page for administrative access.
**Method:** GET
**Endpoint:** /admin/library
**Headers:**
- `Authorization` (string): Bearer token
**Response:**
- HTML content for the admin library page
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden
+31
View File
@@ -0,0 +1,31 @@
info:
name: Hash Conflicts (API)
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/admin/hash-conflicts'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Hash Conflicts (API)
`GET /api/admin/hash-conflicts` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/hash_conflicts_api.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Users
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/auth/users'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Users
`GET /api/auth/users` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/list_users.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Update User Max Devices
type: http
seq: 3
http:
method: PUT
url: '{{base_url}}/api/auth/users/{{media_item_id}}/max-devices'
auth: inherit
body:
type: json
data: |-
{
"max_devices": 15
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Update User Max Devices
`PUT /api/auth/users/:id/max-devices` — auth: jwt+admin.
Canonical reference: docs/developer/api/admin/update_user_max_devices.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-47
View File
@@ -1,47 +0,0 @@
info:
name: Get Device Usage
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/analytics/device-usage'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get usage statistics for all devices.
**Endpoint**: GET /api/analytics/device-usage
**Auth**: Required (Bearer token)
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| devices | array | List of device usage statistics |
| devices[].id | string | Device ID |
| devices[].device_name | string | Device name |
| devices[].device_type | string | Device type (kobo, kindle, koreader) |
| devices[].sync_count | int | Number of sync operations |
| devices[].last_sync | string | Last sync timestamp |
| devices[].total_reading_minutes | int | Total reading time on device |
| devices[].books_read | int | Number of books completed on device |
## Example Response
```json
{
"devices": [
{
"id": "789e4567-e89b-12d3-a456-426614174001",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"sync_count": 45,
"last_sync": "2026-02-08T17:25:00Z",
"total_reading_minutes": 1250,
"books_read": 3
-57
View File
@@ -1,57 +0,0 @@
info:
name: Get Popular Books
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/analytics/popular-books?limit=10'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get popular books sorted by read count.
**Endpoint**: GET /api/analytics/popular-books
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| limit | int | No | Maximum number of books to return (default: 10) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| books | array | List of popular books |
| books[].media_item_id | string | Book ID |
| books[].title | string | Book title |
| books[].author | string | Book author |
| books[].read_count | int | Number of times read |
| books[].avg_completion | float | Average completion rate (0-1) |
| books[].cover_url | string | Cover image URL |
## Example Request
```
GET /api/analytics/popular-books?limit=10
```
## Example Response
```json
{
"books": [
{
"media_item_id": "323e4567-e89b-12d3-a456-426614174002",
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"read_count": 5,
"avg_completion": 0.85,
"cover_url": "/api/books/323e4567-e89b-12d3-a456-426614174002/cover"
@@ -1,58 +0,0 @@
info:
name: Get Reading Stats Date Range
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get reading statistics for a specific date range.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format, default: 30 days ago) |
| end_date | string | No | End date (ISO 8601 format, default: today) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed in range |
| total_pages_read | int | Total pages read in range |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Percentage of books completed (0-1) |
| daily_reading_minutes | array | Daily reading time per day |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats?start_date=2024-01-01&end_date=2024-01-31
```
## Example Response
```json
{
"total_books_read": 2,
"total_pages_read": 450,
"total_reading_time_minutes": 720,
"completion_rate": 0.85,
"daily_reading_minutes": [
{
"date": "2024-01-01",
"minutes": 30
-58
View File
@@ -1,58 +0,0 @@
info:
name: Get Reading Stats
type: http
seq: 4
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get overall reading statistics for the authenticated user.
**Endpoint**: GET /api/analytics/reading-stats
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|-----------|-------------|
| start_date | string | No | Start date (ISO 8601 format) |
| end_date | string | No | End date (ISO 8601 format) |
## Response Fields
| Field | Type | Description |
|-------|------|-------------|
| total_books_read | int | Total books completed |
| total_pages_read | int | Total pages read |
| total_reading_time_minutes | int | Total reading time in minutes |
| completion_rate | float | Average book completion rate (0-1) |
| daily_reading_minutes | array | Daily reading time breakdown |
| daily_reading_minutes[].date | string | Date (ISO 8601) |
| daily_reading_minutes[].minutes | int | Minutes read on that date |
## Example Request
```
GET /api/analytics/reading-stats
```
## Example Response
```json
{
"total_books_read": 12,
"total_pages_read": 3450,
"total_reading_time_minutes": 5400,
"completion_rate": 0.78,
"daily_reading_minutes": [
{
"date": "2026-01-15",
"minutes": 45
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Reading Statistics
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/analytics/reading-stats'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Reading Statistics
`GET /api/analytics/reading-stats` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_analytics.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Device Usage
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/analytics/device-usage'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Device Usage
`GET /api/analytics/device-usage` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_device_usage.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Popular Books
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/analytics/popular-books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Popular Books
`GET /api/analytics/popular-books` — auth: jwt.
Canonical reference: docs/developer/api/analytics/get_popular_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+59
View File
@@ -0,0 +1,59 @@
info:
name: Login User
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/auth/login'
auth: inherit
body:
type: json
data: |-
{
"login": "user@example.com",
"password": "SecureP@ss123!"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Login User
`POST /api/auth/login` — auth: none.
Canonical reference: docs/developer/api/authentication/login.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Logout User
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/auth/logout'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Logout User
`POST /api/auth/logout` — auth: none.
Canonical reference: docs/developer/api/authentication/logout.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Password Rules
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/auth/password-rules'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Password Rules
`GET /api/auth/password-rules` — auth: none.
Canonical reference: docs/developer/api/authentication/password_rules.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+58
View File
@@ -0,0 +1,58 @@
info:
name: Refresh Token
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/auth/refresh'
auth: inherit
body:
type: json
data: |-
{
"refresh_token": "d4f5g6h7..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Refresh Token
`POST /api/auth/refresh` — auth: none.
Canonical reference: docs/developer/api/authentication/refresh_token.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+62
View File
@@ -0,0 +1,62 @@
info:
name: Register User
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "user@example.com",
"username": "john",
"password": "SecureP@ss123!",
"first_name": "John",
"last_name": "Doe"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: after-response
code: |-
function onResponse(res) {
try {
const responseBody = res.getBody();
const token = responseBody.access_token;
const refreshToken = responseBody.refresh_token;
if (token) {
bru.setEnvVar("token", token, { persist: true });
console.log("Access token saved");
}
if (refreshToken) {
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
console.log("Refresh token saved");
}
} catch (error) {
console.error("Token capture failed:", error.message);
}
}
onResponse();
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Register User
`POST /api/auth/register` — auth: none.
Canonical reference: docs/developer/api/authentication/register.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,40 @@
info:
name: Register - Admin Role Unauthenticated (403)
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "smoke-admin-attempts@tests.bookhoard.internal",
"username": "smokeadminattempts",
"password": "Sm0ke@Pass123!",
"role": "admin"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 403", function() {
expect(res.status).to.eql(403);
});
docs:
content: |-
# Register - Admin Role Unauthenticated (403)
With admins already present, an unauthenticated register attempt
carrying role=admin must be rejected with 403 and create nothing.
Repeats safely.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Register - Duplicate Email (409)
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "testuser@tests.bookhoard.internal",
"username": "testuser",
"password": "Wh4t3v3r@Pass!"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 409", function() {
expect(res.status).to.eql(409);
});
docs:
content: |-
# Register - Duplicate Email (409)
Registering with an email that already exists must fail with 409 and
create nothing. Negative scenario — hand-authored (the generated set
covers only happy paths). Repeats safely.
type: text/markdown
@@ -0,0 +1,38 @@
info:
name: Register - Weak Password (400)
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/auth/register'
auth: inherit
body:
type: json
data: |-
{
"email": "smoke-weak-attempts@tests.bookhoard.internal",
"username": "smokeweakattempts",
"password": "weak"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 400", function() {
expect(res.status).to.eql(400);
});
docs:
content: |-
# Register - Weak Password (400)
Passwords below the configured complexity rules (see
GET /api/auth/password-rules) must be rejected with 400. Repeats safely.
type: text/markdown
+38
View File
@@ -0,0 +1,38 @@
info:
name: Auto Link Books
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/sync/auto-link-books'
auth: inherit
body:
type: json
data: |-
{
"confidence_threshold": 0.8,
"limit": 100
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Auto Link Books
`POST /api/sync/auto-link-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/auto_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+48
View File
@@ -0,0 +1,48 @@
info:
name: Bulk Link Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/sync/bulk-link-books'
auth: inherit
body:
type: json
data: |-
{
"links": [
{
"unlinked_book_id": "770e8400-e29b-41d4-a716-446655440002",
"media_item_id": "660e8400-e29b-41d4-a716-446655440001",
"confidence_score": 0.95
},
{
"unlinked_book_id": "880e8400-e29b-41d4-a716-446655440003",
"media_item_id": "990e8400-e29b-41d4-a716-446655440004",
"confidence_score": 0.87
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Link Books
`POST /api/sync/bulk-link-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/bulk_link_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Create Device File Alias
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases'
auth: inherit
body:
type: json
data: |-
{
"media_item_id": "550e8400-e29b-41d4-a716-446655440001",
"file_name": "book.epub",
"file_hash": "abc123..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Create Device File Alias
`POST /api/devices/:id/file-aliases` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/create_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Delete Device File Alias
type: http
seq: 4
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases/{{alias_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Device File Alias
`DELETE /api/devices/:id/file-aliases/:aliasId` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/delete_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Book Matches
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/api/books/match'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Book Matches
`GET /api/books/match` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_book_matches.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Device File Aliases
type: http
seq: 6
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Device File Aliases
`GET /api/devices/:id/file-aliases` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_device_file_aliases.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Unlinked Book Suggestions
type: http
seq: 7
http:
method: GET
url: '{{base_url}}/api/sync/unlinked-books/{{unlinked_book_id}}/suggestions'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Unlinked Book Suggestions
`GET /api/sync/unlinked-books/:id/suggestions` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_unlinked_book_suggestions.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Unlinked Books
type: http
seq: 8
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/sync/unlinked-books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Unlinked Books
`GET /api/devices/:deviceId/sync/unlinked-books` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/get_unlinked_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+43
View File
@@ -0,0 +1,43 @@
info:
name: Link Book
type: http
seq: 9
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/sync/link-book'
auth: inherit
body:
type: json
data: |-
{
"device_file": {
"file_path": "/books/hobbit.epub",
"sha256": "abc123...",
"title": "The Hobbit"
},
"media_item_id": "550e8400-e29b-41d4-a716-446655440000",
"confidence_score": 1.0
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Link Book
`POST /api/devices/:deviceId/sync/link-book` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/link_book.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+41
View File
@@ -0,0 +1,41 @@
info:
name: Search Books for Matching
type: http
seq: 10
http:
method: POST
url: '{{base_url}}/api/sync/books/query'
auth: inherit
body:
type: json
data: |-
{
"identifiers": ["isbn:978-0345391802", "uuid:abc-123"],
"sha256": "a1b2c3d4e5f6abc123...",
"title": "The Hobbit",
"author": "J.R.R. Tolkien",
"file_size": 2456789
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Search Books for Matching
`POST /api/sync/books/query` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/search_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,38 @@
info:
name: Update Device File Alias
type: http
seq: 11
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}/file-aliases/{{alias_id}}'
auth: inherit
body:
type: json
data: |-
{
"file_name": "updated-book.epub",
"file_hash": "newhash123..."
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Update Device File Alias
`PUT /api/devices/:id/file-aliases/:aliasId` — auth: jwt.
Canonical reference: docs/developer/api/book-matching/update_device_file_alias.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-15
View File
@@ -1,15 +0,0 @@
info:
name: Bulk Delete Media Items
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/media-items/bulk-delete'
auth: inherit
body:
type: json
data: "{\n \"media_item_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Delete Media Items\n\n Deletes multiple media items in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-delete\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_ids` (array of strings): Array of media item UUIDs to delete\n\n **Response:**\n - `results` (array): Results for each deletion attempt\n - `total` (number): Total number of media items processed\n - `deleted` (number): Number of successfully deleted media items\n - `failed` (number): Number of failed deletions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_ids\": [\n \"uuid-1\",\n \"uuid-2\",\n \"uuid-3\"\n ]"
-16
View File
@@ -1,16 +0,0 @@
info:
name: Bulk Update Media Items
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/media-items/bulk-update'
auth: inherit
body:
type: json
data: "{\n \"media_item_updates\": [\n {\n \"media_item_id\": \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Update Media Items\n\n Updates multiple media items in a single request with different fields for each item.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-update\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_updates` (array): Array of update objects\n - `media_item_id` (string): Media item UUID to update\n - `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)\n\n **Response:**\n - `results` (array): Results for each update attempt\n - `total` (number): Total number of media items processed\n - `updated` (number): Number of successfully updated media items\n - `failed` (number): Number of failed updates\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_updates\": [\n {\n\
\ \"media_item_id\": \"uuid-1\",\n \"updates\": {\n \"title\": \"New Title\",\n \"genre\": \"Fiction\",\n \"tags\": [\"fiction\", \"adventure\"]"
@@ -1,12 +0,0 @@
info:
name: Add Books to Collection
type: http
seq: 6
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{book_id_1}}"
docs: "## Add Books to Collection\n\n Adds multiple books to an existing collection.\n \n **Method:** POST\n \n **Endpoint:** /api/collections/{collection_id}/books\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `book_ids` (array of strings, required): List of book IDs to add\n \n **Response:**\n - `message` (string): Success message\n - `added_count` (integer): Number of books added\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found"
-13
View File
@@ -1,13 +0,0 @@
info:
name: Create Collection
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"name\": \"Science Fiction\",\n \"description\": \"My favorite sci-fi books\",\n \"color\": \"#ff0000\",\n \"icon\": \"\U0001F680\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\",\n \"priority\": 8"
docs: "## Create Collection\n\n Creates a new collection for organizing books.\n \n **Method:** POST\n \n **Endpoint:** /api/collections\n \n **Authentication:** Required (Bearer token)\n \n **Request Body:**\n - `name` (string, required): Collection name (max 100 chars)\n - `description` (string, optional): Collection description (max 500 chars)\n - `color` (string, optional): Hex color code (default #3498db)\n - `icon` (string, optional): Emoji icon (default \U0001F4DA)\n - `auto_assign_rules` (array, optional): Auto-assignment rules\n - `id` (string): Rule ID\n - `field` (string): Field to match (genre, author, series, etc.)\n - `operator` (string): Comparison operator (equals, contains, startsWith)\n - `value` (string): Value to match\n - `priority` (integer): Rule priority (1-10)\n \n **Response:**\n - Collection object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 409: Collection name\
\ already exists"
@@ -1,12 +0,0 @@
info:
name: Create Device Mapping
type: http
seq: 9
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
body:
type: json
data: "{\n \"collection_id\": \"{{collection_id}}"
docs: "## Create Device Mapping\n\n Maps a collection to a device shelf for synchronization.\n \n **Method:** POST\n \n **Endpoint:** /api/devices/{device_id}/collections\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n \n **Request Body:**\n - `collection_id` (string, required): Collection ID to map\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Mapping object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or collection not found"
-30
View File
@@ -1,30 +0,0 @@
info:
name: Delete Collection
type: http
seq: 5
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
docs: |-
## Delete Collection
Permanently deletes a collection and all its mappings.
**Method:** DELETE
**Endpoint:** /api/collections/{collection_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection not found
@@ -1,31 +0,0 @@
info:
name: Delete Device Mapping
type: http
seq: 11
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}'
auth: inherit
docs: |-
## Delete Device Mapping
Removes a collection-to-device mapping.
**Method:** DELETE
**Endpoint:** /api/devices/{device_id}/collections/{mapping_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
- `mapping_id` (string): Mapping ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device or mapping not found
@@ -1,44 +0,0 @@
info:
name: Get Book Collections
type: http
seq: 12
http:
method: GET
url: "{{base_url}}/api/collections/books"
body:
type: json
data: |-
{
"collection_id": "{{collection_id}}"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Get Book Collections
Retrieves all collections that a specific book belongs to.
**Method:** GET
**Endpoint:** /api/collections/books
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `book_id` (string, required): Book ID
**Response:**
- Array of collection objects containing the book
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 404: Book not found
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Collection
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
docs: |-
## Get Collection
Retrieves detailed information about a specific collection.
**Method:** GET
**Endpoint:** /api/collections/{collection_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Response:**
- Collection object with all fields including books, rules, and mappings
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection not found
-31
View File
@@ -1,31 +0,0 @@
info:
name: Get Collections
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/collections?include_auto=true&sort_by=name'
auth: inherit
docs: |-
## Get Collections
Retrieves all collections for the authenticated user.
**Method:** GET
**Endpoint:** /api/collections
**Authentication:** Required (Bearer token)
**Query Parameters:**
- `include_auto` (boolean, optional): Include auto-created collections (default false)
- `sort_by` (string, optional): Sort field (name, created_at, book_count)
- `order` (string, optional): Sort order (asc, desc)
**Response:**
- Array of collection objects
**Status Codes:**
- 200: Success
- 401: Unauthorized
-30
View File
@@ -1,30 +0,0 @@
info:
name: Get Device Mappings
type: http
seq: 8
http:
method: GET
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
docs: |-
## Get Device Mappings
Retrieves all collection mappings for a specific device.
**Method:** GET
**Endpoint:** /api/devices/{device_id}/collections
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
**Response:**
- Array of device mapping objects with collection details
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Device not found
@@ -1,31 +0,0 @@
info:
name: Remove Book from Collection
type: http
seq: 7
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}/books/{{book_id}}'
auth: inherit
docs: |-
## Remove Book from Collection
Removes a single book from a collection.
**Method:** DELETE
**Endpoint:** /api/collections/{collection_id}/books/{book_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
- `book_id` (string): Book ID
**Response:**
- `message` (string): Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Collection or book not found
-12
View File
@@ -1,12 +0,0 @@
info:
name: Update Collection
type: http
seq: 4
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
body:
type: json
data: "{\n \"name\": \"Sci-Fi Favorites\",\n \"description\": \"Updated description\",\n \"color\": \"#00ff00\",\n \"icon\": \"⭐\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-2\",\n \"field\": \"series\",\n \"operator\": \"equals\",\n \"value\": \"Foundation\",\n \"priority\": 9"
docs: "## Update Collection\n\n Updates an existing collection's properties.\n \n **Method:** PUT\n \n **Endpoint:** /api/collections/{collection_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `name` (string, optional): New collection name\n - `description` (string, optional): New description\n - `color` (string, optional): New hex color code\n - `icon` (string, optional): New emoji icon\n - `auto_assign_rules` (array, optional): Updated auto-assignment rules\n \n **Response:**\n - Updated collection object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found\n - 409: Collection name already exists"
@@ -1,12 +0,0 @@
info:
name: Update Device Mapping
type: http
seq: 10
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{mapping_id}}'
auth: inherit
body:
type: json
data: "{\n \"device_shelf_name\": \"Science Fiction\",\n \"sync_direction\": \"book_to_device\""
docs: "## Update Device Mapping\n\n Updates settings for a collection-to-device mapping.\n \n **Method:** PUT\n \n **Endpoint:** /api/devices/{device_id}/collections/{mapping_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n - `mapping_id` (string): Mapping ID\n \n **Request Body:**\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): Sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Updated mapping object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or mapping not found"
@@ -0,0 +1,41 @@
info:
name: Add Auto-Assign Rule
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/rules'
auth: inherit
body:
type: json
data: |-
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Add Auto-Assign Rule
`POST /api/collections/:id/rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/add_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+36
View File
@@ -0,0 +1,36 @@
info:
name: Bulk Add Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/bulk-add-books'
auth: inherit
body:
type: json
data: |-
{
"operations": [
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"book_ids": [
"660e8400-e29b-41d4-a716-446655440001"
]
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Bulk Add Books
`POST /api/collections/bulk-add-books` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_add_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+41
View File
@@ -0,0 +1,41 @@
info:
name: Bulk Assign Books
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
body:
type: json
data: |-
{
"book_ids": [
"660e8400-e29b-41d4-a716-446655440000",
"770e8400-e29b-41d4-a716-446655440000",
"880e8400-e29b-41d4-a716-446655440000"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Bulk Assign Books
`POST /api/collections/:id/books` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_assign.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+32
View File
@@ -0,0 +1,32 @@
info:
name: Bulk Remove Books
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: |-
{
"book_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Bulk Remove Books
`POST /api/collections/:id/books/bulk-remove` — auth: jwt.
Canonical reference: docs/developer/api/collections/bulk_remove_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,16 +0,0 @@
meta:
name: Create Custom Section with Manual Books
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"{{media_item_id}}\"],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"My Favorites\");\n });\n}"
docs: "Create a custom collection with manually selected books.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"book-id-1\", \"book-id-2\"],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
@@ -1,19 +0,0 @@
meta:
name: Create Custom Section Missing Required Fields
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"icon\": \"\U0001F4DA\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 400 with missing name\", function() {\n expect(res.status).to.eql(400);\n});\n\ntest(\"response contains error message\", function() {\n expect(res.body.error).to.exist;\n });\n}"
docs: 'Test error handling when creating a collection without required fields.
**Expected Response**: 400 Bad Request with error message.'
@@ -1,17 +0,0 @@
meta:
name: Create Custom Section with Filter Rules
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"Science Fiction Books\");\n });\n\ntest(\"response contains is_system false\", function() {\n expect(res.body.is_system).to.exist;\n expect(res.body.is_system).to.eql(false);\n });\n}"
docs: "Create a custom collection with filter rules.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| name | string | Yes | Collection name |\n| icon | string | No | Emoji icon |\n| description | string | No | Collection description |\n| show_on_dashboard | boolean | No | Show on dashboard (default: true) |\n| auto_assign_rules | string | No | JSON string of filter rules |\n| manual_book_ids | array | No | Array of book IDs |\n| match_type | string | No | \"all\" (AND) or \"any\" (OR) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\
\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
+35
View File
@@ -0,0 +1,35 @@
info:
name: Create Collection
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
body:
type: json
data: |-
{
"name": "To Read",
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Create Collection
`POST /api/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/create_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,39 @@
info:
name: Create Shelf Mapping
type: http
seq: 6
http:
method: POST
url: '{{base_url}}/api/devices/{{device_id}}/collections'
auth: inherit
body:
type: json
data: |-
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"device_shelf_name": "Sci-Fi",
"sync_direction": "bidirectional"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 201", function() {
expect(res.status).to.eql(201);
});
docs:
content: |-
# Create Shelf Mapping
`POST /api/devices/:id/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/create_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Delete Collection
type: http
seq: 7
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Collection
`DELETE /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/delete_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Delete Shelf Mapping
type: http
seq: 8
http:
method: DELETE
url: '{{base_url}}/api/devices/{{device_id}}/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Shelf Mapping
`DELETE /api/devices/:id/collections/:collectionId` — auth: jwt.
Canonical reference: docs/developer/api/collections/delete_shelf_mapping.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Collection
type: http
seq: 9
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Collection
`GET /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/get_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Get Book Collections
type: http
seq: 10
http:
method: GET
url: '{{base_url}}/api/collections/{{collection_id}}/books'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Book Collections
`GET /api/collections/:id/books` — auth: jwt.
Canonical reference: docs/developer/api/collections/get_collection_books.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Collections
type: http
seq: 11
http:
method: GET
url: '{{base_url}}/api/collections'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Collections
`GET /api/collections` — auth: jwt.
Canonical reference: docs/developer/api/collections/list_collections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-16
View File
@@ -1,16 +0,0 @@
meta:
name: Preview Collection
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/preview'
auth: inherit
body:
type: json
data: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
runtime:
scripts:
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\nexpect(res.body.items).to.be.an('array');\n });\n}"
docs: "Preview collection with filter rules before saving.\n\n**Endpoint**: POST /api/collections/preview\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| rules | array | No | Filter rules to evaluate |\n| manual_book_ids | array | No | Manually selected book IDs |\n| limit | int | No | Max items to return (default: 20, max: 100) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"rules\": [\n {\n \"id\": \"rule1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Fiction\",\n \"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}\n```\n\n## Response\n\nReturns array of matching book items.\n\nThis endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection."
+32
View File
@@ -0,0 +1,32 @@
info:
name: Preview Collection
type: http
seq: 12
http:
method: POST
url: '{{base_url}}/api/collections/preview'
auth: inherit
body:
type: json
data: |-
{
"library_id": "550e8400-e29b-41d4-a716-446655440000",
"rules": [],
"manual_book_ids": [],
"limit": 20
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Preview Collection
`POST /api/collections/preview` — auth: jwt.
Canonical reference: docs/developer/api/collections/preview_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,31 @@
info:
name: Remove Auto-Assign Rule
type: http
seq: 13
http:
method: DELETE
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Remove Auto-Assign Rule
`DELETE /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/remove_auto_assign_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+32
View File
@@ -0,0 +1,32 @@
info:
name: Reorder Collection Items
type: http
seq: 14
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/items/order'
auth: inherit
body:
type: json
data: |-
{
"item_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Reorder Collection Items
`PUT /api/collections/:id/items/order` — auth: jwt.
Canonical reference: docs/developer/api/collections/reorder_items.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,18 +0,0 @@
info:
name: Bulk Add Books to Collections
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/bulk-add-books'
auth: inherit
body:
type: json
data: "{\n \"operations\": [\n {\n \"collection_id\": \"{{collectionId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Add Books to Collections\n\n Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/bulk-add-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `operations` (array): Array of collection-book operations\n - `collection_id` (string): Collection UUID\n - `book_ids` (array): Array of book UUIDs to add to the collection\n\n **Response:**\n - `results` (array): Results for each operation\n - `total` (number): Total number of operations\n - `success` (number): Number of successful operations\n - `failed` (number): Number of failed operations\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"operations\": [\n {\n \"collection_id\":\
\ \"collection-uuid-1\",\n \"book_ids\": [\"book-1\", \"book-2\"]"
@@ -1,15 +0,0 @@
info:
name: Bulk Remove Books - All Books
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: "## Bulk Remove Books from Collection\n\n Removes multiple books from a collection in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/{collection_id"
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Empty List
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Empty List Validation\n\n Tests validation behavior when providing an empty book_ids array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** book_ids array must contain at least one book UUID.\n\n **Purpose:** Ensures the API properly validates input and rejects empty removal requests."
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Invalid IDs
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Invalid IDs\n\n Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.\n\n **Expected Result:** 200 OK with partial success\n\n **Purpose:** Verifies that:\n - Invalid UUID formats don't crash the endpoint\n - Non-existent book IDs are handled gracefully\n - Valid IDs in the same request are still processed\n - Response includes detailed results showing which succeeded/failed\n\n **Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately."
@@ -1,17 +0,0 @@
info:
name: Bulk Remove Books - Single Book
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/{{collection_id}}/books/bulk-remove'
auth: inherit
body:
type: json
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Remove Books - Single Book\n\n Tests that bulk remove endpoint works correctly with a single book.\n\n **Expected Result:** 200 OK with removed: 1, total: 1\n\n **Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.\n\n **Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Author Contains
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"author\",\n \"operator\": \"contains\",\n \"value\": \"Asimov\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Author Contains\n\n Tests the \"contains\" operator on the author field to find books by a specific author (partial match).\n\n **Example Use Case:** Finding all books by an author whose name contains \"Asimov\" (e.g., \"Isaac Asimov\").\n\n **Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).\n\n **Expected Result:** Returns all books where the author field contains \"Asimov\".\n\n **Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Copyright Year Greater Than
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"copyright_year\",\n \"operator\": \"greater_than\",\n \"value\": \"2000\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Copyright Year Greater Than\n\n Tests the \"greater_than\" operator on the copyright_year field to find books published after a specific year.\n\n **Example Use Case:** Creating a \"Modern Books\" collection with books published after 2000.\n\n **Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).\n\n **Field:** `copyright_year` - The year the book was copyrighted/published.\n\n **Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).\n\n **Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - Empty Rules Array
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - Empty Rules Array\n\n Tests validation behavior when providing an empty rules array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** rules array must contain at least one rule object.\n\n **Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.\n\n **Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests."
@@ -1,17 +0,0 @@
info:
name: Test Collection Rules - No Matches
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"NonExistentGenre123456\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules - No Matches\n\n Tests behavior when collection rules don't match any books in the library.\n\n **Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.\n\n **Expected Result:** 200 OK with empty matches array and total: 0\n\n **Purpose:** Verifies that the API handles zero-match scenarios gracefully:\n - Returns 200 (success) not 404\n - Returns empty array, not null\n - Returns total: 0 for clarity\n - No errors thrown for no results\n\n **Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections."
@@ -1,18 +0,0 @@
info:
name: Test Collection Rules
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Test Collection Rules\n\n Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/test-rules\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `rules` (array): Array of rule objects to test\n - `field` (string): Field to test (genre, author, copyright_year, tags, etc.)\n - `operator` (string): Comparison operator\n - `equals`: Exact match\n - `contains`: Contains substring (for text fields)\n - `greater_than`: Greater than (for numeric fields)\n - `less_than`: Less than (for numeric fields)\n - `not_equals`: Not equal to\n - `starts_with`: Starts with\n - `ends_with`: Ends with\n - `is_empty`: Field is empty or null\n - `is_not_empty`: Field is not empty and not null\n - `value` (string): Value to compare against (not required\
\ for is_empty/is_not_empty)\n\n **Response:**\n - `matches` (array): Array of matching books\n - `id` (string): Book UUID\n - `title` (string): Book title\n - `author` (string): Book author\n - `genre` (string): Book genre\n - Additional book metadata\n - `total` (number): Total number of matching books\n\n **Status Codes:**\n - 200: Success - returns matching books\n - 400: Invalid request (empty rules array, invalid field/operator)\n - 401: Unauthorized\n - 500: Internal server error\n\n **Example Request:**\n ```json\n {\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
+48
View File
@@ -0,0 +1,48 @@
info:
name: Test Rule
type: http
seq: 15
http:
method: POST
url: '{{base_url}}/api/collections/test-rules'
auth: inherit
body:
type: json
data: |-
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Test Rule
`POST /api/collections/test-rules` — auth: jwt.
Canonical reference: docs/developer/api/collections/test_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+36
View File
@@ -0,0 +1,36 @@
info:
name: Update Collection
type: http
seq: 16
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}'
auth: inherit
body:
type: json
data: |-
{
"name": "To Read (2026)",
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Update Collection
`PUT /api/collections/:id` — auth: jwt.
Canonical reference: docs/developer/api/collections/update_collection.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+29
View File
@@ -0,0 +1,29 @@
info:
name: Update Collection Rule
type: http
seq: 17
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/rules/{{rule_id}}'
auth: inherit
body:
type: json
data: |-
{
"enabled": false
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Update Collection Rule
`PUT /api/collections/:id/rules/:ruleId` — auth: jwt.
Canonical reference: docs/developer/api/collections/update_rule.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
-22
View File
@@ -1,22 +0,0 @@
info:
name: Delete Conflict
type: http
seq: 4
http:
method: DELETE
url: '{{base_url}}/api/conflicts/{{conflict_id}}'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Delete Conflict
Permanently deletes a specific conflict record from the system.
**Method:** DELETE
**Endpoint:** /api/conflicts/{conflict_id
-37
View File
@@ -1,37 +0,0 @@
info:
name: Dismiss All Resolved Conflicts
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/conflicts/dismiss-all'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Dismiss All Resolved Conflicts
Deletes all resolved conflicts for the authenticated user, cleaning up the conflict list.
**Method:** POST
**Endpoint:** /api/conflicts/dismiss-all
**Authentication:** Bearer token
**Response:**
- `deleted` (number): Number of conflict records that were deleted
**Status Codes:**
- 200: Success - conflicts deleted
- 401: Unauthorized
- 500: Internal server error
**Example Response:**
```json
{
"deleted": 5
-22
View File
@@ -1,22 +0,0 @@
info:
name: Get Conflict Details
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/conflicts/{{conflict_id}}'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Get Conflict Details
Retrieves detailed information about a specific conflict, including side-by-side comparison of conflicting data from all sources.
**Method:** GET
**Endpoint:** /api/conflicts/{conflict_id
-74
View File
@@ -1,74 +0,0 @@
info:
name: List Conflicts
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/conflicts?status=unresolved'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## List Conflicts
Lists all sync conflicts for the authenticated user with optional filtering.
**Method:** GET
**Endpoint:** /api/conflicts
**Authentication:** Bearer token
**Query Parameters:**
- `status` (string, optional): Filter by resolution status
- `unresolved`: Only unresolved conflicts (default)
- `user_resolved`: Conflicts resolved by user
- `auto_resolved`: Automatically resolved conflicts
- `all`: All conflicts regardless of status
- `type` (string, optional): Filter by conflict type
- `progress`: Reading progress conflicts
- `note`: Bookmark/note conflicts
- `highlight`: Highlight conflicts
**Response:**
- `conflicts` (array): Array of conflict objects
- `total` (number): Total number of conflicts matching filters
- `unresolved` (number): Number of unresolved conflicts
**Each Conflict Object:**
- `id` (string): Conflict UUID
- `media_item_id` (string): Associated book UUID
- `media_item_title` (string): Book title
- `conflict_type` (string): Type of conflict (progress, note, highlight)
- `conflict_data` (object): Side-by-side comparison of conflicting data
- `koreader`: Data from KOReader device
- `kobo`: Data from Kobo device
- `web`: Data from web interface
- `resolution_status` (string): Current status (unresolved, user_resolved, auto_resolved)
- `created_at` (string): ISO 8601 timestamp when conflict was detected
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Example Request:**
```
GET /api/conflicts?status=unresolved&type=progress
```
**Example Response:**
```json
{
"conflicts": [
{
"id": "conflict-uuid",
"media_item_id": "book-uuid",
"media_item_title": "Foundation",
"conflict_type": "progress",
"conflict_data": {
"koreader": { "percentage": 0.65, "epubcfi": "..."
-15
View File
@@ -1,15 +0,0 @@
info:
name: Resolve Conflict
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/conflicts/{{conflict_id}}/resolve'
auth: inherit
body:
type: json
data: "{\n \"winner\": \"koreader\",\n \"manual_data\": null,\n \"apply_to_all_future_conflicts\": false,\n \"reason\": \"User chose more recent progress\""
headers:
- key: Authorization
value: Bearer {{token
docs: "## Resolve Conflict\n\n Resolves a sync conflict by choosing which source to use for the conflicting data.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/{conflict_id"
@@ -0,0 +1,40 @@
info:
name: Bulk Dismiss Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-dismiss'
auth: inherit
body:
type: json
data: |-
{
"conflict_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
]
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Dismiss Conflicts
`POST /api/conflicts/bulk-dismiss` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/bulk_dismiss_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -0,0 +1,41 @@
info:
name: Bulk Resolve Conflicts
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: |-
{
"conflict_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"660e8400-e29b-41d4-a716-446655440001"
],
"strategy": "highest_progress"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Bulk Resolve Conflicts
`POST /api/conflicts/bulk-resolve` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/bulk_resolve_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Delete Conflict
type: http
seq: 3
http:
method: DELETE
url: '{{base_url}}/api/conflicts/{{media_item_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 204", function() {
expect(res.status).to.eql(204);
});
docs:
content: |-
# Delete Conflict
`DELETE /api/conflicts/:id` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/delete_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Dismiss All Resolved
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/conflicts/dismiss-all'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Dismiss All Resolved
`POST /api/conflicts/dismiss-all` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/dismiss_all_resolved.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Conflict Details
type: http
seq: 5
http:
method: GET
url: '{{base_url}}/api/conflicts/{{media_item_id}}'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Conflict Details
`GET /api/conflicts/:id` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/get_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: List Conflicts
type: http
seq: 6
http:
method: GET
url: '{{base_url}}/api/conflicts'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# List Conflicts
`GET /api/conflicts` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/list_conflicts.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Resolve Conflict
type: http
seq: 7
http:
method: POST
url: '{{base_url}}/api/conflicts/{{media_item_id}}/resolve'
auth: inherit
body:
type: json
data: |-
{
"winner": "kobo"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Resolve Conflict
`POST /api/conflicts/:id/resolve` — auth: jwt.
Canonical reference: docs/developer/api/conflicts/resolve_conflict.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,17 +0,0 @@
info:
name: Bulk Dismiss Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-dismiss'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Dismiss Conflicts\n\n Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-dismiss\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to dismiss\n\n **Response:**\n - `results` (array): Results for each dismissal\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully dismissed conflicts\n - `failed` (number): Number of failed dismissals\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\"]"
@@ -1,18 +0,0 @@
info:
name: Bulk Resolve Conflicts
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Resolve Conflicts\n\n Resolves multiple sync conflicts in a single request using a specified resolution strategy.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Resolution strategy\n - `most_recent`: Use the most recently updated progress\n - `highest_progress`: Use the reading progress with the highest percent read\n - `server`: Always prefer server-side data\n - `device`: Always prefer device-side data\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n\
\ - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\", \"uuid-3\"],\n \"strategy\": \"most_recent\""
@@ -1,18 +0,0 @@
info:
name: Bulk Resolve with Highest Progress Strategy
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/conflicts/bulk-resolve'
auth: inherit
body:
type: json
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: "## Bulk Resolve with Highest Progress Strategy\n\n Resolves multiple sync conflicts using the \"highest_progress\" strategy, which keeps the reading progress with the highest percentage read.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Must be \"highest_progress\"\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been\
\ reading on multiple devices and want to keep the furthest position."
@@ -1,38 +0,0 @@
info:
name: Create Collection with Dashboard
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/collections'
auth: inherit
runtime:
scripts:
- type: tests
code: "test(\"creates collection successfully\", function() {\n expect(res.status).to.eql(201);"
docs: |-
Create a new collection with dashboard visibility enabled.
**Endpoint**: POST /api/collections
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Collection name |
| description | string | No | Collection description |
| color | string | No | Hex color code |
| icon | string | No | Icon emoji or name |
| show_on_dashboard | boolean | No | Show on dashboard (default: false) |
## Example Request
```json
{
"name": "My Favorites",
"description": "My favorite books",
"color": "#FF5733",
"icon": "⭐",
"show_on_dashboard": true
@@ -1,73 +0,0 @@
info:
name: Get Dashboard Sections
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/dashboard/sections?library_id={{library_id}}'
auth: inherit
body:
type: none
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with auth\", function() {\n expect(res.status).to.eql(200);\n\
});\n\ntest(\"response contains sections array\", function() {\n expect(res.body.sections).to.exist;\n\
expect(res.body.sections).to.be.an('array');\n });"
docs: |-
Get all dashboard sections for a specific library.
**Endpoint**: GET /api/dashboard/sections
**Auth**: Required (Bearer token via auth: inherit)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| limit | int | No | Items per section (default: 20, max: 100) |
## Response
Returns array of sections including:
- System collections (continue-reading, recently-added, recently-read, not-started)
- User collections marked with show_on_dashboard: true
## Section Structure
Each section has:
- `id`: Collection name (string)
- `is_system`: Boolean indicating if this is a system collection
- `title`: Display title
- `description`: Collection description
- `icon`: Emoji icon
- `items`: Array of BookInfo objects
- `view_all_url`: URL to view all items (system collections only)
- `priority`: Display order (lower numbers first)
## Example Response
```json
{
"sections": [
{
"id": "continue-reading",
"is_system": true,
"title": "Continue Reading",
"description": "Books you're currently reading (0 < progress < 1)",
"icon": "📖",
"items": [
{
"media_item_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading",
"priority": 1
}
]
}
```
@@ -1,30 +0,0 @@
info:
name: Get Dashboard Sections by Library
type: http
seq: 3
http:
method: GET
url: '{{base_url}}/api/dashboard/sections'
auth: inherit
runtime:
scripts:
- type: tests
code: "test(\"status must be 200\", function() {\n expect(res.status).to.eql(200);"
docs: |-
Get all dashboard sections for a specific library.
**Endpoint**: GET /api/dashboard/sections
**Auth**: Required (Bearer token)
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
## Response
Returns array of sections including:
- Smart sections (continue-reading, in-progress, recently-added, etc.)
- User collections marked for dashboard
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Dashboard Preferences
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/dashboard/preferences'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Dashboard Preferences
`GET /api/dashboard/preferences` — auth: jwt.
Canonical reference: docs/developer/api/dashboard/get_preferences.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+31
View File
@@ -0,0 +1,31 @@
info:
name: Get Dashboard Sections
type: http
seq: 2
http:
method: GET
url: '{{base_url}}/api/dashboard/sections'
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
runtime:
scripts:
- type: tests
code: |-
test("status is 200", function() {
expect(res.status).to.eql(200);
});
docs:
content: |-
# Get Dashboard Sections
`GET /api/dashboard/sections` — auth: jwt.
Canonical reference: docs/developer/api/dashboard/get_sections.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
@@ -1,16 +0,0 @@
info:
name: Restore System Collection
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/dashboard/restore-system-collection'
auth: inherit
body:
type: json
data: "{\n \"collection_name\": \"continue-reading\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid collection\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response has success message\", function() {\n expect(res.body.message).to.exist;\n});"
docs: "Restore a system collection to its default state.\n\n**Endpoint**: POST /api/dashboard/restore-system-collection\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| collection_name | string | Yes | Name of system collection to restore |\n\nValid collection names:\n- `continue-reading`: Books you're currently reading (0 < progress < 1)\n- `recently-added`: Newly added items to this library\n- `recently-read`: Books you've finished (progress >= 1)\n- `not-started`: Books you haven't read yet (progress = 0 or no record)\n\n## Example Request\n\n```json\n{\n \"collection_name\": \"continue-reading\"\n}\n```\n\n## Response\n\nReturns success message on restore."
Loaded 100 of 868 files, more files were not shown because too many files have changed in this diff. Show more