Compare commits

...
78 Commits
Author SHA1 Message Date
John O'Keefe 2165829d0e fix(ux): confirm modal's Cancel/Confirm were dead on the promise-API path
Release / build-and-push (push) Successful in 3m29s
User report: the Purge Archived dialog ('Permanently delete all
archived items?...') opened, and Cancel did nothing — and Confirm
would have been equally dead, leaving the page stuck behind the
overlay until a reload.

Root cause: confirm.ts wired the modal's buttons ONLY inside the
htmx:confirm interceptor. confirmDialog() — the promise API used by
purgeArchivedItems, remove-account, and other TS call sites — never
wired them. So whenever a page's first confirm interaction came from
the promise path (no hx-confirm element clicked first), both buttons
had no listeners. The 2026-10-30 confirm_probe spec only exercised the
hx-confirm path, which is why it survived.

Fix: wireButtons() now runs at module init (the header renders before
deferred modules) and inside confirmDialog() — both idempotent via the
existing data-wired guard.

Pinned by tests/ui/promise_confirm_probe.spec.ts (no env gating): the
purge POST is route-intercepted, so the probe asserts Cancel closes
the modal and Confirm issues the guarded request without deleting any
dev data. hx-confirm path re-pinned green (confirm_probe with BH_FEED).
Verified manually at 390x844: open -> cancelWired=true -> Cancel
closes -> Confirm fires the intercepted request.
2026-11-02 11:24:55 -05:00
John O'Keefe 04e855e691 fix(ui): right slide-out sheet for oversized modals on phones; 360px fixes
Auditing at 360x760 (Firefox's smallest emulated device; Galaxy S25
class) — 390 was too generous a baseline — the Customize Dashboard
pop-up was the worst page in the app: its card measured 986px in a
760px viewport with no internal scroll, so Cancel/Save sat ~90px below
the fold on a fixed, non-scrolling overlay — unreachable.

New reusable shell: .app-modal-panel (input.css) renders a centered
dialog on desktop (>=640px, unchanged) and a full-height right
slide-out sheet on phones: h-dvh, rounded-l-2xl, slide-in animation
(replays on every open because the overlay toggles display), internal
scroll, overscroll containment. Overlay pairs with items-stretch /
justify-end below sm.

Converted the measured overflows:
- Customize Dashboard (986px): sheet + sticky header/footer, rows wrap
  with the Restore/toggle group right-aligned (ml-auto), titles
  truncate, System badge shrink-0.
- Collection create/edit (796px, cut at both ends at 360): sheet +
  sticky header/footer; edit modal shared the file.

Also at 360: admin-library's Archived buttons row (View Archived +
Purge Archived Now, 315px at x=49) inflated the page to 364px — now
flex-wrap. Collection-episodes shows a 1px sub-pixel rounding artifact
(vw=361) — left as-is, invisible.

Verified at 360x760: sheet geometry (footer bottom 739 < 760), rows
fit, desktop 1280 screenshot unchanged; full-route sweep at 360 shows
no page-level horizontal overflow beyond the two notes above.
2026-11-02 10:55:23 -05:00
John O'Keefe 3e8ac3b9b7 docs(user): koreader install is a git clone (git pull to update), per owner
Replaced the 'clone then copy to plugins/' phrasing with the actual
workflow: clone straight into the plugins folder (koreader/plugins,
.adds/koreader/plugins on Kobo), restart KOReader; updates are git pull
in bookhoard.koplugin + restart. Closes the last prose-trusted item in
the user-docs rebuild.
2026-11-02 10:42:49 -05:00
John O'Keefe 10a5ac88da docs(user): themes — drop the invented 'default light theme' tag, per owner
Lupine's '(default light theme)' tag had no basis anywhere: the string
exists only in the ThemeType union, and there is no light-default logic
in theme.ts or the Appearance panel. Platform-owner clarification:
Tokyo Night is the sole default (fresh install); the Dark/Light control
is a list filter with per-half counts, not a theme toggle. The page now
says exactly that, keeps the code-verified bits (mode filter opens on
your current theme's half via ThemeModeOf, checkmark indicator, counts)
and states the one-default rule plainly.
2026-11-02 10:39:09 -05:00
John O'Keefe 3feb608d26 docs(user): two more code-grounding fixes from the verification sweep
- sync-guide matching ladder was missing rung 8: Title only at 0.3
  confidence, last-resort after title+author+size (book_matching.go
  priorities 1-8, file-path priority handled at the API layer).
- library-browsing said the sort dropdown had 'more' options than it
  does: exactly five (Title A-Z/Z-A, Author A-Z, Date Added, Page
  Count).

Sweep results, all verified against source: themes.md 24+13=37 exact
(web/src/theme.ts union), reader-guide claims (corner zones, selection
copy, comics annotations disabled - bookmarks only) present in
web/src/reader/reader.ts, android-app.md strings (local-network
permission, login error table) in bookhoard-app source, calibre
dc:title/calibre:series parsing in media_scanner_calibre fixtures.
2026-11-02 10:28:45 -05:00
John O'Keefe 9b2d3da7e2 docs(user): koreader-setup — document the actual plugin menu from source
The guide documented setup and stopped. Everything after 'approved' was
undocumented: the real menu tree from bookhoard.koplugin main.lua is now
in the guide — one-tap actions (Push progress / Pull progress / Sync
rating, review & time now / Setup OPDS catalog / Device info /
Disconnect / Register device), Automatically push progress (DEFAULTS
OFF — the likely cause of 'sync not working after approval' reports),
Periodically sync every # pages (default 50), Sync mode
(Immediate/Checkpoint), Sync behavior (Sync to a newer state default
Prompt / to an older state default Never — the device-side side of
conflict handling), and the seven What to sync toggles (progress,
bookmarks, highlights, notes, ratings, review, reading time — all on by
default; the ratings/review/reading-time sync was entirely missing from
docs despite the six server endpoints).

Also fixed: Android 'coming later' line now points at the real app,
OPDS auto-registration verified in the approval poll (setupOPDS on
approved), expiry wording, and a troubleshooting entry for the
wifi-toggle permission message auto-sync can raise. Ground truth noted
in a source comment; verify against plugin source on plugin changes.
2026-11-02 10:23:52 -05:00
John O'Keefe 788e9463cf chore(web): delete dead linking module
The module could never execute against any real page:

- renderUnlinkedBooks targeted #unlinked-books-list, an id that exists
  in no template; the live /unlinked-books page is server-rendered by
  templates.UnlinkedBooks and driven by the Alpine unlinkedBooks
  component (web/src/unlinked_books.ts, setupEventDelegation) with its
  own CSP-safe wiring.
- showSuggestionsModal/hideMatchModal need #match-modal and
  #match-modal-content, which no template renders either.
- Its inline onclick handlers referenced window.showMatchModal, a
  function that was never defined anywhere.

Nothing imports it except main.ts's side-effect import, and no
template/Go/TS surface references its Alpine component registration,
containers, or globals (grepped templates/, internal/, web/src/).
Verified after removal: tsc, vp check, vitest (198), and /unlinked-books
plus dashboard/stats load with zero console errors, empty state and
Alpine component intact.
2026-11-02 10:23:43 -05:00
John O'Keefe ef4de7896a fix(csb): custom-section book picker search never worked — two latent bugs
Fine-toothed functional verification of the delegation work exposed that
the picker's book search has been dead since launch, for two
independent reasons:

- The builder fetched GET /api/books/search, a route no router has ever
  registered — every search 404'd (the CSP-dead onclick buttons on the
  results hid the failure). It now calls the unified JSON search the
  bookshelf uses (/api/media-items/search?q=&library_id=), maps its
  rows (id/title/author/cover_image_path) to the picker's BookInfo
  shape, and treats the endpoint's 404-with-results:[] as empty.

- Even with results, the picker container shared id=search-results with
  the header search dropdown (header.templ renders it on every page);
  getElementById returns the first match in tree order, so results were
  written into the invisible topbar dropdown. The picker container is
  now cs-search-results (templ + all three TS references).

Verified end-to-end at 390x844: select library -> search '1984' -> 3
results render -> click adds the selection chip -> chip x removes it.
Zero console errors.
2026-11-02 10:14:35 -05:00
John O'Keefe 12aab156f6 docs(user): sync-guide rebuild to today's platform; calibre guide de-API'd
sync-guide.md was 591 lines with good implementation-derived bones
(matching confidence table, universal locations, conflict flow) wrapped
around a stale device story: Android 'coming later' (the app exists and
self-registers), a deleted queue UI, invented UI (color status badges,
Auto-Resolve Future, Priority Sync buttons), placeholder support links,
an HTTPS-1.3 overclaim on LAN setups that are plain HTTP by design, and
a changelog promising the future. Rebuilt to ~140 lines, every claim
grounded: device table matches reality (web/Android/KOReader supported,
Kobo native server-complete with rollout pending), resolution
strategies as the API actually defines them (device / server /
highest_progress), offline queuing honestly scoped (Android queues;
server holds per-device queue; no dedicated queue UI today), reading
history on Progress page, RLS-era privacy wording, device limit 10
default admin-settable. Reader Sync Progress button verified.

calibre-integration.md: mostly current (sidecar-first model, Dublin
Core tables, Write-to-file section all kept); fixed the user-hostile
'POST /api/scanner/scan' instructions to point at the admin scan
controls, and dropped the yourusername placeholder repo links.
2026-11-02 10:12:41 -05:00
John O'Keefe c1a7648076 docs(user): reader audio section + kobo guide refresh to platform reality
reader-guide.md gains the audiobook/episode player section (seek,
prev/next episode, Bookmarks panel, Chapters list, Player settings with
autoplay/speed/resume-rewind overrides, Shortcuts dialog) — the player
existed with zero user docs.

kobo-setup.md drops the stale 'not available yet, actively being
developed' framing: the server side of native Kobo sync is implemented
and documented (sync/initialization endpoints, bookmark + markup sync,
shelf mappings, KEPUB sidecar config), so the guide now states exactly
that, keeps stock-firmware rollout as the honest remaining piece, and
keeps KOReader as the supported today-path.

ui-controls.md: Player settings / Shortcuts & interactions / Chapters /
Bookmarks entries (templ-entity fragments for ampersand labels).
2026-11-02 10:09:18 -05:00
John O'Keefe 9e2f216af5 docs(user): dashboard, browsing, profile rewrites + new podcasts guide; trash stubs
Ground-truthed against the templates this pass:
- dashboard.md: real section kinds (system shelves incl. Continue
  Series, user collections, custom sections via /custom-section
  builder), Customize Dashboard panel semantics (per-library, drag,
  switches, 10-50 items, Save Changes), Refresh, This Week widget,
  per-item Read/Listen and progress-conflict resolve actions. The old
  page listed a wrong shelf set and never mentioned custom sections.
- library-browsing.md: the file was secretly titled 'Saving Custom
  Filters' and covered nothing else. Now the browsing page: All Books
  toolbar (search, Sort, Filters -> drawer hand-off, Save/Load/Clear
  saved filters), book detail as hub, series pages, podcasts split.
- profile-guide.md: field table ground-truthed from profile_form.templ
  (username/email DO remain editable there), audio defaults (speed,
  resume rewind, keep awake), timezone rendering contract, both theme
  doors (7-pick select vs 37-theme Appearance panel), Change Password
  with live checks, Danger Zone/Remove My Account + last-admin rule.
- podcasts.md NEW: the Podcasts page had zero documentation. Subscribing
  (Add Feed -> RSS URL, library, back-catalog download, download order,
  Add & Subscribe), Continue/All Feeds, Download All, player + Play as
  Sequence cross-link.
- Trashed user-areas.md, frontend-guide.md, settings-guide.md: stubs
  whose planned content now exists as real pages (or duplicates
  profile-guide). user-guide.md rewritten as the plain-language front
  door with a go-here-when table; index.md rows updated to match.
- ui-controls.md: 10 new gate-checked entries for every newly cited
  control (Add & Subscribe via templ-entity fragment).
2026-11-02 10:07:28 -05:00
John O'Keefe ceed3ddeae fix(stats): Most Active card wrapped mid-token on narrow screens
'2026-11-01 (148 min)' broke as '(148 / min)' in the half-width stat
card. Date and minutes now render as stacked block lines — date,
minutes, weekday — so nothing splits across rows.
2026-11-02 09:46:58 -05:00
John O'Keefe 0673550d58 fix(admin): library rows collapse on phones — chips hid the name
Visual review of the first flex-wrap attempt: with the name block at
flex-1 (0 basis), the row never registered as overfull, so wrap never
fired and the shrink-0 chips still overprinted Scan/Manage. Dropping
flex-1 lets wrap trigger — but then the chips starved the name down to
'P…'. The type/folders chips are the least-important content in the
row: hidden below sm, so phones show icon + full name/description with
Scan/Manage on a second row; desktop keeps the single-line layout with
chips (>=640px unchanged).
2026-11-02 09:46:58 -05:00
John O'Keefe 71b171bf72 fix(csp): allow blob: stylesheets in style-src for the reader
Every /readers load logged a CSP violation: foliate-js injects its
reader-generated stylesheets as blob: URLs, and style-src only allowed
'self' 'unsafe-inline' — so the sheet was refused and selection/
highlight styling inside the reader rode on nothing.

blob: was already granted for img-src, font-src, and frame-src for
exactly this reader content; style-src now matches. The content is
app-generated at runtime (no cross-origin input), script-src is
untouched, and the strict script hash allow-list keeps its guarantee.

Pinned by TestThemeFlashGuardHashMatchesCSP (script-src only — the
style-src addition doesn't interact) and TestGoSourcesAreCSPSafe.
2026-11-02 09:31:58 -05:00
John O'Keefe 2df6f8e55c fix(ui): replace CSP-blocked inline handlers with delegated listeners
The hash-allow-list CSP refuses inline onclick=/onerror= attributes, so
three TS islands shipped dead controls:

- stats.ts rendered Most-Read/Longest covers with onerror= — every
  /stats load logged violations (5+ on the dev data) and the broken
  cover fallback never ran. Now: capture-phase error listeners on the
  three list containers (they persist across innerHTML swaps; error
  does not bubble but capture still reaches them).
- custom-section-builder.ts picker rows and selection chips used
  onclick= against window globals. Rows now carry data-book-id/title/
  author and chips data-remove-book; initCustomSectionBuilder wires one
  delegated click listener per container. window.addBookToSelection/
  removeBookFromSelection exports removed — no remaining references.
- linking.ts: the unlinked-books 'Link' button called
  window.showMatchModal, which never existed anywhere (doubly dead:
  missing global AND CSP-blocked); suggestion rows and Cancel used
  onclick= too. All now data-attribute driven through one document-level
  delegated listener; row Link opens the suggestions flow via
  getSuggestions(progressId), which fetches matches and opens the modal.

Verified: tsc --noEmit, vp check, vitest (198), manual modal flows.
2026-11-02 09:31:58 -05:00
John O'Keefe 88adf30aa9 fix(ui): mobile layout overflows across admin, reader, and book pages
Every fix is a minimal class-level change; desktop layouts are untouched
because each fixed row already fit on one line there.

- progress: CFI string is one unbreakable token — it inflated the page
  layout width to 398-501px (whole-page zoom-out on phones) and rendered
  debug-looking text edge to edge. min-w-0 + break-all lets it wrap.
- audio reader: settings strip min-content (~470px) exceeded the 390px
  viewport, clipping Skip/Sleep and — because the page ICB inflated —
  pushing the chapters drawer (w-full of the viewport) past the screen
  edge so its timestamps clipped too. flex-wrap fixes both symptoms.
- book detail edit-metadata modal footer: Rescan/Reset/Write-to-file row
  overflowed the modal; clipped buttons were unreachable. flex-wrap.
- bookshelf save-filter modal footer: three buttons overflowed both
  screen edges. flex-wrap.
- admin libraries: rows overprinted (badge chips shrink-0 vs actions
  shrink-0, no wrap) — 'manga⟳Scan▸1 folders⌄Manage'. flex-wrap stacks
  actions under the name on phones.
- admin podcasts: maintenance table forced a 892px page (44% zoom).
  overflow-x-auto on the card scrolls the table inside it instead.
- stats: 4-pill range row forced 421px; page permanently ~7% zoomed out.
  flex-wrap.
- podcast feed detail: label+select pairs now travel in their own flex
  pairs so flex-wrap can never split 'Order' from its select; ids and
  htmx change-triggers preserved.
- cosmetics: conflicts stat labels tracking-wide→normal (UNRESOLVED
  clipped), archived file path line-clamp-3, rules-page Smart badge
  wraps cleanly, dashboard View All shrink-0 nowrap (was wrapping to
  two lines).
2026-11-02 09:30:55 -05:00
John O'Keefe e3ca3426ba fix(auth): emit default_audio_speed unconditionally on the profile response
TestDocsMatchRoutes failed on users/get_profile.md: the example carries
default_audio_speed but the response struct tagged it omitempty, so the
gate's zero-value marshal never sees the key and reads the doc as
inventing it. The tag was semantically dead: the column is NOT NULL
DEFAULT 1.0 (schema.sql 44) and writes validate 0.25-4, so a stored 0
is out of domain and every real profile emits the field. Response-only
struct, no form-bind impact. The key was added to the doc example in
ef827a3a without a gate run - this is exactly the class of drift the
gate exists to catch; make test now passes again.
2026-11-02 08:56:37 -05:00
John O'Keefe 42b283480a docs: archive the screenshot-automation plan as rejected
The Playwright screenshot pipeline is not being built: screenshots are
the only doc format no gate can verify, they rot silently at every UI
change, and a redesign was the stated plan. Replaced by
docs/developer/writing-ui-docs.md (pattern-first text, exact quoted
labels enforced by TestUIControlLabels) — see that page for the full
reasoning. Body kept below unmodified for history.
2026-11-02 08:22:01 -05:00
John O'Keefe 913174cdf8 docs(gates): widen relative-link checker to all docs, repair the rot it finds
TestDocsRelativeLinks walked only docs/developer; widening it to all of
docs/ immediately surfaced the same class of rot the gate was built
for, outside its old reach:

- docs/index.md was the old monolith's landing page: linked the deleted
  api-reference.md, 10 directory-style area links, and escaped the docs
  tree to README/AGENTS/.env/compose. Rewritten as a portal whose every
  link resolves, with one representative endpoint doc per API area and
  pointers to the new writing-ui-docs.md standard.
- directory-style links in user stubs (devices/, ../operations/)
- case-mismatched links (Development.md) in contributing.md
- monolith-era pointer (API_REFERENCE.md, TROUBLESHOOTING.md) and
  escaped AGENTS.md links in contributing/development.md
- docs/development/calibre-opf-implementation.md double-up escapes, a
  link to the removed repo-root plan file (absorbed here), and a
  source-code hyperlink the docs site can't serve (now inline code)
- operations/operations.md linked three never-written guide pages as
  aspirational links - they stay plain text until pages exist

HANDS-OFF (project owner, 2026-11-02): docs/user/filters/** and
docs/user/searching-and-filtering.md are exempt as link sources so no
future checker failure can force edits to the filter documentation.
Files linking INTO them remain checked.
2026-11-02 08:21:47 -05:00
John O'Keefe bf4735f3ce docs(user): rewrite collections to the screenshot-free standard
The old page was stale in ways the standard now forbids: spatial
language ("top-right"), documented URL query params users should never
need, no mention of smart collections or podcast episodes, no hand-off
to the filter docs.

Ground-truthed against the templates: Static vs lightning-Smart type
choice, Books/Podcast episodes rules scope, Add Items picker ( Books /
Episodes tabs, Add Selected, Remove Selected), Edit Rules -> Filter
tree -> Apply to Collection, Play as Sequence with the listening-order
picker (Feed order / reversed / oldest / newest), Hide finished books
(reading-queue mode) and Show Up Next strip toggles, system shelves
(Continue Reading, Recently Added, Recently Read, Not Started, Continue
Series) and Restore System -> Restore.

Plain language per the user-docs tone contract: short sentences,
pattern-first (one shelf concept, four flavors), every step ends in an
outcome, rules taught by LINK to the filter guide (reference, never
restate). Every quoted control exists as a literal and is glossaried in
ui-controls.md (gate-checked).
2026-11-02 08:18:27 -05:00
John O'Keefe e07301f74a docs(standard): screenshot-free UI documentation standard + UI-label gate
The planned Playwright screenshot docs (SCREENSHOT_AUTOMATION.md) are
replaced by a text standard: screenshots rot silently and cannot be
gate-checked, while this UI's labels are server-rendered templ literals
that can. Layers: pattern-first prose (concepts survive redesigns) >
capability inventory > label->outcome flows > quoted labels > generated
references where a registry exists (filters/fields.md precedent). The
filter docs stay hands-off exemplars; user-doc tone is plain language by
contract, dev/admin tone explicitly deferred.

writing-ui-docs.md is the standard (page template, findability notes,
tone-by-audience, process rule: a UI change is not done until the docs
audit runs). ui-controls.md is the canonical control-label glossary the
docs quote from; TestUIControlLabels greps every entry against
templates/*.templ (source: ts override; also:-fragments for
Alpine-composed labels like the Match/Group header) so renames fail
make test in the same commit as the rename. Gate proven both ways: a
planted bogus label failed; restored set passes.
2026-11-02 08:18:19 -05:00
John O'Keefe fbdd4ab9f9 feat(audio-player): Resume Rewind select in the settings popover
Release / build-and-push (push) Successful in 4m43s
The player window could only change Skip and Speed; resume rewind
lived exclusively on the profile page. The user-facing settings now
match Speed's precedent — changeable from the player, saved to the
account, applied immediately.

The select mirrors the default-speed row (CSP-safe @change bound to
one Alpine method; templ emits the option list from the shared
ResumeRewindOptions allow-list and the :selected expression against
the live resumeRewind field). Applying updates the running player's
value first — the next open/unpause uses it with no reload — then
PUTs audio_resume_rewind_seconds to the profile endpoint as the
string wire shape the server binds. normalizeResumeRewind gates the
select's value through 0/5/10 before both steps.
2026-11-01 21:29:19 -05:00
John O'Keefe ef827a3a05 test(api): pin the resume-rewind contract
Release / build-and-push (push) Successful in 3m2s
TestAudioResumeRewind drives the whole surface against the shared dev
DB with the fresh per-test regular user (the live admin is never
touched; the fixture book lives in a test-named library that cascades
away): default 0, profile PUT "5" rides the chapters payload,
non-allow-listed values 400 without side effects, explicit "0"
resets everywhere, and an omitted JSON key leaves the column
untouched.

Changelog entry in docs/contract_pins.md (additive chapters key +
the profile field's string-shaped PUT, keep_screen_awake precedent)
and the get_profile.md example gains the preference keys.

Debug note for the next agent: the first runs here 401'd with
'Ghost session rejected' — NOT a harness or auth bug. The tests never
execute schema.sql; the shared dev DB gets its schema from the app
container's boot. The rebuilt tests image ran the new GetUser
(selecting audio_resume_rewind_seconds) against a DB whose schema
came from the old app image — every GetUser errored, and the
ghost-session guard turned that into 401s. Applying the schema.sql
ALTER to the dev DB (what the next app rebuild does anyway) turned
the suite green. Until make rebuild-app runs, the running app
container serves the old binary.
2026-11-01 19:38:08 -05:00
John O'Keefe 7b7ce39826 feat(audio-player): apply the resume-rewind setting on open and unpause
The chapters payload now carries resume_rewind_seconds (server-side
per-user setting); the player applies it at two events, each rewinding
once from where playback currently sits — never stacked:

- open-resume: restorePosition starts the rewind early from the saved
  spot (percentage and chapter+offset branches, clamped at the book
  start). Deep links (?loc=) stay exempt — an explicit jump is not a
  resume.
- unpause: a genuine pause→play steps back again (the re-orientation
  gap). Programmatic plays — post-restore autoplay and track-switch
  autoplays — set a suppress flag: their play event is the session
  start the open-rewind already positioned, not an unpause.

Math lives in audio-rewind.ts (pure, unit-tested) after the
audio-scrubber pattern; payload values gate through the 0/5/10
allow-list so stale or foreign servers degrade to exact resume.
2026-11-01 17:21:35 -05:00
John O'Keefe 9cf9e1d305 feat(api): expose and validate the audio resume rewind setting
GET/PUT /api/auth/profile now carries audio_resume_rewind_seconds
(always-present on the response: 0 is a real value, exact resume; the
request shape is the keep_screen_awake string precedent so the form
select can send an explicit 0=Off while JSON clients that omit it
leave the column untouched — a plain int with an omitempty contract
could not express 'set to off'). Strict allow-list {0,5,10}: the UI
offers exactly three choices and the API refuses everything else.

The audiobook chapters endpoint embeds resume_rewind_seconds in its
existing payload — it already loads the user row for playback speed,
so both players (web + Android, phone UI and the Android Auto service)
get the value with the fetch they perform anyway; zero extra round
trips.

Profile page gains the Resume Rewind select (Off / 5 s before /
10 s before) next to Default Playback Speed.
2026-11-01 17:19:58 -05:00
John O'Keefe daffe499eb feat(users): audio_resume_rewind_seconds column
Per-user resume rewind for the audiobook/podcast player: how many
seconds (0/5/10) before the saved position playback starts, both on
open-resume and again on every genuine unpause. Motivated by the car
startup case — exact resume loses the listener's place, and the
PlaybackRequestResolver path (phone UI, Android Auto, media buttons)
all funnel through one start position.

0 = exact resume (default), so the column is inert until the profile
page sets it. Follows the default_audio_speed / keep_screen_awake
precedent: idempotent ALTER in schema.sql (re-runs on every boot),
explicit GetUser SELECT, dedicated :exec update. Handler-side strict
allow-list {0,5,10} lands with the API slice.
2026-11-01 17:16:35 -05:00
John O'Keefe 0d613ba0e3 fix(docs): folder-index pages expand the sidebar and highlight — same rule for every page
The filter manual is the docs tree's first FOLDER-index page: it serves
at /docs/user/filters while its nav entry carries /index
(/docs/user/filters/index). The sidebar's two checks — group expansion
(containsURL) and active-link styling (docsNavClass) — compared URLs
EXACTLY, so the folder URL matched neither: no submenu expansion, no
highlight (2026-11-01 user report: "the left sidebar should open to
the submenu and highlight the page").

Fix is structural, not per-page: both checks normalize folder-index
URLs (a trailing /index is the same page as its directory), so EVERY
folder-index page — current and future — expands and highlights like
a file page. No per-page registration exists anywhere in the docs
system to redo: the sidebar derives from the docs tree, and this was
the one URL shape its equality rule missed.

Pins: navigation (section + group expand for the folder URL, group
present) and docsNavClass (folder URL highlights the Overview item,
normalization symmetric, file pages keep their highlight, no
cross-highlighting).
2026-11-01 16:34:25 -05:00
John O'Keefe a4dc03984c feat(docs): "?" help affordance on every filter mount + docs loader fixes
The filter manual (1ed1602e) was reachable only by knowing /docs
already existed — nothing in the UI pointed at it (the parked manual
plan's open decision). Every filter-builder mount now carries a "?"
in its header linking to the guide (opens in a new tab so drawer and
editor state survive): the All Books drawer and the collection rules
page — the two mounts sharing FilterBuilderRows. The docs home
indexes the manual (guide, cookbook, generated field reference).

Two docs-loader fixes surfaced by pinning this:
- Folder URLs resolve to their index page: /docs/user/filters now
  serves user/filters/index.md (the manual is a folder: guide +
  cookbook + generated reference); previously only root /docs had
  index handling.
- The docs folder path is resolved by walking upward (bounded):
  `go test` runs test binaries from the PACKAGE directory, so the
  relative "docs" path resolved to a folder that didn't exist there
  and every /docs route 404'd in tests while working in prod —
  found by the new pin, fixed so tests exercise the docs routes
  exactly as prod serves them.

Pins: TestFilterHelpAffordance (folder URL serves the guide; drawer
and rules pages carry the link). internal/docs suite + full suite
green (239s).
2026-11-01 16:22:35 -05:00
John O'Keefe 1ed1602e70 docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):

- index.md — the guide: the three homes of filters (drawer/saved
  filters, smart collections, dashboard sections), the books/episodes
  scope door, rule anatomy, value picking (chips with x, live counts
  under other rules), GROUPS via the new header toggle (+ the row
  chips as the fine-tuning tool), the live match count, today's
  defaults (reversed listening order, family-lens scoping,
  un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
  OR-across-authors, big-unread-fantasy, downloaded-unplayed,
  new-arrivals, dashboard shelf, shareable views). Every claim
  cross-checked against the generated reference — which caught the
  one wrong operator label in my first draft ("within last" vs the
  real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
  TestFieldReferenceDocIsCurrent: the drift test fails the build if
  the registry ever changes without regenerating the docs (user
  decision: docs forced to match working code; FILTER_DOCS_REGEN=1
  regenerates).
- searching-and-filtering.md cross-links to the folder.
2026-11-01 15:46:34 -05:00
John O'Keefe 2344f5686a feat(filter-builder): the group header is the ALL/ANY switch
The group header ("MATCH"/"GROUP") was a dead label while the engine
honored group modes all along — buildGroupClause parenthesizes nested
groups and joins each by its own mode, and validation accepted
all/any from day one. The only thing missing was the UI: + Group
birthed ALL groups and no control anywhere could flip one, so the
user's target structures (GROUP(GROUP(... or ...) ...) — 3-level
mixed-mode nesting) were constructible only through the invisible
row-level chips, and standalone ANY-groups not at all (2026-11-01
user report: "MATCH cannot be clicked").

- The header is now the control: MATCH ALL ⇄ MATCH ANY (root — a
  synthetic header row, since the root was never projected and its
  mode was invisible too), GROUP ALL ⇄ GROUP ANY (nested). ANY is
  accented; aria-pressed carries the state. Labels are path-based
  (root reads Match, explicit groups read Group) instead of
  depth-based, which previously mislabeled top-level groups.
- The row-level chips are untouched — they mix AND/OR inside a group
  (A AND (B OR C)), which a global mode toggle cannot express; the
  header is the discoverable entry point, the chips the refinement.
- Dropdown audit (the "would it bug out" check): suggestTree is
  mode-generic — root-ANY drops group siblings, sibling ANY-groups
  survive into the constraint with their mode intact, empty groups
  never emit (no 400s) — now pinned. Deep-nesting Go pin: a 3-level
  mixed-mode tree evaluated through composed search + the suggestion
  endpoint (200, never the empty-group 400). 192 web tests green.
2026-11-01 15:43:52 -05:00
John O'Keefe 5fb80f82ed test(podcasts): the briefing acceptance plays the born-reversed order
Release / build-and-push (push) Successful in 3m32s
The §0-item-2 acceptance pin predates the chain-order system and
assumed the native default: it started the chain at feed position 1
and expected a forward hop. With the born-reversed default (d56031f3),
the page's first card is feed position 5 and the chain runs 5 → 1 —
the acceptance's MEANING is unchanged (play from the first card you
see, chained back-to-back within the live briefing set); the fixture
card it starts from flipped accordingly. Changelogged.

This was the only failure in the full-suite validation run (154s,
green) — the first whole-suite pass across today's four units
(chips, label endpoint, apply-path seed, born-reversed acceptance).
2026-11-01 13:55:49 -05:00
John O'Keefe 57bc0e9dd4 feat(collections): the rules-Apply path seeds the reversed default too
The creation seed (d56031f3) covered born-smart collections; a STATIC
collection that becomes smart through the rules editor's Apply landed
without any curated order — its dropdown stayed "Feed order" while
every other podcast smart collection opened reversed. Same decision,
same guardrails (2026-11-01 user approved closing this gap): a PUT
carrying an episodes-scope tree seeds chain_order=reverse only when
neither the request NOR the stored view_settings names one — a
deliberate native (client-sent or saved) is never clobbered, and
books-scope trees seed nothing.

Pins: apply-seeds-when-unordered, respects-stored-native,
respects-client-native (TestSmartCollectionBornReversed now covers
both seed paths); changelogged in contract_pins.
2026-11-01 13:44:23 -05:00
John O'Keefe 96a40f7d88 feat(filter): label endpoint names id values for client-held trees
The SSR embed (65f257f3) covers trees the server renders; one path
still fell through to the flawed fallback — applying a SAVED filter
from a cold bookshelf. That page was rendered before any filter
existed (nothing embedded), and the fallback reused the suggest facet,
whose rows are MEDIA-CONSTRAINED: "feed in [A,B,C] AND title contains
Chapter" labeled only the feeds with a matching episode (the exact
live repro from the original bug report).

- GET /api/filter/labels?field=X&ids=a,b,c resolves names straight
  from the entity tables via the shared ResolveFieldLabels core
  (extracted from FilterTreeLabels): no media join, no tree
  constraint. RLS/visibility ride the request identity — a
  zero-episode subscribed feed resolves its title (pinned; the facet
  path structurally could not), an unsubscribed feed id stays unnamed
  (pinned), non-id fields are 400 (pinned), ids cap at 100.
- Client: resolveIdLabels/resolveLabelsAsync use ONLY the endpoint;
  the quiet suggest-fetch mode is deleted (its only caller was the
  flawed fallback). The saved-filter apply flow resolves through the
  endpoint and rebuilds the chips bar when labels land — chips render
  raw ids only while the round trip is in flight, never stuck.
- 188 web tests green; Go endpoint pins + filter/episode slices green;
  contract_pins documents the endpoint and the fallback removal.
2026-11-01 13:40:25 -05:00
John O'Keefe a20741ccc9 feat(filter-builder): multi-pick values render as removable chips
The id-kind value cells (feed/library/collection/added_by) rendered
their picks as one comma-joined string inside a 9rem-wide input — you
saw the first label and nothing else, and reading 3-5 picks meant
scrolling inside a tiny box (worst on mobile, user request 2026-11-01).

Id-kind cells now render a chipbox: each picked value is a chip (label
truncated with a hover title, removable via an X button with a finger-
sized tap target), the box flex-wraps and grows vertically instead of
clipping, and the inline input stays at the end for typing/searching —
including the Backspace-on-empty-removes-last-chip affordance. Text/
number/date cells keep today's single input; the value menu is
unchanged (it never offers an already-picked value, so the chip X is
the faster removal path, not a new one).

Presentation only: the stored wire is still one comma-joined value per
rule; matching, menus, apply, and label resolution are untouched. Pins
moved from valueCellText (now text-cells-only) to the chip model
(idChipsFor/idDraftFor) and cover removal order, last-chip removal, and
Backspace; 187 web tests green.
2026-11-01 13:33:34 -05:00
John O'Keefe d56031f334 feat(collections): new podcast smart collections are BORN reversed
Per user request, same shape as 0e29eff7 — a default flip, no internal
logic changes: POST /api/collections with an episodes-scope filter_tree
now seeds view_settings.chain_order = "reverse" on the new row, so the
collection lands with "Feed order (reversed)" selected in the detail
header's dropdown and the chain/page order follows the stored value as
if picked. Books-scope smart collections and statics get nothing
seeded; an explicit client chain_order is never clobbered; existing
collections are untouched (the seed exists only at creation).

Pins: TestSmartCollectionBornReversed (episodes seeded, explicit keep,
books-scope and static no-seed); TestEpisodeCollectionsFilterTree's
native expectations flip to the born-reversed reality (changelogged).
One misplaced pin from 81e54f89 corrected: the API envelope's match
list is not chain-ordered and never was — order pins live on the SSR
page and reader chain.
2026-11-01 11:53:24 -05:00
John O'Keefe 0e29eff7b7 feat(podcasts): unset listening order defaults to reversed (audiobook-first)
The per-feed listening order — the Order dropdown on the podcast page
and player — resolved its UNSET state to "native": feed document order,
which runs newest-first for classic RSS. This platform is audiobook-
first, so native started every book at its LAST chapter (verified on
live data: 7th Son position 1 = Bonus Episode #2, positions descending
through the chapters).

The unset fallback flips to "reverse" at every resolution site of that
ONE setting — the podcast page's list + dropdown (podcastOrderForFeed),
the player's feed chain (reader.go), and the download queue
(orderOverrideFor) — so the dropdown shows reversed selected, the page
renders in that order, and play/download follow. Stored overrides are
untouched (native stays a valid savable choice); no ordering semantics
changed; collections are untouched.

Pins: the flipped native expectation is changelogged in contract_pins;
new pins cover the sequence config carrying reverse, feed position 1
playing last, and the page dropdown rendering reverse selected for an
unset feed.
2026-11-01 11:46:14 -05:00
John O'Keefe 65f257f377 fix(filter-builder): id labels resolve at render — refresh keeps names
Refresh showed raw uuids in id-kind value cells (feed/library/
collection/added_by). Two defects, one story:

1. Only ids persist in filter trees; the client's id→label cache dies
   with the page. The quiet re-resolution fetch reused the suggest
   facet, whose rows are MEDIA-CONSTRAINED (INNER JOIN media_items +
   the tree's sibling rules as a constraint) — so an id whose media
   doesn't match got no row and no label. Live repro: "feed in [A,B,C]
   AND title contains Chapter" labeled one feed of three (live-request
   evidence: 200 with total:1). A label is a property of the entity
   row, not of what currently matches.

   Fix: pages that render a tree resolve labels SERVER-SIDE at render
   and embed the map on the mount (data-id-labels) — the collection
   editor (stored tree) and the bookshelf (URL qf). New
   SearchService.FilterTreeLabels reads the entity tables with no
   media join and no tree constraint; scoping rides the request
   identity through the scoped handle (podcast_feeds/collections RLS —
   an invisible id resolves to nothing; pinned), libraries mirror the
   facet's visibility join. A feed with ZERO episodes resolves its
   title — pinned; that case was structurally impossible before.

2. The bookshelf drawer never hydrated at page load at all: the only
   load-time hydrate calls live in the PARENT's x-init
   (initBookshelf), and Alpine initializes parents before nested
   components — builderData() was null there, so hydrateFromForm
   no-oped and a URL-qf refresh left the drawer with an empty seeded
   rule while the grid stayed filtered. The drawer now hydrates in its
   OWN init when the mount carries no data-tree; the init-order
   contract is pinned in vitest (185/185).

The quiet suggest fetch remains only as a fallback for trees the SSR
never saw; embedded labels make it a no-op on refresh. Interactive
menus are untouched (their constrained, count-bearing behavior is
correct for choosing).

Pins: TestRulesPageEmbedsIdLabels (zero-episode feed labels at render,
bookshelf qf library label, hidden library NOT labeled);
filter/episode-tree/search-contract/RLS/playlist container slices
green; contract_pins entry documents the embed contract.
2026-11-01 10:44:48 -05:00
John O'Keefe c448fa438f fix(filter-builder): one operator name per field (Option A); drawer ids resolve on refresh
The is/in question, settled per user decision: they were exact
synonyms (feed's clause compiles is/is_not and in/not_in to the same
ANY), so the vocabulary collapses instead of being explained.

- Registry: feed's offered ops are in/isn't-in (+has/missing);
  legacyFieldOps keeps is/is_not ACCEPTED at parse forever — every
  stored tree and saved filter from before the collapse parses and
  evaluates unchanged. Library keeps is/isn't (its only names);
  collection's genuinely distinct operators are untouched.
- Client: id-kind cells are uniform — every operator on an id field
  accepts a set, so all id cells say "pick one or more" and accumulate
  picks. The per-operator placeholder/pick branching is gone (it also
  never fired: the registry types is as Values:"many").
- The drawer-refresh id fix rides along: hydrateFromForm now calls
  resolveIdLabels, so a page refresh with an active saved filter
  resolves stored ids to names in the bookshelf drawer (the collection
  rules page already did at init).

Pins: feed payload omits the synonyms while legacy feed is/is_not
trees parse (Go unit); uniform accumulate + placeholder (vitest);
menu-open regression and hydrate-resolution pins kept. Web 183/183;
services, filter + episode-tree + search/collections contract slices
green (full suite skipped per user — vocabulary + UI-layer change,
engine paths pinned by the targeted slices).
2026-11-01 01:16:20 -04:00
John O'Keefe 81e54f89b2 fix(collections): the order select re-orders the PAGE — visible order = play order
The detail header's listening-order select persisted fine and the
reader chain honored it, but the page's card list never applied
chain_order (it kept the composed query's title order) — so flipping
the select changed nothing visible, and Play as Sequence launches from
the FIRST CARD ON THE PAGE, making the played start (and the perceived
order) disagree with the curated setting (2026-11-01 user report on
the smart podcast page).

- Smart episode collections: the page's match list re-orders through
  OrderEpisodeMedia (the same OrderEpisodes comparator the sequence
  producer uses — §10 R2's single source of order truth) before card
  building. Books-scope matches keep title order.
- Static episode playlists: the sequence now honors the curated order
  too — native keeps the manual position order (SQL), reverse flips
  it, oldest/newest re-key on publication date with feed position as
  tiebreak. Previously statics ignored chain_order entirely while the
  select rendered on their pages.

Pins: native page order (feed positions) and the reverse flip
re-ordering the SSR cards, asserted in the curated-order subtest
alongside the existing reader-chain pins. Podcast/playlist/audiobook/
RLS/dashboard/contract slices green.
2026-11-01 00:49:04 -04:00
John O'Keefe 178b1f30c5 fix(filter-builder): menu opens to the whole list again; arity-aware cells
Regression from the id-label resolution (09f8f087): opening an id-kind
value menu carried the STORED ids as the typed filter, and the new
id-exact-match narrowed the menu to exactly the already-picked rows —
nothing else was visible, so "can't add more than one" (and clicking
the lone visible row unpicked it). The ids now ride as the typed
lookup ONLY in quiet mode (label resolution); a real menu open browses
(blank) or filters by the typed NAME draft.

The multi-value affordance is now ARITY-AWARE, per user request: the
op decides how many values the cell takes, and both the hint and the
pick behavior follow it — "in" says "pick one or more" and toggles
ids into the comma list; "is" says "pick one" and REPLACES the value.
Changing the operator updates the hint in place.

Pins: menu-open after a pick must not carry the stored ids (the
regression), one-arity pick replaces instead of toggling, and the
placeholder follows the op's arity. 38 filter-builder vitest cases;
web suite 183/183; container filter + episode-tree slices green.
2026-11-01 00:30:41 -04:00
John O'Keefe cce488b76f fix(filter-builder): multi-pick id values matched nothing; hint the multi boxes
Comma-joined multi-picks matched ZERO: clauseFeed/clauseLibrary/
clauseCollection parsed each rule value as ONE uuid, but the builder's
pick loop stores several picks as a single comma-joined value
(["id1,id2"]) — uuid.Parse failed on the joined string, the id list
came back empty, and "feeds in [A, B]" matched no episodes (one pick
worked, which is why the briefing recipe survived). parseUUIDValues
now splits every value on commas (trimming) before parsing, accepting
both the builder's joined shape and hand-written separate elements —
same leniency the suggest-side id lookup already has.

The multi-value cells now say so: id-kind value inputs get the
placeholder "pick one or more" (the only boxes on the page that accept
several values; every other cell replaces its value).

Pins: comma_joined_multi_feed_matches_both — a tree collection built
with the exact comma-joined wire the client sends, asserting BOTH
feeds' episodes match. Filter-builder vitest 35/35, container filter +
episode-tree slices green.
2026-11-01 00:20:31 -04:00
John O'Keefe 09f8f0875f fix(filter-builder): id labels survive Apply — stored ids resolve to names
The library cell reverted to the raw uuid after Apply: the apply flow
reloads the page, the SSR-preloaded tree re-ingests, and idLabels —
populated only by suggestion PICKS — starts empty, so valueCellText
falls back to the id. Same on any fresh load of a stored id-rule.

Two halves:

- Server: the id-source suggest query's typed filter (library/
  collection/added_by/feed) now also exact-matches a comma-separated
  ID list alongside the name ILIKE — so a suggest call with the stored
  ids returns their labeled rows. No new endpoint; the menu and the
  resolver share one surface.

- Client: fetchSuggestions gains a QUIET mode (fills idLabels, touches
  no menu state), id rows fall back to their stored ids as the typed
  lookup when no draft is present, and init() calls resolveIdLabels()
  after tree ingest — one quiet fetch per id-kind rule resolves every
  stored id to its name from the first paint after reload.

Pins: the Go side (suggest with a library's UUID returns the row with
the library NAME as display), the client side (resolveIdLabels fills
labels, the cell renders the name, no menu opens). 35 filter-builder
vitest cases green; container filter slice green.
2026-11-01 00:11:57 -04:00
John O'Keefe ed42111df1 fix(filter-builder): OR-aware suggest constraints (user correction)
The previous fix was too blunt: under OR it sent NO constraint tree at
all, so "library = ebooks" stopped narrowing the author menu the
moment an OR-connected author rule appeared. The user spec is subtler:

  library = ebooks AND (author = Lewis Carroll OR author = ?)
  → the menu shows EBOOK authors, with Lewis Carroll OFF the list.

suggestTree now walks root → edited rule and builds one conjunctive
chain:

- ALL-group siblings stay (AND narrows: the ebook scope persists);
- ANY-group siblings drop — they are independent alternatives under
  OR and must not narrow;
- EXCEPT a same-field ANY sibling, whose picked values NEGATE into
  the chain (contains → not_contains, is → is_not, in → not_in): the
  picked value leaves the menu, everything else in scope stays;
- an ALREADY-NEGATED sibling picks nothing to take off, so it drops
  rather than flipping positive (a positive flip would narrow);
- a field without a negation twin falls back to dropping;
- incomplete rules never constrain (not filters yet), and the edited
  rule itself never constrains its own menu.

Pins: the user's exact tree (ebook scope kept + Lewis Carroll negated
off), the root-OR variant (different-field sibling drops, same-field
negates), the twinless fallback, plus the existing AND/OR cases —
34 filter-builder vitest cases green, container filter slice green
(TestFilterSuggestionsUnderRules unaffected: the server contract is
unchanged, only which qf the client composes).
2026-10-31 23:48:04 -04:00
John O'Keefe c52b514036 chore: formatting sweep — vp fmt on drifted web files, templ regen
Pre-existing format drift in seven web/src files (blank-line and
indent normalization by vp fmt) plus templ generate normalization
across the generated _templ.go files; both swept up while committing
the filter-builder fix. No behavior changes.
2026-10-31 23:27:21 -04:00
John O'Keefe 28efaf3fe9 fix(filter-builder): id cells showed raw ids; OR mode starved the value menu
Two user-reported builder defects:

1. Id-kind value cells (library/collection/feed/added_by) rendered the
   stored UUIDs in the input box. The menu offered names (s.display)
   and picking stored the id correctly — but the cell bound
   rule.values.join(',') raw. The cell now renders through
   valueCellText: idLabels give the NAMES; typing drafts a name filter
   in idDrafts (the stored values stay ids — the clause matches ids —
   and are updated only by picking). Text/number/days cells are
   unchanged.

2. The value menu ignored the connective: fetchSuggestions always sent
   the sibling rules as the qf constraint, so with "Author contains
   Lewis Carroll" present, flipping to OR still starved the author
   menu down to Lewis Carroll. Under OR the siblings are independent
   alternatives — a new rule's candidates must not be narrowed by
   them. pathUnderAll now checks the edited rule's group path: under
   AND all the way up, siblings constrain as before (helpful
   narrowing); under any OR on the path, NO tree rides and the menu
   browses the whole scope.

Server contract unchanged — the suggest endpoint already treats a
missing qf as an unconstrained browse; the fix is client-side
mode-awareness. Pins: 6 new vitest cases (AND sends siblings, OR sends
none, id label display, draft typing never touches stored ids, pick
toggles + relabels, text passthrough).
2026-10-31 23:27:21 -04:00
John O'Keefe 7c7e282e79 feat(dashboard): custom-section builder saves again — the flagged tree migration
Archaeology, prompted by the user asking when it broke: the builder's
save NEVER worked. The page was born (2026-02-19, a92bf99a) posting
auto_assign_rules as a JSON-encoded STRING into an endpoint that has
bound []Rule since its creation (2026-01-31, 6495cc2c), plus fields
(manual_book_ids, match_type, library_id, show_on_dashboard) the
endpoint never accepted. The preview button hit a real endpoint, so
the page looked alive while Save always failed at request binding.
Verified live before this change: the page's exact payload 400'd
"invalid request" against the unmodified server.

The fix adapts the builder's payload at write time — the legacy rules
vocabulary converts to a filter tree (RulesToFilterTree restored from
the retirement commit as the single write path, unit test restored
with it):

- match_type seeds the converted tree's root mode (any default).
- manual_book_ids attach to the created collection after create
  (unparsable ids skipped — the AddBooks leniency).
- show_on_dashboard persists (new create field).
- Converted trees validate under ParseFilterTree: an unhonorable rule
  is a 400 at write naming the problem, never a stored 500 at render.

Dashboard sections: smart user sections now evaluate their tree LIVE —
GetUserCollectionItems merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed a REAL production wiring bug: every production
NewDashboardService call omitted the search service, so tree
evaluation through the dashboard could never have worked (the test
harness wired it correctly — the gap was production-only and latent,
because nothing in the dashboard evaluated trees before this
migration). The dashboard handler and both inline sites now receive
the search service, and evaluation failures log instead of silently
emptying a section.

Supersedes the same-day loud-400 stance for the builder's payload
(contract_pins migration entry); custom-section-builder.md documents
the save contract; the create_collection API doc gains the adapted
fields. New TestCustomSectionBuilderSave/BadRule pin the payload →
tree → dashboard-render path end to end. Full container suite green
(155s).
2026-10-31 19:40:43 -04:00
John O'Keefe a86bdac87e refactor(collections): retire the legacy rules surface — tree-only storage
The pre-tree rules machinery is deleted: no row ever carried a rules
array or a non-default match mode (verified in the dev DB), the web
never read the envelope keys, and the Android app consumes none of it.
Dead code gets no documentation.

Removed:
- PUT /api/collections/:id/match-mode — endpoint, route, and the
  UpdateCollectionMatchMode query. The tree's root mode owns any|all.
- auto_assign_rules / rule_match_mode from every envelope
  (create/list/detail/update), from the create/update request shapes,
  and from the schema: both columns DROPPED (schema.sql carries
  idempotent DROP COLUMN IF EXISTS heals for existing DBs; sqlc
  regenerated).
- The rules CRUD service layer (AddRule/SetRuleEnabled/RemoveRule +
  validateRule + UpdateCollectionRulesJSON), ParseRules /
  EnabledRules / IsSmartCollection, RulesToFilterTree (+ the SSR
  preload branch), the legacy branch of EvaluateSmartMembership, and
  SmartEvalOptions.MatchMode. IsCollectionSmart = tree presence;
  create-path isSmart = collection_type=smart OR a filter_tree.

Kept (live consumers): POST /api/collections/preview still evaluates
rules payloads in memory (the custom-section builder's preview), and
include_auto stays on the list (the Android app sends it).

The custom-section builder's SAVE was already broken — its
stringified auto_assign_rules payload failed request binding (probed
live: 400 invalid request). Create now rejects that payload loudly
with a 400 naming the retirement, instead of silently creating a
hollow static section; custom-section-builder.md carries the flag.
The builder's tree migration remains the flagged follow-up.

Test-side (asymmetry rule — deleted behavior, deleted tests):
TestPodcastSmartCollections rewritten onto trees (mode lives in the
tree root), match-mode subtests and tree_wins_over_legacy_rules
deleted, TestRulesToFilterTree deleted, contract-pin envelope keys
retracted per the contract_pins 2026-10-31 retirement entry. Mirrors
hand-synced: openapi.yaml, bruno create/update, docs/api/*.md,
docs/schema.go, api.d.ts. Full container suite green (155s).
2026-10-31 19:06:48 -04:00
John O'Keefe ab98015190 feat(collections): RLS ownership enforcement at the DB layer
collections + collection_items join the RLS-governed tables, same
doctrine as the media_items family lens (2026-10-29). Policies route
through two PURE predicate functions (app_can_read_collection /
app_can_write_collection) that never query collections — no policy
recursion under FORCE RLS — so a future sharing model is one EXISTS
branch inside the functions, not a schema re-touch. Reads: owner or
admin; writes: owner, admin escape for SYSTEM collections only.

Why: the write path had no app-level ownership gate on Update/Delete/
AddBooks — any authenticated user could mutate another user's
collection by id. The DB is now the enforcement of record; existing
app-level gates (match-mode, reorder, view-settings) stay as
defense-in-depth.

Behavior changes (contract_pins 2026-10-31):
- cross-user GET detail: 200 -> 404 (row invisible)
- cross-user PUT update: silent success -> 404 (ErrNoRows mapping;
  service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows)
- cross-user POST books: silent success -> 404
- cross-user DELETE: 204, now a true DB no-op
- anonymous sees nothing; admin persona unchanged

Bootstrap: registration seeds system collections BEFORE the JWT
exists — CreateDefaultCollectionsForUser declares the new user's
identity (rls.WithUser), the same fix mirrored in the test harness
(createDefaultCollectionsForUser owner-scoped; collection sweeps run
under the owner's identity).

Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end
(404 detail/update/add/view-settings, 204 no-op delete, admin escape,
owner positive control, registration seeding under RLS). Harness
fixture writes to non-system collections now run as the row's OWNER
(scopeExecAs/ownerSeedCtx) — the admin write-escape covers system
rows only, and bare-ctx pool reads of collections see nothing.
Filter/playlist/audiobook/scan-state fixtures converted. Full
container suite green (159s).
2026-10-31 18:30:02 -04:00
John O'Keefe aaf57a66a2 fix(ui): cookie-authed users' writes silently stranded behind bearer gates
THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.

Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.

Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.

Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
2026-10-31 17:02:44 -04:00
John O'Keefe 744d33e13f fix(search): keep the empty-qf 400 at the wire
The empty-tree grammar relaxation (create-modal episodes seed) must not
leak into the search deep-link contract: qf={"mode":"all","children":[]}
as a QUERY would silently mean "match everything". The seed's meaning —
a rules-less collection matching nothing — lives in the stored tree and
its composers; the query entry point now rejects a rule-less tree
explicitly, preserving the pinned 400 that TestFilterQFValidationAndPrecedence
guards (the test caught the leak during this round's full-suite run).
2026-10-31 15:56:23 -04:00
John O'Keefe d6619cd0e1 test(ui): the knob matrix — every control the round-4 defects lived behind
Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.

Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.

Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
2026-10-31 15:40:21 -04:00
John O'Keefe 8aa66a30fc docs(api): sync mirrors with the retired rules CRUD and the chain-order endpoint
Three mirrors had drifted:

- The rules-endpoint retirement (4f71eda6) deleted the routes but not
  their mirrors: removed docs/developer/api/collections/
  {add_auto_assign_rule,remove_auto_assign_rule,test_rule,update_rule}
  .md + their bruno twins, and pruned /api/collections/test-rules,
  /{id}/rules, /{id}/rules/{ruleId} from the generated openapi spec
  (hand-edited — regenerating with -update would wipe hand-authored
  bruno suites that predate the docs mirrors).
- New endpoint PUT /api/collections/:id/view-settings gets a doc page
  + schema-registry entry + bruno mirror (repo convention: the route
  and its docs land together).
- 54 pre-existing TestDocsMatchRoutes failures: the media-item
  endpoints emit the 16 episode/download fields (source_feed_id,
  feed_position, download_state, enclosure_url, hosts/guests, …) and
  libraries emit purpose/allow_member_feeds, but the doc examples
  were never extended — the package only runs on the host, so the
  container suite never caught it. Examples now carry every key the
  handlers emit; internal/docs is fully green.
2026-10-31 15:40:21 -04:00
John O'Keefe 4410a994ef fix(ui): silence the two benign cancelled-transition rejections
Round-4 console hygiene: fast navigation rejects with "AbortError:
Transition was skipped" (the browser's MPA view transitions —
@view-transition navigation:auto — when a navigation interrupts a
running crossfade) and Alpine's x-transition hide rejects with
{isFromCancelledTransition: true} when a re-show supersedes an
out-phase. Both are races the user cannot lose anything in, but they
print console errors on every quick click — and the knob matrix's
zero-console-error tripwire (the thing that catches silent CSP death)
depends on a clean console. A narrow global gate swallows exactly
these two shapes; every other rejection still reports.
2026-10-31 15:40:21 -04:00
John O'Keefe aa9bd950f9 fix(collections): gate the chain-order endpoint by ownership
PUT /api/collections/:id/view-settings writes the row; it now demands
UserOwnsCollection like its sibling config mutations (SetMatchMode,
ReorderItems) — otherwise any signed-in user could flip another
user's curated listening order. Discovered while documenting the
endpoint: the sibling pattern exists precisely because the main
PUT/DELETE routes lack it (their gap is noted for follow-up, not
expanded here).
2026-10-31 15:40:21 -04:00
John O'Keefe 79b47c9680 fix(collections): Close/Cancel removed the hidden confirm dialog, not the modal
closeCollectionModal picked document.querySelector(".fixed.inset-0") —
the FIRST overlay in DOM order, which is the sitewide #confirm-modal
that the header renders (hidden, display:none, but present). The real
create/edit modal stayed open: every Close and Cancel click on the
collection modals did nothing. Caught by the knob-matrix spec — the
pencil-glyph case clicked Close and the Edit modal persisted.

The buttons now pass $el and the overlay OWNING the button is closed
(closest(".fixed.inset-0")), falling back to #modal-container's child
for non-Alpine callers. Both the collection create/edit modal and the
restore-system modal take the argument.
2026-10-31 15:40:21 -04:00
John O'Keefe 21142cae5f chore(router): remove the CHAINDBG probe — the curated-order path is browser-verified
The debug print served its purpose: the stale-binary native-order render
was an artifact of an incomplete rebuild, not code. Fresh container
resolves view_settings.chain_order and the chain plays in the curated
order (newest-first verified on a 23-episode feed with a date tie at the
tail, handled by the position tie-break).
2026-10-31 14:08:54 -04:00
John O'Keefe fd716a7dab fix(filters): the episodes-scope vocabulary buried the episode fields under book-only metadata
The scope door's first live test showed the episodes editor leading its
field menu with People (Author/Narrator/Contributor) and Publishing —
book-only fields that can never match an episode row — while the fields
the scope exists for (Feed, Downloaded, Played) sat below the fold.
Root cause: the registry's book fields carry no Scopes restriction
(nil = all scopes), a leftover from before the episode fields existed.

35 unambiguous book-only fields (people, publishing metadata, topics,
series/comics, identifiers, cover, reading-specific statuses) are now
tagged Scopes:books. Title, description, published dates, language,
file facts, explicit, the my-reading group (listening progress is
real), and collection stay dual-scope. Parse continues to accept the
hidden fields everywhere (their composed clauses simply match nothing
on episode rows) — this governs the AUTHORING surface, not the
grammar.

TestFilterParseValidation's empty-root rejection updated to the new
contract (empty root legal — the seed; nested empty groups still
invalid), and TestEpisodesScopeHidesBookOnlyFields pins both scopes'
vocabulary.
2026-10-31 13:58:42 -04:00
John O'Keefe 536fb4794c fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field
The first real-browser create of an Episodes-scope collection failed
with "invalid request": the modal's hx-post submits
application/x-www-form-urlencoded, and the form value for filter_tree
had to bind into CreateCollectionRequest.FilterTree (any) — echo's
form binder errors on any-typed fields instead of setting the string,
so every form carrying a tree 400'd before validation even ran. JSON
bodies (the save-filter bridge, the editor's Apply) were unaffected,
which is why no API-level or Go test caught it: only a browser posting
the real form shape exercised the transport.

Fix: the form variant binds a dedicated string field
(FilterTreeForm, form:"filter_tree") on both request structs while
FilterTree keeps the JSON tag; resolveTreePayload picks whichever
transport arrived and both ride the same parseRequestFilterTree
validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded
seed → 201 → scope round-trips).
2026-10-31 13:52:40 -04:00
John O'Keefe cc0e9d7f91 feat(podcasts): the episodes authoring door + curated chain order
"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.

The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
  (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
  means "match books by rules". Choosing Episodes seeds an EMPTY
  episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
  collection is born episode-scoped: the editor opens with the episode
  vocabulary (feed/downloaded/played/published date) and membership
  runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
  invalid) — the seed's legal shape. The root arrives at depth 1 by
  call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
  NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
  EvaluateSmartCollection's zero-enabled-rules convention. Without the
  guard the composed query degrades to the bare scope head and a fresh
  collection would swallow every episode in scope — pinned by
  TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
  and TestEmptyRootTreeSeedsEpisodesScope.

The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
  stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
  stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
  handlers.CollectionChainOrder; the detail header's Play-as-Sequence
  gains the order select next to it (PlayCollectionButton), which
  PUTs and reloads. Pinned by chain_follows_the_curated_order
  (reverse flips the chain indexes and hops).

Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.

Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
2026-10-31 13:39:24 -04:00
John O'Keefe d5dbaa48ea fix(ui): user-test round 4 — five browser-reported control defects
Every one of these was silent at the code level and loud in a real
browser; each fix carries its own tripwire (render pin, unit pin, or
CSP-safe rewrite) so the class cannot quietly return.

1. Theme picker dead since the CSP migration (eb57b720): the Appearance
   panel's theme buttons emitted `@click="selectTheme(opt.Name)"` as
   LITERAL text (templ gotcha #1 — quoted attribute values are never
   interpolated; `opt` is a Go loop var), so Alpine threw "Undefined
   variable: opt" on every click and no PUT /api/auth/theme ever fired.
   The wood-paneling sibling always used the correct expression form,
   which is why the panel half-worked. Fixed to the interpolated form;
   TestThemePickerHandlersAreInterpolated pins that real theme names
   reach the HTML and the raw identifier never does.

2. Filter-builder op dropdown "does nothing": the field ingest
   hardcoded `ops: []` into every field's meta while only mirroring the
   ops into the global label registry — opsFor() returned [] for EVERY
   field, so the op menu rendered zero items: an invisible menu, no
   console error (user tried author/publisher/title/subtitle, all
   equally dead). FieldMeta.ops is now the wire's op objects, opsFor()
   is a standalone exported helper, and opsForField unit tests pin the
   round-trip. Verified live: the field menu opens/closes while the op
   menu stayed shut, both with identical wiring — the difference was
   the data, not the wiring.

3. AND/OR connective chip dead: the chip carried an `if` STATEMENT in
   the Alpine attribute (`@click="if (row.chipClickable) ..."`) which
   the CSP build's evaluator rejects as a statement — the whole
   expression silently never ran. Moved the clickability check into a
   toggleConnective(row) component method; the attribute is now a plain
   method call.

4. Pencil vs card navigation race: navigateToCollection guarded with
   `event.target instanceof HTMLElement`, which is FALSE for the
   pencil glyph's SVGElement — a click landing on the glyph sailed past
   the guard and navigated while the htmx edit-modal request still
   fired (the reported "modal flashes, then the builder page"). The
   guard now uses target.closest("button") (works on SVGElement) and
   takes $event explicitly instead of the deprecated window.event.
   .icon-btn is already a 36px square, so no hit-area change needed.

5. Add Items modal under the sidebar + taller than the window: the
   picker overlay sat at z-50 UNDER the app-sidebar's z-60 (its left
   edge rendered beneath the nav), and the card had no height bound —
   header + tabs + filters + grid + footer overflow short windows with
   the footer unreachable (fixed overlay, no page scroll). The picker
   and create-modal overlays move to z-[65], both confirm dialogs to
   z-[70], and the picker card is now a flex column capped at
   max-h-[85vh] whose grids flex-fill (they grow on tall windows,
   shrink on short ones). The ladder is documented at .app-sidebar in
   input.css: sidebar 60 / overlays 65 / confirm 70 / toasts 9999.

Found by clicking every control the way a user does (panel clicks +
computed-style reads + screenshots) after the previous sweep trusted
DOM-text snapshots, which show hidden menus' text and cannot see
occlusion, stacking, or geometry at all.
2026-10-31 13:14:05 -04:00
John O'Keefe 8aec80cbb7 docs(ui): record the 2026-10-30 browser sweep against coverage notes
Marks the sweep-candidate list as executed (every knob driven in a real
browser), preserves the four defect classes the sweep fixed so the next
agent does not re-learn them the hard way, and pins the panel-vs-
Playwright limitation (value assignment without change events) that any
future automated spec of the htmx-triggered controls must work around.
2026-10-30 20:51:26 -04:00
John O'Keefe 94985adb5e fix(collections): browser-verification pass over the unified builder/editor
The real-browser sweep (the gotcha classes' native habitat — CSP refusals
fail SILENTLY at build/test time) found four defects, all in the item
3.3/3.4 surfaces:

- Shared builder rows used Alpine attribute expressions that the CSP
  parser rejects outright: backtick template literals in :style, the
  ?. optional chain in :placeholder/x-if, and an if-STATEMENT in a
  @click.outside. Rewritten as string concat plus two component methods
  (fieldIsDays, dismissSuggestions). Until this fix every drawer open
  threw a wall of "CSP Parser Error" console noise and the days-value
  affordance never rendered.
- "2 bookss match": the count noun is interpolated into "<n> <noun>s
  match", so callers must pass the SINGULAR ("book"/"item").
- The rules editor page received its fields/tree payloads from the
  route as already-JSON-encoded strings and wrapped them in JSONArg
  AGAIN — double-encoded, so JSON.parse yields a string and the editor
  loaded with NO vocabulary and NO prefill. The route now emits raw
  JSON into the data attributes (single encode).
- Apply was silently inert: the hook store round-trip used
  window.Alpine?.store (optional-chained, no-op when absent) and the
  Cancel/Apply block sat outside the builder island scope for part of
  the session. Both fixed via the imported Alpine instance and moving
  the buttons inside the island; Apply now PUTs the tree end-to-end
  (verified live: prefill → edit → apply → 201 → reload → membership).

Browser-verified clean (no fixes needed): picker scope tabs + episode
grid (?scope=episodes door), selection store across swaps, Add Selected
episode membership, library-switcher partial contract (episodes visible
under All Libraries, correctly filtered out under a book library — and
now never OFFERED as a switcher option), episode cards + reorder arrows
persisting position, collection-chain player (Ep N/M, prev/next carry
?queue=collection:, order control absent in collection mode and present
+ persistent in feed mode both in player and feed page), and the
empty-tree Apply guard (400 — a static collection can never be
promoted by accident).

Notes for the next reader of these lines: the OpenChamber panel's
browser.select sets select.value without dispatching change, so htmx
change-triggers must be exercised by real events (Playwright) rather
than the panel; and one 500 was observed on the player's leave-page
progress PUT — a request whose context the navigating browser cancels
mid-write, pre-existing and benign (the immediately following save
returned 200).
2026-10-30 20:50:55 -04:00
John O'Keefe 7bf5697c71 fix(podcasts): library switcher leaked literal "continue" and the podcasts option
The D13 podcast exclusion (9a148c23) was written as a templ
`if !IsBookLibrary(...) { continue }` — but templ markup control flow
has NO continue STATEMENT: codegen emitted the word as a TEXT node
(visible junk between options on every page's library subbar) and
rendered the podcasts option anyway, defeating the exclusion. The real
browser sweep caught what every Go/API test structurally cannot:
generated markup that renders wrong yet compiles clean.

Rewritten as an inverted guard (the idiomatic templ skip). New render
pin TestLibrarySwitcherExcludesPodcasts fails on both halves of the
original leak — the literal text AND the presence of the podcasts
option — so this codegen gotcha class stays tripped-wired.
2026-10-30 20:50:38 -04:00
John O'Keefe c54430c254 fix(podcasts): code-review pass over the session's UI surfaces
Findings from the systematic gotcha-class review before the browser
pass:

- The collection smart editor's Apply derived the collection NAME from
  document.title — which parses to "Smart Collection", RENAMING the
  collection on every apply. The server now renders the real name
  (data-collection-name) and the write path refuses to proceed without
  it.
- TestRenderingPartialsRender pinned the pre-episode-collections
  empty-state copy ("No books in this collection yet.") — updated to
  the shipped label ("Nothing in this collection yet."; collections
  hold episodes now).
- The deleted handler-local rule comparators (evaluateRule/
  compareValues — removed in item 3.4's single-evaluator refactor)
  left behind two unit-test files testing the duplicates; deleted.
  Their semantics remain covered at the service level
  (TestEvaluateRules_* over FieldMatches/EvaluateRules) and
  TestTextToString (which actually pins utils.PgTextString) stays.

Everything else reviewed clean against the gotcha classes: templ
whole-expression attrs, ?={ boolean attrs, JSONArg quoting in
x-init/data attrs, Alpine CSP store paths, htmx form-encoding, the
&#34; assert escaping, and the aria-label selector contract on the
player's reload listener.
2026-10-30 19:38:39 -04:00
John O'Keefe 94bd69d22d feat(filters): save current filter as smart collection — the bridge (item 3.5)
The bookshelf's save modal grows a second action: "Save as Collection"
POSTs the current (completed, scope-stamped) tree to
/api/collections — the same validated write path every tree
collection uses — and lands you on the new collection's page. The qf
engine then drives it everywhere: the shelf's live shelf-count, the
collections page, the replay chain for episode trees. The legacy
"Save Filter" (bookmark the wire params) stays as the sibling action.

This is the plan's bridge: a book search graduated into a live shelf
with one click, authored in the ONE filter system.
2026-10-30 19:25:02 -04:00
John O'Keefe 4f71eda6be feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)
The smart-collection editor at /collections/:id/rules now hosts the
SAME builder island as All-Books (FilterBuilderRows — the rows markup
extracted into one shared templ both mounts render; the shared
filterBuilderExtra component). One vocabulary, one engine, one row UI;
the collection differences are exactly three: the payload is the
collection's scope (episode trees get the episode fields — books
trees hide them), Apply writes the tree to the collection (PUT
filter_tree through the validated write path), and a legacy rules
array preloads as its CONVERTED tree (RulesToFilterTree rendered
server-side) so the upgrade lands at the first Apply — read-compat
never mutates data silently.

Retired with the page: the per-rule CRUD endpoints and test-rules
(their handler bodies, routes, api-docs rows, and the dead collections.ts
island functions — the file's LIVE halves, the collection modal/navigate/
grid-refresh, stay; the dead rules half went). match-mode endpoint and
preview survive (legacy channel + the dashboard custom-section-builder,
flagged for its own migration). Contract-pin changelog documents the
deletions per the asymmetry rule.

The builder component gains collection mounts: scopeKey (the row
universe for counts/apply), data-tree prefill, treeJSON (the apply
wire with scope riding the top level), and an apply hook (the page
owns the write; All-Books never sets one). ensureReadyRule defaults to
the scope's first field instead of hardcoding author.

Tests migrated onto trees: TestSmartCollections' live-membership /
reject-mutations / include-auto / system-collection subtests now drive
TREE collections; the deleted-endpoint subtests die with them.
2026-10-30 19:22:17 -04:00
John O'Keefe eb68672390 feat(filters): one vocabulary, server-rendered — the TS mirror retires (item 3.3)
"One filter system to maintain" now holds at the presentation layer
too: the field/op list the builder renders comes from
services.FilterFieldsPayload(scope) — the Go registry's wire face,
scope-filtered and group-ordered — rendered into the bookshelf mount
as a data attribute (templates.JSONArg), ingested once by the Alpine
component. The registry carries operator presentation (labels,
negation, arity) in FilterOpWire alongside the grammar; per-field
value-cell semantics (valueKind) derive from the existing Kind.

The hardcoded TS mirror (OPS, op(), F(), TEXT_OPS/NUM_OPS/DATE_OPS,
FIELD_GROUPS, its known drift surface) is deleted; filter_builder.ts
now ingests the wire (ingestFilterFields — malformed payloads leave
empty menus rather than client-invented rows), and the field menu's
episode fields are HIDDEN on the All-Books page for free (the books
scope payload excludes them — the plan's "hide podcasts from the
rules on the all books page" is now structural, not enforced by a
second hand-kept list).

Unit tests ingest a minimal wire fixture (the payload shape is the
contract, not the mirror); vitest 167 green, tsc + vp clean.
2026-10-30 19:07:45 -04:00
John O'Keefe 6b2125a45a feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
collections gains filter_tree JSONB (idempotent in-place; same qf DSL
as saved filters — the scope key included). IsCollectionSmart is the
ONE detection (tree OR legacy rules), and EvaluateSmartMembership is
the ONE evaluator: tree-bearing collections run the composed engine
via ComposedLiveMedia (full media rows, so the collection surfaces
keep their episode-card stamping, feed positions for the replay
chain, and the family lens rides the mandatory head), while legacy
rule arrays keep per-item FieldMatches until rewritten — one call
surface, two storage epochs. All smart surfaces (detail JSON, counts,
detail page, reader chain) dispatch through it; the search service is
injected into the collection subsystem (variadic — main.go builds one
shared instance; the test harness mirrors it).

Write path: CreateCollection/UpdateCollection accept the tree,
validated against the engine grammar at the HANDLER (an invalid tree
is a 400, never a stored render-time 500) and exposed in the envelope
(pin widened in TestContractPin_CollectionsJSON + changelog). Update
semantics: an edit form that doesn't send a tree keeps the stored one.

RulesToFilterTree converts legacy rules + match mode at upgrade time:
episode rules map mechanically (feed→in-list, played/downloaded→
booleans, published_within→published_date+within_days), tree root
mode absorbs rule_match_mode, episode rules force the episodes scope,
and legacy starts_with/ends_with fall back to contains (documented).

Read-compat by design: nothing is migrated at boot — legacy arrays
evaluate exactly as before until the collection is rewritten.

Pinned by TestRulesToFilterTree (unit conversion) +
TestEpisodeCollectionsFilterTree (a tree collection evaluates through
the engine, the chain follows the tree's live membership, invalid
trees 400 at write, the tree wins when legacy rules coexist, and an
edit without a tree keeps it).
2026-10-30 19:02:49 -04:00
John O'Keefe 56871b65ed feat(filters): scopes + episode fields — the engine becomes the single query system (item 3.1)
The qf tree gains an optional top-level `scope` ("books" default |
"episodes" — absent = books, so every existing saved tree and legacy
client stays valid). The row head is scope-aware AND this closes a
real leak: the composed engine's head had NO podcasts exclusion, so
episodes already surfaced on All-Books qf queries (plan §9 violation —
the sqlc flat head excluded them, the composed head didn't). Books
scope now says library_type_name <> 'podcasts'; episodes scope is
source = 'podcast_episode'.

The registry fields declare their scopes (the plan's "reusable
template with conditionals" = scope-driven rendering in 3.3): episode
fields declare exactly the episodes scope and the books scope REJECTS
them at parse (the "hide podcasts from the rules on the all books
page" rule enforced server-side); books fields inside episodes trees
are allowed and honestly no-op on NULLs.

New fields (the item-2.4 rule vocabulary expressed in SQL):
- feed — KindIdentifier over podcast_feeds (RLS-governed suggestion
  source and match; unsubscribed feeds match nothing, by the engine)
- downloaded — is_true/is_false on download_state = 'downloaded'
- played — THE canonical finished predicate (viewer progress >= 0.999,
  the hide_finished definition shared with item 2.4), reading_progress
  join per viewer
- published_date — clauseDateFactory like added_at, within_days
  default op (the published_within rule's SQL form)

Engine hygiene: the stray ComposedSearch SQL debug printf is gone.

Pinned by TestFilterScopeValidation (unit: scope grammar, books×
episode-field 400s, legacy no-scope trees) and
TestFilterScopesAndEpisodeFields (integration: books head excludes
podcasts including the 404-no-results contract, episodes head is
episodes only, feed-in/downloaded/played/published-date evaluate in
SQL against a subscribed-feed fixture with a played viewer).
2026-10-30 18:19:55 -04:00
John O'Keefe bc91ce68d4 test(pins): widen whitelist for the episode-collections JSON shapes (item 2 series)
TestContractPin_CollectionsJSON tripped on the additive keys the
episode-collections work introduced — the tripwire worked exactly as
designed (additive keys must be whitelisted here AND in
docs/contract_pins.md first).

- Collections envelope gains rule_match_mode (item 2.4): the
  smart-collection match semantics on GET /api/collections/:id.
- BookInfo gains four STABLE keys (item 2.3): is_episode,
  feed_title, download_state, hidden — dropped omitempty so every
  card surface emits the same key set (the viewer_percentage
  precedent; adapters that predate episode awareness emit zero
  values).

docs/contract_pins.md changelog entry written. Nothing removed,
nothing renamed.
2026-10-30 17:25:22 -04:00
John O'Keefe 962f9089e0 chore(db): querier interface picks up the UpdateCollectionViewSettings comment — generated side of cc56b9ef 2026-10-30 17:12:29 -04:00
John O'Keefe 69773ec307 feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)
ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:

- The chain builder only read static membership (collection_items) — a
  smart collection has none. buildSmartCollectionSequenceContext now
  evaluates the rules LIVE for the requesting user (mode from the
  collection, family lens via the visible set) and chains the
  downloaded episode matches in NATIVE feed order — the collection's
  computed order is content (R1: never written back; OrderEpisodes is
  the order chokepoint per R3). A match that aged out of the briefing
  (played since, published beyond the window) falls back to the feed
  queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
  matches are now flagged (IsEpisode + download state) so they carry
  the play affordance and the header's Play-as-Sequence button;
  feed titles stay empty on smart cards for now (batch lookup is
  polish, not acceptance).

Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
2026-10-30 17:12:07 -04:00
John O'Keefe cc56b9ef2a fix(collections): UpdateCollectionViewSettings wrote a nonexistent updated_at column
The query said `SET view_settings = $2, updated_at = NOW()` but the
collections table has never had an updated_at column — every call
failed with 42703. The one caller (media_scanner's series-import
provenance write) discards the error, so the failure was silent:
imported-from-series view settings never persisted. Surfaced while
writing the sibling UpdateCollectionMatchMode query for item 2.4 (the
item-2.4 commit message prematurely claimed this fix — this is that
fix, for real).
2026-10-30 16:55:16 -04:00
John O'Keefe abb7c84833 feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)
The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.

Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.

Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.

Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).

Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
2026-10-30 16:54:36 -04:00
John O'Keefe f68fb19afb test(ui): COVERAGE.md — episode-collection sweep candidates (item 2.3) 2026-10-30 16:18:47 -04:00
John O'Keefe adb93cba9e feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
D11 lifts: podcast episodes (media_items rows) join collections, and
"Play collection as sequence" feeds the player from the collection's
order. No new tables — collection_items already carries ordered
membership (item 2.1 audit).

PICKER: the collection modal gains a Books/Episodes tab pair. The
episodes tab hits /api/media-items/search?scope=episodes — a dedicated
picker query (SearchEpisodesForPicker) whose visibility is entirely
RLS: the media_items subscription predicate drops unsubscribed feeds'
episodes, and the podcast_feeds join is governed the same way. The
books scope keeps its podcasts exclusion (plan §9) — this scope is the
only door in. Pending episodes are pickable (plan ahead; the detail
page shows them as not downloaded). Selection rides the existing
bookPicker store — ids never collide across scopes, and AddBooks works
unchanged on episode ids.

DETAIL: the All-Libraries membership view switches to
GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An
unsubscribed episode (or family-lens hidden book) survives as a
Hidden card rendered as an "Unavailable — remove entry" hint row
instead of silently vanishing; visible episodes render feed title,
download state, and a Play link. The library-scoped view keeps its
existing dashboard-query behavior (the switcher's contract is
untouched for books).

CHAIN: ?queue=collection:<id> on any reader URL makes
buildPodcastSequenceContext produce the COLLECTION context (§10 R2
same shape, source=collection): downloaded episodes in collection
order (positions are content — R1; no feed-order override), prev/next
URLs carrying the queue param so the chain survives hops, and
backToDetail landing on the collection page. Ownership gates the chain
— foreign/deleted ids fall back to the feed queue; the order control
stays hidden (2.2's source gate). The header's "Play as Sequence"
button starts from the first downloaded episode in list order.

Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped
picker + family-lens auditor case (subscribed episode in a hidden
library must not reach the picker), books-scope exclusion intact,
hint rows, chain carrying the queue param, start-from-first, foreign
fallback, and reorder driving the chain (the R4 primitive works on
episode ids unchanged).
2026-10-30 16:18:29 -04:00
John O'Keefe a90e7debfd feat(podcasts): queue context source + player order control — item 2.2
Foundation rule R2 (§10): the queue context is the only queue, and
every producer emits the same shape. PodcastSequenceContext gains
source (feed|collection), feed_id, and order; the feed producer sets
them now — the collection producer (2.3) slots into the same shape
without the player ever special-casing beyond showing/hiding controls.

The player header gains the FEED-mode order control next to the
"Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a
one-click reverse button. Both PUT the existing per-user feed order
override — the SAME state the feed page writes, so flipping it in the
player updates the feed page's dropdown too. The control renders only
when source=feed: a collection queue's order is curated content
(R1), never a listening preference. Behavior is hx-put +
hx-swap="none" (response discarded) with a module-level
htmx:afterRequest listener in audio.ts reloading the page — the
server re-renders prev/next/index in the new order; templ decides
what it looks like, JS only triggers the round trip (CSP-clean, no
inline handlers).

Pinned by TestPodcastPlayerOrderControl: the config JSON carries
source/feed_id/order/episode_index; the control renders feed-mode;
an order override re-renders with the dropdown reflecting it (data-
json pins must match templ's &#34; attribute escaping).
2026-10-30 15:52:14 -04:00
John O'Keefe 7bf0b1257d feat(podcasts): widen reader_settings key ceiling — item 2.1 foundation
reader_settings.setting_key was VARCHAR(50), and the composite
podcast_order:<uuid> key already sits at exactly 50 chars — zero
headroom. The deferred queue-editor idea (plan §0 item 2b) and any
future per-feed/per-collection preference key would have been forced
to payload-stuff inside the order row by a column width. Composite
prefix:<uuid> keys are the norm now (§10 R5), so the ceiling widens
to VARCHAR(120): idempotent in-place ALTER (varchar widening is
metadata-only, no table rewrite), re-runs harmlessly on every boot
per the schema convention.

No new episode-membership tables: collection_items already carries
ordered playlist membership (position, A2c) against media_items,
which podcast episodes ARE — episodes join collections through the
existing table.

Pinned by TestReaderSettingsCompositeKeyHeadroom: a 57-char composite
key (the podcast_custom_order:<uuid> shape) upserts and reads back.
Under the old width this fails; sqlc codegen is byte-identical.
2026-10-30 15:29:34 -04:00
204 changed files with 12323 additions and 6239 deletions

No files matched your search

+1
View File
@@ -87,3 +87,4 @@ session-*.md
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
test-results
.openchamber/
+11
View File
@@ -145,6 +145,17 @@ every user sees every library unless an admin hides one) is enforced by
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
gates may answer 404 as well as 403 (row invisible vs Go-level
check — contract_pins 2026-10-29).
- **Collections RLS (2026-10-31)**: `collections` and
`collection_items` are governed too — predicates
`app_can_read_collection` / `app_can_write_collection`, PURE over
their arguments (never query `collections`, so no policy recursion
under FORCE RLS). Reads: owner-or-admin; writes: owner, admin escape
for SYSTEM collections only. Future sharing = one EXISTS branch
inside the predicate functions, no policy migration. Registration
seeding declares the new user's identity (`rls.WithUser` in
`CreateDefaultCollectionsForUser` — the JWT does not exist yet);
test-harness writes to non-system collections must run as the row's
OWNER (`scopeExecAs`/`ownerSeedCtx`, not the admin handle).
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
+9 -1
View File
@@ -1,6 +1,14 @@
# Screenshot Automation Plan for Bookhoard Documentation
> **Status**: Ready to implement when frontend is complete
> **Status**: REJECTED / ARCHIVED (2026-11-02) — not implementing.
> **Superseded by**: [docs/developer/writing-ui-docs.md](docs/developer/writing-ui-docs.md) —
> Bookhoard docs are written screenshot-free from now on. Why: screenshots go
> stale silently (no gate can diff pixels against the UI), die on every
> redesign, and are unverifiable; the UI is server-rendered so exact labels are
> greppable and gate-checked instead (`TestUIControlLabels`). The prose
> standard teaches patterns that survive re-layouts. Kept below unmodified for
> history.
>
> **Last Updated**: 2026-02-03
This document outlines the complete plan for automatically generating screenshots for Bookhoard documentation using Playwright.
@@ -1,41 +0,0 @@
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
-1
View File
@@ -16,7 +16,6 @@ http:
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
settings:
@@ -1,31 +0,0 @@
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
+29
View File
@@ -0,0 +1,29 @@
info:
name: Set Collection Chain Order
type: http
seq: 13
http:
method: PUT
url: '{{base_url}}/api/collections/{{collection_id}}/view-settings'
auth: inherit
body:
type: json
data: |-
{
"chain_order": "newest"
}
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs:
content: |-
# Set Collection Chain Order
`PUT /api/collections/:id/view-settings` — auth: jwt.
Canonical reference: docs/developer/api/collections/set_chain_order.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
@@ -1,48 +0,0 @@
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
-1
View File
@@ -15,7 +15,6 @@ http:
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
-29
View File
@@ -1,29 +0,0 @@
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
+7 -3
View File
@@ -170,9 +170,13 @@ func main() {
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
opdsHandler.SetSettings(registry)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
// One composed-engine service shared by search AND the collection
// subsystem (item 3.2): tree-driven smart collections evaluate
// through the unified engine.
collectionSearchService := services.NewSearchService(queries, scopedDB)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
+4 -2
View File
@@ -659,12 +659,14 @@ func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped read: collections RLS (2026-10-31) hides rows from
// the pool session's SeedIdentity.
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"bytes"
"testing"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Collections RLS (2026-10-31): ownership enforcement moved to the DB
// layer (p_collections_* + parent-checked collection_items policies in
// schema.sql, predicates app_can_read/write_collection). The API surface
// answers invisibility with 404 — including the write endpoints that
// had NO app-level gate (Update/AddBooks, the flagged gap). Admin keeps
// the escape; system collections stay per-user; registration still
// seeds them (the seeding identity fix in
// CreateDefaultCollectionsForUser is regression-pinned here).
func TestCollectionsRLS(t *testing.T) {
setup := setupTestServer(t)
ownerToken := setup.RegularToken // user A: the harness regular user
adminToken := setup.Token
marker := fmt.Sprintf("rls%d", time.Now().UnixNano()%100000)
// User B: a second regular user via registration. The register
// endpoint is rate-limited — exactly one call. The response's
// access_token is B's identity for every probe below.
regEmail := fmt.Sprintf("%s@tests.bookhoard.internal", marker)
regBody := fmt.Sprintf(
`{"email":%q,"username":%q,"password":"Test@Pass123!"}`,
regEmail, "rlsb"+marker)
regResp, err := http.Post(setup.Server.URL+"/api/auth/register",
"application/json", bytes.NewReader([]byte(regBody)))
require.NoError(t, err)
require.Equal(t, http.StatusCreated, regResp.StatusCode,
"registration must succeed — including the system-collection seeding that now runs under the new user's RLS identity")
var reg struct {
Token string `json:"access_token"`
}
require.NoError(t, json.NewDecoder(regResp.Body).Decode(&reg))
require.NotEmpty(t, reg.Token)
otherToken := reg.Token
_ = regResp.Body.Close()
// B's registration seeded B's OWN system collections — visible in
// B's list (this is the registration-seeding identity fix, live).
code, body := podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "Continue Reading",
"registration seeded the new user's system collections under RLS")
// A creates the target collection.
code, body = podcastAuthed(t, setup, ownerToken, "POST", "/api/collections",
`{"name":"test RLS Owner ` + marker + `"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, ownerToken, "DELETE", "/api/collections/"+coll.ID, "")
})
// ── B (non-owner) probes A's collection: invisible = 404 ──
code, _ = podcastAuthed(t, setup, otherToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusNotFound, code, "detail-by-id is RLS-invisible cross-user")
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
assert.NotContains(t, string(body), "test RLS Owner "+marker,
"the list query stays user-scoped")
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+coll.ID,
`{"name":"renamed by B"}`)
require.Equal(t, http.StatusNotFound, code,
"update of an RLS-invisible row answers 404 (was: ungated success): %s", string(body))
code, body = podcastAuthed(t, setup, otherToken, "POST", "/api/collections/"+coll.ID+"/books",
`{"book_ids":[]}`)
require.Equal(t, http.StatusNotFound, code,
"AddBooks on an RLS-invisible collection answers 404 (the flagged ungated-write gap, closed at the DB)")
code, body = podcastAuthed(t, setup, otherToken, "PUT",
"/api/collections/"+coll.ID+"/view-settings", `{"chain_order":"newest"}`)
require.Equal(t, http.StatusNotFound, code,
"chain-order write on an invisible collection answers 404")
code, _ = podcastAuthed(t, setup, otherToken, "DELETE", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusNoContent, code,
"DELETE touches no rows under RLS (silent no-op — no destructive action)")
// The no-op delete lied about nothing: A still owns the collection.
code, body = podcastAuthed(t, setup, ownerToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code, "owner retains the collection after B's failed delete")
// ── admin escape: sees everything, as with media ──
code, _ = podcastAuthed(t, setup, adminToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code, "admin persona sees collections (policy escape)")
// ── B's ownership works: B renames B's OWN system collection ──
// (positive control — B is not locked out of what B owns)
code, body = podcastAuthed(t, setup, otherToken, "GET", "/api/collections", "")
require.Equal(t, http.StatusOK, code)
var list struct {
Collections []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"collections"`
}
require.NoError(t, json.Unmarshal(body, &list))
var bSystem string
for _, c := range list.Collections {
if c.Name == "Continue Reading" {
bSystem = c.ID
break
}
}
require.NotEmpty(t, bSystem, "B owns the seeded system collections")
code, body = podcastAuthed(t, setup, otherToken, "PUT", "/api/collections/"+bSystem,
`{"name":"Continue Reading"}`)
require.Contains(t, []int{http.StatusOK, http.StatusCreated}, code,
"owner mutation of an OWN collection still works: %s", string(body))
}
+95 -140
View File
@@ -56,36 +56,6 @@ func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionI
return result
}
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
t.Helper()
payload := map[string]interface{}{
"field": field,
"operator": operator,
"value": value,
}
if enabled != nil {
payload["enabled"] = *enabled
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
return resp
}
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
t.Helper()
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
rule := result["rule"].(map[string]interface{})
return rule["id"].(string)
}
// Data helpers -----------------------------------------------------------
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
t.Helper()
ctx := context.Background()
@@ -145,81 +115,83 @@ func TestSmartCollections(t *testing.T) {
setup := setupTestServer(t)
adminID := getTestUserID(t, setup.DB)
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
t.Run("TreeCollectionLiveMembership", func(t *testing.T) {
// The unified engine (item 3.2): a collection created WITH a
// books-scope tree evaluates LIVE — stored links are ignored,
// only tree matches render.
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart SF Book One": "Science Fiction",
"Smart SF Book Two": "Science Fiction",
"Smart FANT Book": "Fantasy",
})
_ = bookIDs
// Rule evaluation ignores stored links: a static collection's own
// added item must NOT show up once the collection is smart.
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
// Add one hand-picked book (a Fantasy one that the smart rule
// will not match — so it must vanish from the render).
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Tree " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"])
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+created.ID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
memberTitles := smartLibraryTitleSet(t, smartDetail)
detail := smartGetCollection(t, setup, setup.Token, created.ID)
assert.Equal(t, true, detail["is_smart"], "tree presence derives the type")
memberTitles := smartLibraryTitleSet(t, detail)
assert.True(t, memberTitles["Smart SF Book One"])
assert.True(t, memberTitles["Smart SF Book Two"])
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
})
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Diss Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
// Disable: membership drops to zero at once.
falseFlag := false
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
dReq.Header.Set("Content-Type", "application/json")
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
dResp, err := http.DefaultClient.Do(dReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, dResp.StatusCode)
_ = dResp.Body.Close()
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
assert.False(t, memberTitles["Smart FANT Book"], "the tree's AND governs membership live")
})
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Guard Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart Guard " + uuid.NewString()[:8],
"filter_tree": tree,
})
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
_ = resp.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
@@ -238,53 +210,10 @@ func TestSmartCollections(t *testing.T) {
_ = removeResp.Body.Close()
})
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart Back Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
ruleID := smartDecodeRuleID(t, resp)
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, true, smartDetail["is_smart"])
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
delResp, err := http.DefaultClient.Do(delReq)
require.NoError(t, err)
require.Equal(t, http.StatusOK, delResp.StatusCode)
_ = delResp.Body.Close()
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
// Membership mutations work again on the reverted static.
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
addReq.Header.Set("Content-Type", "application/json")
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
addResp, err := http.DefaultClient.Do(addReq)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
_ = addResp.Body.Close()
})
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
_ = resp.Body.Close()
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
// System collections may never carry rules.
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
t.Run("SystemCollectionsBlockedFromTheEditor", func(t *testing.T) {
// System collections may never carry rules/trees — the editor
// route refuses them (item 3.4: the page IS the editor).
colls, err := setup.DB.GetCollectionsByUser(ownerSeedCtx(pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
require.NoError(t, err)
var systemID string
for _, c := range colls {
@@ -295,20 +224,46 @@ func TestSmartCollections(t *testing.T) {
}
require.NotEmpty(t, systemID, "admin user should have system collections")
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
_ = sysResp.Body.Close()
req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+systemID+"/rules", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
"system collections never render the smart editor")
})
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
"Smart List Book": "Science Fiction",
})
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
require.Equal(t, http.StatusCreated, resp.StatusCode)
_ = resp.Body.Close()
tree := map[string]interface{}{
"scope": "books", "mode": "all",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
}
body, _ := json.Marshal(map[string]interface{}{
"name": "Test Smart List " + uuid.NewString()[:8],
"filter_tree": tree,
})
req2, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+setup.Token)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp2.StatusCode)
var created struct {
ID string `json:"id"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&created))
_ = resp2.Body.Close()
collID := created.ID
t.Cleanup(func() {
del, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID, nil)
del.Header.Set("Authorization", "Bearer "+setup.Token)
_, _ = http.DefaultClient.Do(del)
})
// Default list (the Android app's read): smart collections hidden.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
+10 -4
View File
@@ -459,6 +459,8 @@ var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
"viewer_percentage", "library_name",
// Episode-collections extras (2.3 widening, every surface emits):
"is_episode", "feed_title", "download_state", "hidden",
}
func collectionURL(setup *TestServerSetup, id string) string {
@@ -498,8 +500,9 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
// payload; docs/contract_pins.md changelog).
envelopeKeys := []string{
"id", "user_id", "name", "description", "color", "icon",
"auto_assign_rules", "view_settings", "books", "book_count",
"view_settings", "books", "book_count",
"is_smart", "created_at", "up_next",
"filter_tree", // item-3.2 widening (docs/contract_pins.md)
}
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
@@ -562,11 +565,14 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
})
t.Run("smart collection (rules branch)", func(t *testing.T) {
t.Run("smart collection (tree branch)", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
"auto_assign_rules": []map[string]interface{}{
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
"filter_tree": map[string]interface{}{
"scope": "books", "mode": "any",
"children": []map[string]interface{}{
{"field": "genre", "op": "is", "values": []string{"Science Fiction"}},
},
},
}, setup.Token)
defer resp.Body.Close()
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// The custom-section builder's save, end to end (2026-10-31 tree
// migration). The page posts the legacy rules vocabulary — rules as a
// JSON-encoded ARRAY inside a STRING (it has done so since the page was
// written, 2026-02-19; the save never once bound successfully before
// the migration) — plus match_type, hand-picked book ids, and the
// dashboard pin. The server converts the rules to a filter tree at
// write time, attaches the manual picks, and the dashboard section
// renders stored membership + the tree's live matches, deduped.
func TestCustomSectionBuilderSave(t *testing.T) {
setup := setupTestServer(t)
adminToken := setup.Token
regToken := setup.RegularToken
marker := fmt.Sprintf("csb%d", time.Now().UnixNano()%100000)
libID := createTestLibraryWithFolder(t, setup.Server, adminToken, "test CSB Library "+marker, true)
libUUID, err := uuid.Parse(libID)
require.NoError(t, err)
// Two items the title rule matches, one it doesn't. Alpha is ALSO
// hand-picked — the section must render it once, not twice.
makeItem := func(title string) uuid.UUID {
id, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"CSB Author"},
FilePath: fmt.Sprintf("/tmp/csb/%s-%s.epub", marker, strings.ReplaceAll(title, " ", "-")),
FileSize: pgtype.Int8{Int64: 128, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr)
out, perr := uuid.FromBytes(id.ID.Bytes[:])
require.NoError(t, perr)
return out
}
alpha := makeItem("CSBRULE Alpha")
_ = makeItem("CSBRULE Beta")
manual := makeItem("CSB Manual Pick")
require.NotZero(t, alpha)
// The EXACT payload shape the builder page posts.
payload := fmt.Sprintf(`{
"library_id": %q,
"name": "test CSB Section %s",
"icon": "📚",
"description": "built by the custom-section builder",
"show_on_dashboard": true,
"auto_assign_rules": %[3]s,
"manual_book_ids": [%q, %q],
"match_type": "all"
}`, libID, marker,
`"[{\"field\":\"title\",\"operator\":\"contains\",\"value\":\"CSBRULE\"}]"`,
alpha.String(), manual.String())
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", payload)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal([]byte(body), &coll))
collID := coll.ID
require.NotEmpty(t, collID)
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+collID, "")
})
// The converted tree round-trips: root mode from match_type, the
// rule as a qf child, and the collection IS smart with no
// collection_type sent.
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var detail struct {
IsSmart bool `json:"is_smart"`
FilterTree struct {
Mode string `json:"mode"`
Children []struct {
Field string `json:"field"`
Op string `json:"op"`
Values []string `json:"values"`
} `json:"children"`
} `json:"filter_tree"`
}
require.NoError(t, json.Unmarshal([]byte(body), &detail))
assert.True(t, detail.IsSmart, "converted rules make the collection smart")
assert.Equal(t, "all", detail.FilterTree.Mode, "match_type seeds the tree's root mode")
require.Len(t, detail.FilterTree.Children, 1)
assert.Equal(t, "title", detail.FilterTree.Children[0].Field)
assert.Equal(t, []string{"CSBRULE"}, detail.FilterTree.Children[0].Values)
// The dashboard section renders stored membership + live matches,
// deduped: Alpha (rule + manual), Beta (rule), Manual (manual).
code, body = podcastAuthed(t, setup, regToken, "GET",
"/api/dashboard/sections?library_id="+libID, "")
require.Equal(t, http.StatusOK, code, "%s", string(body))
var dash struct {
Sections []struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(body), &dash))
var section *struct {
Title string `json:"title"`
CollectionID string `json:"collection_id"`
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
for i := range dash.Sections {
if dash.Sections[i].CollectionID == collID {
section = &dash.Sections[i]
break
}
}
require.NotNil(t, section, "the custom section renders on the dashboard: %s", string(body))
titles := map[string]int{}
for _, it := range section.Items {
titles[it.Title]++
}
assert.Equal(t, 1, titles["CSBRULE Alpha"], "rule-matched AND hand-picked renders exactly once")
assert.Equal(t, 1, titles["CSBRULE Beta"], "rule match rides the tree")
assert.Equal(t, 1, titles["CSB Manual Pick"], "manual picks are attached")
assert.Len(t, section.Items, 3, "nothing else leaks into the section")
}
// TestCustomSectionBuilderBadRule pins the adapter's rejection path: a
// rule the engine cannot honor is a 400 that names the field, never a
// stored 500 at render time.
func TestCustomSectionBuilderBadRule(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test CSB Bad `+fmt.Sprint(time.Now().UnixNano())+`","auto_assign_rules":"[{\"field\":\"not_a_field\",\"operator\":\"contains\",\"value\":\"x\"}]"}`)
assert.Equal(t, http.StatusBadRequest, code, "%s", string(body))
assert.Contains(t, string(body), "section rule")
}
+218 -8
View File
@@ -295,6 +295,115 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
}
// TestFilterScopesAndEpisodeFields pins item 3.1: the books scope
// excludes podcasts (the leak the composed head used to have), the
// episodes scope is episodes only, episode fields evaluate in SQL
// (feed-in, downloaded, played, published-date-within), and episode
// fields are 400s in books trees.
func TestFilterScopesAndEpisodeFields(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
regUUID := getRegularUserID(t, setup.DB)
builderFixtureLib(t, setup, regUUID, "Scope")
// Podcasts library + a subscribed feed with three downloaded
// episodes: two fresh/unplayed, one played (~finished) — the
// played-rule fixture.
feedLib, _ := createPodcastsLibrary(t, setup, "test Scope Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: feedLib.ID,
FeedUrl: "https://feed.invalid/scope-probe.xml",
Title: "Scope Probe Feed",
})
require.NoError(t, err)
epFresh := upsertTestEpisode(t, setup, feed, "scope-fresh", "Scope Fresh Episode", 1)
epPlayed := upsertTestEpisode(t, setup, feed, "scope-played", "Scope Played Episode", 2)
upsertTestEpisode(t, setup, feed, "scope-pending", "Scope Pending Episode", 3)
subscribeRaw(t, setup, regUUID, feed.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2)`, epFresh.ID, epPlayed.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
INSERT INTO reading_progress (media_item_id, user_id, percentage)
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET percentage = 1.0`, epPlayed.ID, regUUID)
feedID := uuid.UUID(feed.ID.Bytes).String()
qfScoped := func(scope string, children ...map[string]interface{}) url.Values {
b, _ := json.Marshal(map[string]interface{}{"scope": scope, "mode": "all", "children": children})
return url.Values{"qf": {string(b)}}
}
const booksScope = "books"
const episodesScope = "episodes"
search := func(p url.Values) []string {
rows := p
status, body := qfSearch(t, setup, setup.RegularToken, rows)
// 404 is the endpoint's pinned no-results contract — an empty set.
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status, "qf must parse: %v", rows)
return qfTitles(t, body)
}
t.Run("books_scope_excludes_podcasts", func(t *testing.T) {
// An exact-ish title hits BOTH a book fixture ("Scope Tide") and
// the episode fixture if the head leaks; books scope must return
// the book, never the episodes.
got := search(qfScoped(booksScope, qfRule("title", "is", "Scope Tide")))
assert.Equal(t, []string{"Scope Tide"}, got, "books scope returns the book")
got = search(qfScoped(booksScope, qfRule("title", "is", "Scope Fresh Episode")))
assert.Empty(t, got,
"books scope must never return podcast episodes (the composed-head leak is CLOSED)")
})
t.Run("episodes_scope_is_episodes_only", func(t *testing.T) {
got := search(qfScoped(episodesScope, qfRule("title", "is", "Scope Fresh Episode")))
assert.Equal(t, []string{"Scope Fresh Episode"}, got,
"episodes scope: only episodes — books drop")
})
t.Run("episode_rules_evaluate_in_sql", func(t *testing.T) {
got := search(qfScoped(episodesScope,
qfRule("feed", "in", feedID)))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode", "Scope Pending Episode"},
got, "feed-in matches every episode of the feed (download state is a separate rule)")
got = search(qfScoped(episodesScope,
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
got, "downloaded:true drops the pending episode")
got = search(qfScoped(episodesScope,
qfRule("played", "is_false"),
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode"}, got,
"played:false + downloaded = the unplayed playable episode (viewer progress)")
got = search(qfScoped(episodesScope,
qfRule("played", "is_true")))
assert.ElementsMatch(t, []string{"Scope Played Episode"}, got,
"played:true matches the finished episode")
got = search(qfScoped(episodesScope,
qfRule("published_date", "within_days", "7"),
qfRule("downloaded", "is_true")))
assert.ElementsMatch(t, []string{"Scope Fresh Episode", "Scope Played Episode"},
got, "published-within includes the dated episodes")
})
t.Run("episode_fields_are_400_in_books_scope", func(t *testing.T) {
p := qfScoped(booksScope, qfRule("played", "is_true"))
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode,
"episode-only fields are rejected in books trees at parse time")
})
}
func TestFilterUserRelativeFields(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
@@ -346,12 +455,17 @@ func TestFilterUserRelativeFields(t *testing.T) {
aID, wickID, aID, tideID)
var collID pgtype.UUID
err := setup.DBPool.QueryRow(context.Background(),
// Seed as the OWNER (collections RLS, 2026-10-31): the insert
// predicate wants actor == owner, and the item/cleanup writes want
// the same identity — the admin write-escape covers SYSTEM rows only.
err := database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(aID),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
seedExec(t, setup.DBPool,
t.Cleanup(func() {
scopeExecAs(t, setup, aID, `DELETE FROM collections WHERE id = $1`, collID)
})
scopeExecAs(t, setup, aID,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
titlesFor := func(token string, qf string) []string {
@@ -433,7 +547,6 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
// Add a second distinct author+genre pair (trigram-disjoint).
ctx := context.Background()
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Sug Dune",
@@ -537,6 +650,16 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
}
assert.True(t, found, "the visible library is offered")
// ID LOOKUP (2026-11-01): typing a stored ID resolves its label —
// the builder's post-Apply reload path resolves stored ids to names
// through this same call.
status, results = suggest("library", libUUID.String(), "")
require.Equal(t, http.StatusOK, status)
require.NotEmpty(t, results, "the id lookup returns its row")
assert.Equal(t, libUUID.String(), results[0]["value"])
disp, _ := results[0]["display"].(string)
assert.Contains(t, disp, "Test Builder", "the id resolves to the library NAME")
// BROWSE the library field UNDER rules (the blank-typed click): the
// id-source query must not choke on the unused typed parameter and
// must list exactly the library holding the surviving books.
@@ -561,16 +684,21 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(context.Background(),
// Seed as the OWNER (collections RLS, 2026-10-31) — same identity
// rule as the first seeding site in this file.
ownerID := pgtype.UUID{Bytes: [16]byte(userA), Valid: true}
err = database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(ownerID),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
ownerID,
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
t.Cleanup(func() {
scopeExecAs(t, setup, ownerID, `DELETE FROM collections WHERE id = $1`, collID)
})
var tideID pgtype.UUID
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err)
seedExec(t, setup.DBPool,
scopeExecAs(t, setup, ownerID,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
status, results = suggest("collection", " ", "")
@@ -775,3 +903,85 @@ func TestFilterFlatEquivalence(t *testing.T) {
require.Equal(t, statusFlat, statusTree, "same result status")
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
}
// ---- deep group nesting (2026-11-01 group ALL/ANY header toggle): ----
// the UI can now produce 3+-level mixed-mode trees; pin the engine's
// parenthesized evaluation for the user's target structure
// GROUP(GROUP(Field and (Field or Field)) or GROUP(Field and Field and
// (Field or Field)) and GROUP(Field)) — plus a suggestion request
// carrying a nested-any constraint (the mode-aware menu under real
// nesting) — must be 200, never the empty-group 400.
func TestFilterDeepGroupNesting(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
userID := getRegularUserID(t, setup.DB)
libUUID, _ := builderFixtureLib(t, setup, userID, "Deep")
titles := func(qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
status, rows := qfSearch(t, setup, setup.RegularToken, p)
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
return qfTitles(t, rows)
}
// root ALL[
// ANY[ ALL[fantasy, ANY[>=250p, abridged]] , ALL[Dar, >=500p] ],
// author not_contains Quill
// ]
// => Tide (fantasy + 300p), Wick (Dar + 700p); Fen dies at the root
// AND (Quill); Stone fails branch A (history) and branch B (Voss).
deep := map[string]interface{}{
"mode": "all",
"children": []interface{}{
map[string]interface{}{
"mode": "any",
"children": []interface{}{
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("genre", "is", "fantasy"),
map[string]interface{}{
"mode": "any",
"children": []interface{}{
qfRule("page_count", "at_least", "250"),
qfRule("abridged", "is_true"),
},
},
},
},
map[string]interface{}{
"mode": "all",
"children": []interface{}{
qfRule("author", "contains", "Dar"),
qfRule("page_count", "at_least", "500"),
},
},
},
},
qfRule("author", "not_contains", "Quill"),
},
}
deepJSON, merr := json.Marshal(deep)
require.NoError(t, merr)
got := titles(string(deepJSON))
assert.ElementsMatch(t, []string{"Deep Tide", "Deep Wick"}, got,
"3-level mixed-mode nesting evaluates parenthesized")
// The same shape riding the SUGGEST endpoint (the mode-aware menu's
// constraint) — nested any must be a 200, never the empty-group 400.
p := url.Values{
"library_id": {libUUID.String()},
"author": {""},
"limit": {"20"},
"qf": {string(deepJSON)},
}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusOK, resp.StatusCode,
"a nested-any constraint rides the suggestion endpoint cleanly: %s", p.Encode())
}
+13 -6
View File
@@ -13,6 +13,8 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
@@ -67,13 +69,14 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
require.NotEmpty(t, foundPath)
// (b) The playlist collection exists with provenance, dashboard pin,
// and the item at reading position 7.
// and the item at reading position 7. Owner-scoped read: collections
// RLS (2026-10-31) hides the row from the pool session's SeedIdentity.
adminUUIDStr := adminID.String()
var collID uuid.UUID
var collName, collDesc string
var showOnDashboard bool
var viewSettingsRaw []byte
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT id, name, description, show_on_dashboard, view_settings
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
@@ -85,7 +88,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
var position *int32
var itemCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped reads: collections RLS (2026-10-31) — items ride the
// parent's readability, so the pool session's SeedIdentity sees none.
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
require.Equal(t, 1, itemCount)
@@ -96,11 +101,11 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
// (c) Rescan: no duplicate collection, no duplicate membership.
scanTestFolder(t, setup, adminID, folder)
var collectionCount, membershipCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collectionCount))
require.Equal(t, 1, collectionCount)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(adminID),
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
require.Equal(t, 1, membershipCount)
@@ -131,7 +136,9 @@ func TestPlaylistCollectionsEndToEnd(t *testing.T) {
// (e) hide_finished + Up Next + completion marker.
// Flip the collection's flag on, mark the single item finished for the
// admin, and the dashboard section becomes Complete with no items.
_, err = setup.DBPool.Exec(context.Background(),
// Owner-or-admin UPDATE policy: run as ADMIN (collections RLS,
// 2026-10-31).
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
package main
// Resume rewind (per-user server setting): seconds before the saved
// position the audiobook/podcast player starts (0 = exact resume).
// Contract: the profile endpoint owns reads/writes with a strict
// 0/5/10 allow-list (the request shape is the keep_screen_awake string
// precedent — the form select must be able to send an explicit Off),
// and the chapters payload carries the value so every client (web +
// Android, incl. the Auto service) gets it with its existing fetch.
// All writes ride the fresh per-test regular user — the shared admin
// account is never touched (AGENTS doctrine). Fixture book lives in a
// test-named library that cascades away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestAudioResumeRewind(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// 664 Hz tone: not the shared helper's 440 or the speed test's 552,
// so the scanner's content dedup never skips this book.
writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664)
scanTestFolder(t, setup, admin, folder)
var bookPath string
for _, item := range listLibraryAudioItems(t, setup, libID) {
if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" {
bookPath = item.FilePath
}
}
require.NotEmpty(t, bookPath, "Rewind Book item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getPayload := func(itemID uuid.UUID) (rewind int) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
ResumeRewindSeconds int `json:"resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.ResumeRewindSeconds
}
getProfileRewind := func() int {
resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var profile struct {
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile))
return profile.AudioResumeRewind
}
t.Run("defaults to off (exact resume)", func(t *testing.T) {
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 5, getProfileRewind())
require.Equal(t, 5, getPayload(bookID))
})
t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) {
for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
require.Equal(t, 5, getProfileRewind())
})
t.Run("explicit off resets to 0 everywhere", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
require.Equal(t, 0, getPayload(bookID))
})
t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 0, getProfileRewind())
})
}
+3 -2
View File
@@ -207,12 +207,13 @@ func TestScanManifestLifecycle(t *testing.T) {
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
// Owner-scoped read: collections RLS (2026-10-31).
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
require.NoError(t, database.NewScopedDB(setup.DBPool).QueryRow(ownerSeedCtx(admin),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
+38 -8
View File
@@ -329,6 +329,22 @@ func seedCtx() context.Context {
return rls.WithUser(context.Background(), rls.SeedIdentity)
}
// ownerSeedCtx returns a context carrying a fixture user's identity,
// for seeding rows OWNED by that user. Collections RLS (2026-10-31):
// the write predicates want actor == owner for non-system rows (the
// admin write-escape covers SYSTEM collections only), and reads under
// the pool session's SeedIdentity see nothing.
func ownerSeedCtx(userID pgtype.UUID) context.Context {
return rls.WithUser(context.Background(), userID)
}
// scopeExecAs is adminScopeExec's generic form: raw SQL under ANY
// declared identity. Use it (with the row's OWNER) for fixture writes
// to non-system collections — see ownerSeedCtx.
func scopeExecAs(t *testing.T, setup *TestServerSetup, userID pgtype.UUID, sql string, args ...any) {
adminScopeExec(t, setup, userID, sql, args...)
}
// seedExec runs a raw statement against the pool under the seed
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
// the GUC), so governed tables need the identity declared manually
@@ -356,7 +372,12 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
// createDefaultCollectionsForUser creates the 4 default system collections for a user
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
ctx := context.Background()
// Owner-scoped seeding: the collections RLS policies (2026-10-31)
// need an acting identity, and the harness seeds before any JWT —
// the same bootstrap fix as CreateDefaultCollectionsForUser in
// internal/handlers/auth.go. Owner == actor, so SELECT sees the
// rows, the INSERT passes, and the heal-UPDATE passes.
ctx := rls.WithUser(context.Background(), userID)
// Canonical Title-case names — the same list the registration seed
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
// A stale kebab-case copy lived here once and duplicated every
@@ -596,10 +617,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
worker := services.NewWorker(3, connManager)
services.WorkerInstance = worker
jobsHandler := handlers.NewJobsHandler(queries, worker)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
// One composed-engine service for search AND the collection
// subsystem (item 3.2 — mirrors main.go).
collectionSearchService := services.NewSearchService(queries, scopedDB)
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager, collectionSearchService)
filtersHandler := handlers.NewFiltersHandler(queries)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
dashboardService := services.NewDashboardService(queries, collectionSearchService)
dashboardHandler := handlers.NewDashboardHandler(queries, collectionSearchService)
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries)
@@ -769,11 +793,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// run's creates die on UNIQUE(user_id, name) — the "collections
// 500-panic" rot. System collections are skipped (permanent rows).
// Same convention as the library pass: names containing "test".
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Swept under the OWNER's identity: collections RLS (2026-10-31)
// makes unscoped reads return nothing, which would silently turn
// this cleanup into a no-op.
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
queries.DeleteCollection(adminScope, coll.ID)
}
}
}
@@ -847,11 +875,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Same pass for test-created COLLECTIONS (the "collections
// 500-panic" rot: they lingered forever and poisoned later runs).
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
// Owner-scoped for the same RLS reason as the setup sweep.
adminScope := rls.WithUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
adminColls, cerr := queries.GetCollectionsByUser(adminScope, pgtype.UUID{Bytes: adminUUID, Valid: true})
if cerr == nil {
for _, coll := range adminColls {
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
queries.DeleteCollection(ctx, coll.ID)
queries.DeleteCollection(adminScope, coll.ID)
}
}
}
+145 -3
View File
@@ -48,6 +48,14 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEF
-- mid-session. On by default; the profile page's On/Off dropdown owns it.
ALTER TABLE users ADD COLUMN IF NOT EXISTS keep_screen_awake BOOLEAN NOT NULL DEFAULT TRUE;
-- Audio resume rewind: seconds before the saved position the
-- audiobook/podcast player starts (on open-resume AND again on every
-- genuine unpause — the re-orientation gap, never stacked). Strict
-- allow-list 0/5/10 enforced handler-side; 0 = exact resume. The
-- profile page's dropdown owns it; the chapters payload carries it to
-- every client (web + Android, incl. the car path).
ALTER TABLE users ADD COLUMN IF NOT EXISTS audio_resume_rewind_seconds INT NOT NULL DEFAULT 0;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1341,7 +1349,9 @@ CREATE TABLE IF NOT EXISTS collections (
description TEXT,
color VARCHAR(7),
icon VARCHAR(50),
auto_assign_rules JSONB,
-- The unified filter tree (plan item 3.2): when present the qf
-- engine drives membership; see FilterTree. NULL = static.
filter_tree JSONB,
view_settings JSONB,
show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter',
@@ -1357,12 +1367,23 @@ CREATE TABLE IF NOT EXISTS collections (
-- the row id (and everything referencing it) never churn.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Legacy rules-array columns RETIRED (2026-10-31): the unified filter
-- tree is the only smart-membership storage. No production row ever
-- carried a rules array or a non-default match mode (verified), so the
-- columns drop clean; the DROPs are the idempotent heal for databases
-- created before this change.
ALTER TABLE collections DROP COLUMN IF EXISTS auto_assign_rules;
ALTER TABLE collections DROP COLUMN IF EXISTS rule_match_mode;
-- Smart collections in the UNIFIED filter engine (plan item 3.2): the
-- qf tree (same DSL as saved filters, See FilterTree). NULL = static.
ALTER TABLE collections ADD COLUMN IF NOT EXISTS filter_tree JSONB;
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
WHERE show_on_dashboard = true;
-- Create user_dashboard_preferences table
CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1628,12 +1649,19 @@ CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
CREATE TABLE IF NOT EXISTS reader_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
setting_key VARCHAR(50) NOT NULL,
setting_key VARCHAR(120) NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, setting_key)
);
-- Key-width headroom (podcast plan §10 R5, item 2.1): the composite
-- `podcast_order:<uuid>` key sits at exactly 50 chars, and future
-- per-feed/per-collection preference keys must not be forced to
-- payload-stuff by a column width. Widening a varchar is metadata-only
-- (no table rewrite); idempotent on every boot.
ALTER TABLE reader_settings ALTER COLUMN setting_key TYPE VARCHAR(120);
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
-- PDF bookmarks (custom user bookmarks)
@@ -2154,3 +2182,117 @@ CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
-- policy's lookup table, and a user must always see their own
-- subscription rows (subscribe -> visibility is a single INSERT).
-- ============================================================
-- ── collections + collection_items (2026-10-31): ownership at the DB ──
-- layer, same doctrine as media_items/podcast_feeds above. The
-- predicates live in FUNCTIONS (not inline) so a future sharing model
-- edits exactly one place: add the share-table EXISTS branch inside
-- app_can_read_collection / app_can_write_collection. The functions are
-- PURE over their arguments — they never query collections — so the
-- policies cannot recurse, FORCE RLS safe. Reads are authenticated-
-- allow (household-readable: any signed-in identity, SeedIdentity
-- harness included); WRITES are owner-or-admin — that is the
-- enforcement of record for Update/Delete/AddBooks, which have no
-- app-level gate.
-- ===========================================================
CREATE OR REPLACE FUNCTION app_is_admin() RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT EXISTS (
SELECT 1 FROM users u
WHERE u.id = NULLIF(current_setting('app.user_id', true), '')::uuid
AND u.role = 'admin'
);
$$;
CREATE OR REPLACE FUNCTION app_can_read_collection(
p_collection_id UUID, p_owner UUID
) RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
AND (
p_owner = current_setting('app.user_id', true)::uuid
OR app_is_admin()
-- FUTURE SHARING slot: OR EXISTS (
-- SELECT 1 FROM collection_shares cs
-- WHERE cs.collection_id = p_collection_id
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid)
);
$$;
CREATE OR REPLACE FUNCTION app_can_write_collection(
p_collection_id UUID, p_owner UUID, p_system BOOLEAN
) RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT NULLIF(current_setting('app.user_id', true), '') IS NOT NULL
AND (
p_owner = current_setting('app.user_id', true)::uuid
OR (COALESCE(p_system, false) AND app_is_admin())
-- FUTURE SHARING slot: OR EXISTS (
-- SELECT 1 FROM collection_shares cs
-- WHERE cs.collection_id = p_collection_id
-- AND cs.shared_with_user_id = current_setting('app.user_id', true)::uuid
-- AND cs.permission IN ('edit'))
);
$$;
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
ALTER TABLE collections FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_collections_select ON collections;
CREATE POLICY p_collections_select ON collections
FOR SELECT USING (app_can_read_collection(id, user_id));
DROP POLICY IF EXISTS p_collections_insert ON collections;
CREATE POLICY p_collections_insert ON collections
FOR INSERT WITH CHECK (
user_id = NULLIF(current_setting('app.user_id', true), '')::uuid
OR app_is_admin()
);
DROP POLICY IF EXISTS p_collections_update ON collections;
CREATE POLICY p_collections_update ON collections
FOR UPDATE
USING (app_can_write_collection(id, user_id, is_system_collection))
WITH CHECK (app_can_write_collection(id, user_id, is_system_collection));
DROP POLICY IF EXISTS p_collections_delete ON collections;
CREATE POLICY p_collections_delete ON collections
FOR DELETE USING (app_can_write_collection(id, user_id, is_system_collection));
ALTER TABLE collection_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE collection_items FORCE ROW LEVEL SECURITY;
-- Items ride their PARENT's accessibility: the collections SELECT
-- policy auto-applies inside these EXISTS subqueries.
DROP POLICY IF EXISTS p_collection_items_select ON collection_items;
CREATE POLICY p_collection_items_select ON collection_items
FOR SELECT USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
));
DROP POLICY IF EXISTS p_collection_items_insert ON collection_items;
CREATE POLICY p_collection_items_insert ON collection_items
FOR INSERT WITH CHECK (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
DROP POLICY IF EXISTS p_collection_items_update ON collection_items;
CREATE POLICY p_collection_items_update ON collection_items
FOR UPDATE
USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
))
WITH CHECK (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
DROP POLICY IF EXISTS p_collection_items_delete ON collection_items;
CREATE POLICY p_collection_items_delete ON collection_items
FOR DELETE USING (EXISTS (
SELECT 1 FROM collections c
WHERE c.id = collection_items.collection_id
AND app_can_write_collection(c.id, c.user_id, c.is_system_collection)
));
+269
View File
@@ -468,3 +468,272 @@ never-queued episodes) is stamped from the `podcast_queue_seq` SEQUENCE
at enqueue time so claim order = enqueue order (the effective listening
order). Raw-row pins (TestContractPin_MediaListBothBranches →
rawRowKeys) whitelist the key. Nothing removed, nothing renamed.
## 2026-10-30 — episode collections (additive widening, plan item 2/2.3/2.4)
Two shapes widen, both ADDITIVE — nothing removed, nothing renamed:
- **Collections envelope** (`GET /api/collections/:id` → envelopeKeys)
gains `rule_match_mode` (smart-collection match semantics, 'any'
legacy default | 'all'). Static collections emit the default value.
- **BookInfo** (every card surface → bookInfoKeys) gains four STABLE
keys — `is_episode`, `feed_title`, `download_state`, `hidden` — the
episode-collections extras. Adapters that predate episode awareness
emit their zero values (`false`/`""`), matching the
viewer_percentage precedent (nil-representable, key always present).
## 2026-10-30 — collections envelope gains filter_tree (additive widening, item 3.2)
`GET /api/collections/:id` adds `filter_tree` to the envelope: the
unified filter tree when the smart collection carries one (item 3.2 —
tree-driven membership), `null` for legacy/static collections. The
tree is the same qf DSL as saved filters (`{scope?, mode, children}`).
Nothing removed, nothing renamed.
## 2026-10-30 — rules endpoints RETIRED (filter-system unification, item 3.4)
The per-rule CRUD endpoints and the preview endpoint are DELETED — the
unified filter tree (item 3.2) is the only smart-membership authoring
surface, and the rules PAGE is now the filter builder in the
collection's scope:
- `POST /api/collections/:id/rules`, `PUT/DELETE
/api/collections/:id/rules/:ruleId` — gone (legacy rule arrays still
EVALUATE until the collection is rewritten; conversions run at the
builder's first Apply via RulesToFilterTree).
- `POST /api/collections/test-rules` — gone (the builder's live count
via /api/media-items/filter-count replaces the preview).
- `POST /api/collections/preview` STAYS (the dashboard
custom-section-builder still consumes it — flagged for its own
migration, not silently broken).
- Legacy compatibility KEPT: `auto_assign_rules` still round-trips in
the collections envelope; `PUT /api/collections/:id/match-mode`
still serves rules-array collections.
Test-side deletions follow the asymmetry rule: subtests exercising the
deleted endpoints are gone; their surviving SEMANTICS are pinned in
their tree form (TestSmartCollections rewritten on trees,
TestEpisodeCollectionsFilterTree, TestPodcastSmartCollections).
## 2026-10-31 — collections RLS: cross-user detail/writes tighten to 404/no-op
`collections` and `collection_items` joined the RLS-governed tables
(policies `p_collections_*` / `p_collection_items_*`, predicates
`app_can_read_collection` / `app_can_write_collection` — the same
doctrine as the media_items family lens, 2026-10-29). Contract
changes:
- `GET /api/collections/:id` for another user's collection: was 200
(by-id readability), now 404 (row invisible).
- `PUT /api/collections/:id` cross-user: was ungated success (no
app-level ownership check), now 404 — the RLS update touches no
rows, and the handler maps ErrNoRows to 404.
- `POST /api/collections/:id/books` cross-user: was ungated, now 404
(rejectSmartMutation's GetCollectionData is RLS-invisible).
- `DELETE /api/collections/:id` cross-user: still 204, but now a true
DB-level no-op (zero rows deleted) instead of an app-level free hit.
- `PUT /api/collections/:id/view-settings` cross-user: 404 (the owner
gate stays as defense-in-depth; the DB is enforcement of record).
- `PUT .../match-mode` and `.../items/order` keep their app-level 403
gates; the DB now backs them.
- Reads: owner or admin (policy escape); non-owner non-admin users see
nothing, anonymous included. Future sharing rides the predicate
functions — one EXISTS branch in app_can_read/write_collection, no
policy migration.
Test-side: TestCollectionsRLS pins the cross-user matrix end-to-end;
fixture writes to non-system collections run as the row's OWNER
(scopeExecAs/ownerSeedCtx) because the admin write-escape covers
system collections only.
## 2026-10-31 — legacy collection rules surface RETIRED (tree-only storage)
The pre-tree rules machinery is gone — no production row ever carried a
rules array or a non-default match mode (verified in the dev DB), the
web never reads the envelope keys, and the Android app consumes none of
it. Deletions:
- `PUT /api/collections/:id/match-mode` — endpoint retired (it had no
doc page). The tree's root `mode` owns any|all semantics now.
- Envelope: `auto_assign_rules` and `rule_match_mode` are REMOVED from
the create/list/detail/update payloads. This entry retracts the
2026-10-30 pins that listed them. `collection_type` and `is_smart`
stay.
- Request: `auto_assign_rules`/`rule_match_mode` are no longer accepted
on create/update. A create carrying `auto_assign_rules` answers 400
naming the retirement — the custom-section builder's legacy save
(already failing to bind, verified live) fails loudly instead of
silently creating a hollow static section.
- Storage: the `collections.auto_assign_rules` and
`collections.rule_match_mode` columns are DROPPED (idempotent
`DROP COLUMN IF EXISTS` in schema.sql).
- Service: the rules CRUD layer (AddRule/SetRuleEnabled/RemoveRule +
validateRule + the UpdateCollectionRulesJSON query),
ParseRules/EnabledRules/IsSmartCollection, RulesToFilterTree (+ the
SSR preload branch), and the legacy branch of EvaluateSmartMembership
are deleted. IsCollectionSmart = tree presence.
KEPT (live consumers): `POST /api/collections/preview` still accepts
`rules` and evaluates them in memory (the custom-section builder's
preview button); `include_auto` on the list stays (the Android app
sends it). The builder's SAVE remains the flagged §12 tree migration.
## 2026-10-31 — custom-section builder save: the flagged tree migration lands
Supersedes the same-day retirement entry's loud-400 stance for the
builder's payload. `POST /api/collections` now ACCEPTS the builder's
shape and adapts it at write time:
- `auto_assign_rules` (JSON-encoded array — string or bare) converts to
a filter tree via the restored `RulesToFilterTree`; converted trees
validate under `ParseFilterTree`, so an unhonorable rule is a 400 at
write naming the problem.
- `match_type` seeds the tree's root mode (`any` default).
- `manual_book_ids` attach to the created collection after create
(unparsable ids skipped — the AddBooks leniency).
- `show_on_dashboard` persists (new create field).
- `library_id` remains selection context only (not stored).
Dashboard sections: smart user sections evaluate their tree LIVE —
`GetUserCollectionItems` merges stored membership with the composed
engine's matches (deduped, library-scoped, capped at the section
limit). This exposed and fixed a real wiring bug: every production
`NewDashboardService` call omitted the search service, so tree
evaluation through the dashboard could never work (the test harness
wired it correctly — the gap was production-only). The dashboard
handler and both inline sites now receive it, and evaluation failures
log instead of silently emptying a section.
## 2026-11-01 — feed operator vocabulary collapsed (Option A): one name per semantic
The feed field's menu offered `is`/`isn't` alongside `in`/`isn't in` —
exact synonyms (the clause compiles both pairs to the same `ANY`). The
MENUS now offer `in`/`isn't in` (+ has/missing) only. This is an
offered-vocabulary narrowing, not a parse change:
- Stored trees and saved filters carrying `feed is X` / `feed is_not X`
PARSE AND EVALUATE FOREVER (`legacyFieldOps` accepts the synonyms at
validation — the stored wire is never invalidated).
- The fields payload (the client's operator menus) no longer carries
the duplicates.
- Client id-cells are uniform: every operator on an id-valued field
accepts a set, so all id cells say "pick one or more" and accumulate
picks — no per-operator placeholder/pick distinction to explain.
## 2026-11-01 — id labels resolve at render (SSR embed): refresh keeps names
Pages that render a filter tree embed the tree's id→name map at render
time (`data-id-labels` on the builder mount) — the collection editor
(stored tree) and the bookshelf (URL `qf`). Before this, refresh showed
raw uuids: only ids persist in trees; the client's id→label cache dies
with the page, and the quiet suggest fetch reused the MEDIA-CONSTRAINED
facet, so an id whose media doesn't match the tree's sibling rules got
no row and no label (live repro: `feed in [A,B,C] AND title contains
Chapter` labeled one feed of three — two feeds had no "Chapter"
episodes).
- `SearchService.FilterTreeLabels` reads the ENTITY tables (no media
join, no tree constraint): podcast_feeds and collections are
RLS-governed through the scoped handle (invisible ids resolve to
nothing); libraries mirror the facet's visibility join; added_by is a
plain username lookup. A zero-episode feed resolves its title —
pinned (TestRulesPageEmbedsIdLabels), as is the hidden-library
non-resolution.
- The bookshelf drawer also HYDRATES at component init now (no
data-tree mount → read the form): the parent's x-init
(initBookshelf) runs BEFORE the nested builder component exists
(Alpine initializes parents first), so its builderData() call was
null and a URL-qf refresh left the drawer with an empty seeded rule
while the grid stayed filtered. Init-order contract pinned in
filter_builder.test.ts.
- The quiet suggest fetch remains only as a fallback for trees the SSR
never saw (client-side apply flows); embedded labels make it a no-op
on refresh.
## 2026-11-01 — unset podcast order defaults to reversed (audiobook-first)
The per-feed listening order (the D5 `podcast_order:<feedID>` KV shown
in the Order dropdown) resolved its UNSET state to "native" — feed
document order, which runs newest-first for classic RSS, so an
audiobook feed played its LAST chapter first. The unset default is now
`reverse` at every resolution site of that one setting: the podcast
page list + dropdown (`podcastOrderForFeed`), the player's feed chain
(reader.go), and the download worker (`orderOverrideFor`).
- Stored overrides are untouched: anyone who picked an order keeps it
(native remains a valid, savable choice).
- No ordering semantics changed — `OrderEpisodes` and the four-option
vocabulary are as they were; only the unset fallback flipped.
- Pinned: the player sequence config carries `"order":"reverse"` and
feed position 1 plays LAST for an unset feed; the podcast page's
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
replacing the former native pin).
## 2026-11-01 — new podcast smart collections are BORN reversed
`POST /api/collections` with an EPISODES-scope `filter_tree` seeds
`view_settings.chain_order = "reverse"` on the new row (merge semantics:
an explicit client `chain_order` is never clobbered). The dropdown's
default selection and the chain/page behavior come from the STORED
value — no resolution-logic changes; books-scope smart collections and
statics get nothing seeded; existing collections are untouched.
Pinned (TestSmartCollectionBornReversed): episodes seed, explicit-keep,
books-scope no-seed, static no-seed; TestEpisodeCollectionsFilterTree's
born-reversed chain/page pins replace the former native expectations
(which changelog here per the expectation-change rule). One misplaced
pin from 81e54f89 was corrected while flipping: the API envelope's
match list is NOT chain-ordered and never was — order pins live on the
SSR page and the reader chain.
## 2026-11-01 — label endpoint replaces the suggest-facet fallback
`GET /api/filter/labels?field=X&ids=a,b,c` names id-kind filter values
from their source tables (entity rows only — no media join, no tree
constraint; visibility/RLS ride the request identity). Non-id fields
are a 400; ids truncate at the same 100 cap the SSR embed uses.
The client's label resolution (`resolveIdLabels`/`resolveLabelsAsync`)
now uses ONLY this endpoint. The former fallback — the quiet suggest
fetch through the MEDIA-CONSTRAINED facet — is deleted: it labeled only
the ids whose media matched the tree's sibling rules (the live feed
repro), which mattered exactly where the SSR embed can't reach:
applying a SAVED filter from a cold bookshelf. The apply flow now
resolves through the endpoint and rebuilds the chips bar when labels
land. Pinned (TestFilterLabelsEndpoint): a zero-episode feed resolves
its title, comma-joined ids resolve together, an unsubscribed feed id
stays unnamed (RLS), non-id fields are 400.
## 2026-11-01 — the rules-Apply path seeds the reversed default too
PUT /api/collections/:id carrying an EPISODES-scope filter_tree seeds
`chain_order = "reverse"` when neither the request nor the stored
view_settings names one — the creation seed's twin: a static collection
becoming smart lands with the same default a born-smart one gets.
Explicit values (client-sent or stored — e.g. a deliberate native)
are never clobbered. Pinned (TestSmartCollectionBornReversed):
apply-seeds-when-unordered, respects stored native, respects client
native.
TestPodcastSmartCollections' acceptance pin updated with the same flip:
the briefing acceptance now starts from the page's FIRST card (feed
position 5 — the born-reversed default's book order) and chains into
feed position 1. The acceptance meaning is unchanged: play from what
you see, chained back-to-back within the live briefing set.
## 2026-11-01 — audio resume rewind setting
- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key:
`resume_rewind_seconds` (int, the requesting user's setting; 0 for
anonymous). Existing keys unchanged. The web player and the Android
app both gate the value through the 0/5/10 allow-list, so servers
without the key (absent → 0) degrade to exact resume.
- `GET /api/auth/profile` now always carries
`audio_resume_rewind_seconds` (int, never omitted — 0 is a real
value, exact resume). `PUT /api/auth/profile` accepts the key as the
keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's
select can send an explicit Off; JSON clients that omit it leave the
column untouched, and anything outside {0,5,10} is 400.
Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the
chapters payload, non-allow-listed rejections change nothing,
explicit "0" resets everywhere, omitted key leaves it untouched.
+3 -3
View File
@@ -4,7 +4,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 🚀 Getting Started
- **[Development Guide](Development.md)** - Development workflow and architecture
- **[Development Guide](development.md)** - Development workflow and architecture
- Architecture overview
- Directory structure
- Local development setup
@@ -14,7 +14,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 📋 Project Guidelines
- **[AGENTS.md](../../AGENTS.md)** - Agent/development rules
- **[AGENTS.md](../contributing/contributing.md)** - Development rules and standards live in the Contributing portal
- Critical prohibitions
- Mandatory requirements
- Error recovery protocol
@@ -22,7 +22,7 @@ Welcome to the Bookhoard contributing documentation. This section contains guide
## 🤝 How to Contribute
We welcome contributions! Please see our [Development Guide](Development.md) for information on:
We welcome contributions! Please see our [Development Guide](development.md) for information on:
- Setting up your development environment
- Understanding the codebase
+3 -3
View File
@@ -510,9 +510,9 @@ DEBUG=true
## 📚 Additional Resources
- [AGENTS.md](../../AGENTS.md) - Agent/development rules
- [API Reference](../API_REFERENCE.md) - Complete API documentation
- [Troubleshooting](../TROUBLESHOOTING.md) - Deployment issues
- [Development rules and standards](contributing.md) - Contributing portal
- [API Reference](../developer/api/api-reference.md) - Split endpoint docs, indexed from its overview
- [Troubleshooting](../operations/troubleshooting.md) - Deployment issues
- [Go Documentation](https://go.dev/doc/)
- [Echo Framework](https://echo.labstack.com/docs)
- [pgx Documentation](https://pgx.github.io/pgx/)
@@ -1,89 +0,0 @@
---
method: POST
path: /api/collections/:id/rules
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:877 CollectionHandler.AddRule -->
# Add Auto-Assign Rule
Add an automatic book assignment rule to a collection.
**Endpoint**: `POST /api/collections/{id}/rules`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| --------- | ------------- | -------- | --------------- |
| id | string (UUID) | Yes | Collection UUID |
## Request Body
| Field | Type | Required | Description |
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
| value | string/number | Yes | Value to compare against |
| priority | integer | No | Rule priority (1 = highest, default: 1) |
| enabled | boolean | No | Whether rule is active (default: true) |
### Supported Fields
| Field | Type | Description |
| -------------- | ------ | ------------------------------------- |
| genre | string | Book genre |
| author | string | Book author |
| series | string | Book series name |
| language | string | Book language |
| publisher | string | Publisher name |
| copyright_year | number | Publication year (numeric comparison) |
| tags | string | Book tags |
### Supported Operators
| Operator | Type | Description |
| ------------ | ------ | ------------------------------------- |
| equals | all | Exact match |
| not_equals | all | Not equal |
| contains | string | Contains substring (case-insensitive) |
| not_contains | string | Does not contain |
| starts_with | string | Starts with (case-insensitive) |
| ends_with | string | Ends with (case-insensitive) |
| greater_than | number | Greater than |
| less_than | number | Less than |
### Example Request
```json example-request
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
```
## Response (201 Created)
```json example-response
{
"collection_id": null,
"is_smart": null,
"rule": null
}
```
## Error Responses
| Code | Description |
| ---- | ----------------------------------- |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 404 | Collection not found |
| 500 | Internal server error |
## Try It Out
@@ -23,7 +23,6 @@ Creating a collection whose name already exists for the user returns **409**.
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
```
@@ -39,7 +38,6 @@ Creating a collection whose name already exists for the user returns **409**.
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": null,
"view_settings": { "kobo": { "view_mode": "grid" } },
"book_count": 0,
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
@@ -57,20 +55,13 @@ RFC3339. Treat it as display text; do not parse it as an ISO timestamp.
| description | string | no | |
| color | string | no | hex color, e.g. `"#FF5733"` |
| icon | string | no | emoji |
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by non-empty `auto_assign_rules` |
| auto_assign_rules | array | no | rule objects, see below |
| collection_type | string | no | `"static"` (default) or `"smart"`; also implied by a `filter_tree` |
| view_settings | object | no | per-device display preferences |
### Auto-assign rule object
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| id | string | no | rule id |
| field | string | yes | one of `genre`, `series`, `author`, `language`, `publisher`, `copyright_year`, `tags` |
| operator | string | yes | one of `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `greater_than`, `less_than` |
| value | string | yes | value to compare against |
| priority | number | no | 1-10; higher values take precedence |
| enabled | boolean | no | absent = enabled (rules written before this field existed keep matching) |
| filter_tree | object | no | the unified filter tree (qf DSL); presence makes the collection smart |
| auto_assign_rules | string \| array | no | custom-section-builder payload — converted to `filter_tree` at write (see custom-section-builder doc) |
| match_type | string | no | builder payload: `any` (default) or `all` — seeds the converted tree's root mode |
| manual_book_ids | string\[\] | no | builder payload: attached to the collection after create |
| show_on_dashboard | boolean | no | pins the collection as a dashboard section |
## Errors
@@ -27,7 +27,6 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"auto_assign_rules": null,
"view_settings": { "kobo": { "view_mode": "grid" } },
"books": [],
"book_count": 0,
@@ -57,8 +56,8 @@ GET /api/collections/550e8400-e29b-41d4-a716-446655440000
| description | string | |
| color | string | |
| icon | string | |
| auto_assign_rules | array \| null | |
| view_settings | object | |
| filter_tree | object \| null | the unified filter tree (qf DSL); null = static |
| books | array | book cards in this collection |
| book_count | number | length of `books` |
| is_smart | boolean | |
@@ -29,7 +29,6 @@ GET /api/collections?include_auto=true&sort_by=name
"description": "Books I want to read soon",
"color": "#00FF00",
"icon": "📖",
"auto_assign_rules": null,
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
"book_count": 12,
"is_smart": false
@@ -65,7 +64,6 @@ GET /api/collections?include_auto=true&sort_by=name
| description | string | |
| color | string | |
| icon | string | |
| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart |
| created_at | string | Go `time.String()` format |
| book_count | number | live count; respects `library_id` for smart collections |
| is_smart | boolean | |
@@ -1,46 +0,0 @@
---
method: DELETE
path: /api/collections/:id/rules/:ruleId
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:960 CollectionHandler.RemoveRule -->
# Remove Auto-Assign Rule
Remove an automatic book assignment rule from a collection.
**Endpoint**: `DELETE /api/collections/{collectionId}/rules/{ruleId}`
**Auth**: Required
**Content-Type**: `application/json`
## Path Parameters
| Parameter | Type | Required | Description |
| ------------ | ------------- | -------- | --------------- |
| collectionId | string (UUID) | Yes | Collection UUID |
| ruleId | string (UUID) | Yes | Rule UUID |
## Response (204 No Content)
Rule deleted successfully. No response body.
```json example-response
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"is_smart": true
}
```
`is_smart` reflects the collection state AFTER removal — removing the
last rule converts a smart collection back to static.
## Error Responses
| Code | Description |
| ---- | ---------------------------- |
| 401 | Authentication required |
| 404 | Collection or rule not found |
| 500 | Internal server error |
## Try It Out
@@ -0,0 +1,48 @@
---
method: PUT
path: /api/collections/:id/view-settings
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go CollectionHandler.SetCollectionChainOrder -->
# Set Collection Chain Order
Sets the curated listening order for the collection's episode sequence
(plan §10 R2): the chain producer (`buildSmartCollectionSequenceContext`)
plays `Play as Sequence` in this order. The key merges into the
collection's `view_settings` JSONB — playlist toggles that share the
document survive the write. Owner-only.
## Example request
```json example-request
{
"chain_order": "newest"
}
```
## Example response
```json example-response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"chain_order": "newest",
"view_settings": { "chain_order": "newest" }
}
```
## Fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| chain_order | string | Yes | One of `native`, `newest`, `oldest`, `reverse`; `native`/empty removes the stored key |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid collection id"}` | malformed id |
| 400 | `{"error": "chain_order must be one of: native, newest, oldest, reverse"}` | unknown order |
| 404 | `{"error": "collection not found"}` | unknown id |
| 401 / 403 | — | missing JWT / not the owner |
@@ -1,79 +0,0 @@
---
method: POST
path: /api/collections/test-rules
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:996 CollectionHandler.TestRules -->
# Test Rule
Test which books would match given rules without saving.
**Endpoint**: `POST /api/collections/test-rules`
**Auth**: Required
**Content-Type**: `application/json`
## Request Body
| Field | Type | Required | Description |
| ----- | ----- | -------- | ----------------------------- |
| rules | array | Yes | Array of rule objects to test |
### Rule Object
| Field | Type | Required | Description |
| -------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| field | string | Yes | Field to match on (genre, author, series, language, publisher, copyright_year, tags) |
| operator | string | Yes | Comparison operator (equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than) |
| value | string/number | Yes | Value to compare against |
### Example Request
```json example-request
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
```
## Response (200 OK)
```json example-response
{
"matches": [
{
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
"title": "Foundation",
"author": "Isaac Asimov",
"cover_image_path": "/covers/foundation.jpg",
"match_reason": "Matched rule: genre equals Science Fiction"
}
],
"total": 42
}
```
## Use Case
Test rules before creating a collection to verify correct book matching. This endpoint shows which books would be added without actually modifying any collections.
## Error Responses
| Code | Description |
| ---- | ----------------------------------- |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 500 | Internal server error |
## Try It Out
@@ -24,7 +24,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
@@ -41,7 +40,6 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": null,
"view_settings": { "hide_finished": true, "show_up_next": true },
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
}
@@ -57,8 +55,8 @@ set `view_settings.hide_finished` / `view_settings.show_up_next` directly.
| description | string | no | |
| color | string | no | |
| icon | string | no | |
| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) |
| view_settings | object | no | merged into stored settings |
| filter_tree | object | no | replaces the stored filter tree (omitting it keeps the stored one) |
| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings |
| show_up_next | array | no | same shape as `hide_finished` |
@@ -1,45 +0,0 @@
---
method: PUT
path: /api/collections/:id/rules/:ruleId
auth: jwt
area: collections
---
<!-- source: internal/handlers/collections.go:922 CollectionHandler.UpdateRule (SetRuleEnabled) -->
# Update Collection Rule
Despite the route name, this endpoint **toggles a rule's enabled state only**.
It does not update the rule's field/operator/value — rules are replaced wholesale
via [Create Collection](create_collection.md) (`auto_assign_rules`) or added via
the rules endpoint.
## Example request
```json example-request
{
"enabled": false
}
```
## Example response
```json example-response
{
"collection_id": "550e8400-e29b-41d4-a716-446655440000",
"is_smart": true
}
```
## Request fields
| Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- |
| enabled | boolean | yes | toggles the rule on/off |
## Errors
| Status | Body | When |
| ------ | ---- | ---- |
| 400 | `{"error": "invalid collection id"}` / `{"error": "rule id required"}` / `{"error": "invalid request"}` | malformed |
| 404 | `{"error": "rule not found"}` | no such rule |
| 500 | `{"error": "..."}` | unexpected server error |
@@ -68,6 +68,15 @@ Creates a new custom collection with filter rules and/or manual book selection.
**Request Body:**
**Save contract (2026-10-31 tree migration):** the rules array travels
as a JSON-encoded string and the server converts it to a filter tree at
write time — `match_type` seeds the tree's root mode (`any` default),
`manual_book_ids` attach to the created collection, and
`show_on_dashboard` persists. `library_id` is selection context only
(sections scope to the dashboard's active library at render). Invalid
rules are a 400 at write naming the problem; the section renders stored
picks + the tree's live matches, deduped.
```json
{
"name": "My Custom Section",
@@ -41,7 +41,8 @@ Add a folder to an existing library (Admin only).
"id": "uuid",
"library_id": "uuid",
"folder_path": "/path/to/comics",
"created_at": "2026-01-31T10:00:00Z"
"created_at": "2026-01-31T10:00:00Z",
"purpose": "scan"
}
```
@@ -42,7 +42,8 @@ Create a new library (Admin only).
"created_at": "2026-01-31T10:00:00Z",
"created_by_admin_id": null,
"library_type_id": null,
"updated_at": null
"updated_at": null,
"allow_member_feeds": false
}
```
+2 -1
View File
@@ -43,7 +43,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"created_at": null,
"created_by_admin_id": null,
"library_type_id": null,
"updated_at": null
"updated_at": null,
"allow_member_feeds": false
}
```
@@ -18,7 +18,8 @@ Lists the filesystem folders configured for a library.
"id": "aa0e8400-e29b-41d4-a716-446655440000",
"library_id": "990e8400-e29b-41d4-a716-446655440000",
"folder_path": "/books/manga",
"created_at": "2026-01-01T00:00:00Z"
"created_at": "2026-01-01T00:00:00Z",
"purpose": "scan"
}
]
```
@@ -42,7 +42,8 @@ A top-level JSON **array** of library rows:
"updated_at": "2026-01-31T10:00:00Z",
"type_name": "ebooks",
"type_description": "Ebook libraries",
"is_visible": true
"is_visible": true,
"allow_member_feeds": false
}
]
```
@@ -46,7 +46,8 @@ Update a library's information.
"created_at": "2026-02-08T10:00:00Z",
"updated_at": "2026-02-08T11:00:00Z",
"created_by_admin_id": null,
"description": null
"description": null,
"allow_member_feeds": false
}
```
@@ -164,7 +164,23 @@ Tags and contributors are automatically normalized:
"summary": null,
"total_characters": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
```
@@ -231,7 +231,23 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"total_characters": null,
"updated_at": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
]
```
@@ -128,7 +128,23 @@ Update media item metadata (Admin only).
"tags_search": null,
"total_characters": null,
"volume": null,
"web_url": null
"web_url": null,
"source": null,
"source_feed_id": null,
"feed_position": null,
"episode_number": null,
"season_number": null,
"episode_guid": null,
"enclosure_url": null,
"chapters_url": null,
"transcript_url": null,
"hosts": null,
"guests": null,
"declared_duration_seconds": null,
"download_state": null,
"download_attempts": null,
"download_queue_pos": null,
"download_error": null
}
```
+2
View File
@@ -36,6 +36,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
"first_name": "John",
"last_name": "Doe",
"role": "user",
"default_audio_speed": 1.5,
"audio_resume_rewind_seconds": 5,
"keep_screen_awake": true
}
```
+4 -61
View File
@@ -229,7 +229,6 @@ paths:
"color": "#00FF00",
"icon": "📖",
"collection_type": "static",
"auto_assign_rules": [],
"view_settings": { "kobo": { "view_mode": "grid" } }
}
responses:
@@ -277,33 +276,6 @@ paths:
responses:
'200':
description: Success
"/api/collections/test-rules":
post:
tags: [collections]
summary: "Test Rule"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
},
{
"field": "author",
"operator": "contains",
"value": "Asimov"
}
]
}
responses:
'200':
description: OK
"/api/collections/{id}":
delete:
tags: [collections]
@@ -335,7 +307,6 @@ paths:
"description": "Updated description",
"color": "#33CCFF",
"icon": "📚",
"auto_assign_rules": [],
"view_settings": { "hide_finished": true },
"hide_finished": ["true"],
"show_up_next": ["true"]
@@ -409,38 +380,10 @@ paths:
responses:
'200':
description: Success
"/api/collections/{id}/rules":
post:
tags: [collections]
summary: "Add Auto-Assign Rule"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
"priority": 1,
"enabled": true
}
responses:
'201':
description: Created
"/api/collections/{id}/rules/{ruleId}":
delete:
tags: [collections]
summary: "Remove Auto-Assign Rule"
security:
- bearerAuth: []
responses:
'204':
description: No Content
"/api/collections/{id}/view-settings":
put:
tags: [collections]
summary: "Update Collection Rule"
summary: "Set Collection Chain Order"
security:
- bearerAuth: []
requestBody:
@@ -448,11 +391,11 @@ paths:
application/json:
example: |-
{
"enabled": false
"chain_order": "newest"
}
responses:
'200':
description: Success
description: OK
"/api/conflicts":
get:
tags: [conflicts]
+230
View File
@@ -0,0 +1,230 @@
<!-- Gate-checked by TestUIControlLabels (internal/docs/ui_labels_test.go):
every `controls` entry must exist as a literal in templates/*.templ
(or web/src for source: ts). Renames fail make test. Entry text is the
canonical spelling cited across the docs. -->
# UI controls glossary
The canonical spellings for controls cited across the UI documentation. Docs
quote the label exactly as this page lists it — one entry per control; two
spellings of one control is a defect. The `controls` block is machine-read:
when a doc first names a control, add its entry in the same commit; when a
rename lands, update the entry in the same commit and the gate goes green
again.
Entry fields:
- `label` — the exact rendered text users see
- `where` — semantic path (page → section/card), never spatial; see the
[writing standard](writing-ui-docs.md)
- `does` — what interacting with it produces (the outcome)
- `source` — optional; default `templ` (`templates/*.templ`); `ts` searches `web/src`
- `also` — optional, pipe-separated extra literal fragments to search when
the visible label is Alpine-composed rather than static (OR-checked with `label`)
- `note` — optional clarifier
Form-field labels inside a single modal (Name, Description…) are cited
inline in docs and not tracked here; this list carries the controls people
navigate by.
## Dashboard, profile & podcasts
```controls
label: Customize Dashboard
where: Library bar on the dashboard (button); opens the Customize Dashboard panel
does: Opens per-library shelf arrangement — drag order, show/hide switches, items per section.
label: Save Changes
where: Customize Dashboard panel → submit
does: Saves that library's shelf arrangement.
label: Refresh
where: Library bar on the dashboard (button)
does: Re-reads libraries without a full page reload.
note: icon button with a tooltip title
label: Create Custom Section
where: the custom-section builder page (/custom-section) → page title
does: Page where a dashboard shelf is defined by filter rules or hand-picked books.
label: Update Password
where: profile page → Change Password → submit
does: Saves the new password after live requirement checks.
label: Remove My Account
where: profile page → Danger Zone
does: Deletes the account and all personal data after confirmation; blocked for the last admin.
label: Add Feed
where: Podcasts page
does: Opens the Add Podcast Feed panel.
label: Add & Subscribe
where: Add Podcast Feed panel → submit
does: Subscribes the feed and starts watching it for new episodes.
also: Add &amp; Subscribe
note: templ source stores the ampersand entity; the rendered label reads "Add & Subscribe"
label: Download all episodes now
where: Add Podcast Feed panel
does: Grabs the feed's back catalog immediately on subscribe.
label: Player settings
where: audio player → settings control
does: Opens the per-player preferences — Autoplay on open, Default playback speed, Resume rewind.
also: title="Settings"
label: Shortcuts & interactions
where: audio player → the ? control
does: Lists every keyboard shortcut the player supports.
also: Shortcuts &amp; interactions
note: templ source stores the ampersand entity
label: Chapters
where: audio player → chapter-list control
does: Opens the chapter list and shows the current chapter position.
note: title attribute on the control
label: Bookmarks
where: audio player → bookmarks control
does: Opens your markers for this title; tap one to jump back.
note: title attribute on the control
```
## Collections
```controls
label: New Collection
where: Collections page
does: Opens the create-collection modal.
note: the empty page offers the same action as "Create Your First Collection"
label: Create Your First Collection
where: Collections page, when no collections exist
does: Opens the create-collection modal.
label: Create Collection
where: create-collection modal → submit
does: Saves the new collection and returns to the Collections page.
label: Update Collection
where: edit-collection modal → submit
does: Saves your changes to the collection.
label: Static
where: create-collection modal → Type
does: Chooses a hand-picked collection — you add and remove items yourself.
label: ⚡ Smart
where: create-collection modal → Type; also shown as a badge on smart collections page and detail
does: Chooses a rules-matched collection — its membership stays current by itself.
label: Rules scope
where: create-collection modal, when Smart is chosen
does: Asks what the rules ask about — the two buttons below it.
also: 📚 Books|🎧 Podcast episodes
note: "Books" and "Podcast episodes" as the two scope buttons
label: Restore System
where: Collections page
does: Opens a pick-a-shelf modal to bring back a system collection you deleted.
label: Restore
where: Restore System Collection modal → submit
does: Brings the selected system collection back.
label: Edit Rules
where: collection detail page
does: Opens the smart-collection rules editor for this collection.
label: Filter tree
where: smart-collection rules editor
does: The area where the group and rules are built.
note: read/write rules — the how is the filter guide
label: Apply to Collection
where: smart-collection rules editor → save
does: Saves the filter tree and makes the collection match those rules from now on.
label: Add Items
where: collection detail page
does: Opens the item picker window.
label: Add Selected
where: item picker → footer
does: Adds everything checked in the picker to the collection.
label: Remove Selected
where: collection detail page
does: Opens a confirm dialog to remove the checked items from the collection.
label: Search within collection...
where: collection detail page → search box
does: Filters the displayed items — nothing is removed.
label: Play as Sequence
where: collection detail page (when it has a downloaded episode)
does: Plays the collection's episodes one after another in the listening order you pick.
label: Hide finished books (reading-queue mode)
where: edit-collection modal
does: Finished books disappear from the collection's shelf.
label: Show Up Next strip
where: edit-collection modal
does: Adds this collection's Up Next strip to the podcast page.
```
## Everywhere else
```controls
label: Filters
where: All Books / Podcasts page → toolbar
does: Opens the filter drawer for building and applying rules.
label: Register Device
where: Devices page
does: Submits a device registration request for the pending-approval flow.
label: Download All
where: Podcast page → episodes area
does: Queues every episode of the feed for download.
label: Download order
where: Add Podcast Feed panel; podcast feed settings
does: Sets the ordering used when feed episodes are downloaded.
label: Match ALL / Group ANY header
where: filter drawer → group header (also smart collections → Rules and dashboard sections)
does: Sets how the group combines its rules; clicking the header flips ALL/ANY.
also: ? 'Match' : 'Group'|? 'ANY' : 'ALL'
note: label is Alpine-composed — 'Match' for the whole builder, 'Group' for nested; docs write it as MATCH ALL / MATCH ANY
label: Reset to Scanned
where: book detail → edit modal → scan information area
does: Restores scanner-extracted metadata over manual edits.
label: Auto-Scan
where: Settings page → Scanning section
does: Enables automatic periodic library scanning on the interval below it.
label: Scan Interval (seconds)
where: Settings page → Scanning section
does: Sets the seconds between automatic scans.
label: Resume rewind
where: audio player → player settings; profile settings
does: Rewinds playback a configured few seconds when you resume.
also: Resume Rewind
note: profile page spells it capitalized
label: Reading Direction
where: book detail → edit modal
does: Chooses the direction pages advance (publisher convention — e.g. manga).
label: Scan Info
where: book detail page → info block
does: Displays scanner-supplied notes attached to the item.
also: Scan Information
note: the editable form label in the modal reads "Scan Information"
```
+117
View File
@@ -0,0 +1,117 @@
# Writing UI documentation without screenshots
Bookhoard's UI documentation uses no screenshots. This page is the standard
for writing them. The flagship example of the style is the
[filter system documentation](../user/filters/index.md): one system taught
once, the places it appears listed as a *pattern*, samples for every field in
a [generated reference](../user/filters/fields.md) that cannot drift from the
UI.
## Why no screenshots
Screenshots fail in every way the rest of this repo's rot is already gated
against:
- **They rot silently.** No gate can diff pixels against the running UI. A
renamed button makes the screenshot wrong and nothing fails.
- **They cannot be verified.** Shown text can: this app's UI is
server-rendered from `templates/*.templ`, so every stable label and heading
is a literal in the source — checkable, like routes and API examples are.
- **They die with a redesign.** Text keyed to *function* survives a
re-layout. Text keyed to a layout dies with its redesign.
- **They exclude** (screen readers, text search) and bloat diffs.
The one thing screenshots do better — "which button is it?" — text does via
**exact quoted labels** plus a semantic path, and the label gate then holds
that text against the source.
## The layering — what is load-bearing
Write every UI page in this order. The top layers are why users don't get
lost when the UI changes:
| # | Layer | Content | Lifespan |
| --- | --- | --- | --- |
| 1 | **The pattern** | What the thing *is*, what is invariant, every place the same pattern appears | Redesign-proof |
| 2 | **Function inventory** | What the user *can do*, phrased without control names | Redesign-proof |
| 3 | **How-to with examples** | Numbered flows: current label → the outcome it produces | Survives most changes |
| 4 | **Current labels** | Exactly quoted, bold | Gate-checked today |
| 5 | **Generated references** | Enumerables regenerated from source with a currency test | Cannot drift |
Labels are **tips, not the map**. Layer 3 may quote a label; layer 1 must
make the page followable even if that label is renamed next week.
## The page template
1. **The pattern first.** The concept, its invariants, and everywhere it
appears — "one system, three places". Never page cartography.
2. **Function inventory.** Capabilities without control names: "you can pin
any narrowing to a named filter you can reopen later".
3. **How-to with examples.** Each step: the label of what you interact with,
then the outcome ("the count at the bottom shows how many books will
match"). An example beats an adjective.
4. **Findability note.** One line telling the reader how to re-locate the
feature *by function again* after any UI change — deliberately
label-independent ("it's the drawer opened from any books or podcasts
list — the only place whose header can read Match ALL").
5. **Current labels.** Quoted exactly as rendered, one canonical spelling
per control ([arbitrated by the glossary](ui-controls.md)).
## Writing rules
- **Semantic placement, never spatial.** No "top-right" or "at the bottom of
the screen" talk. "Devices page → the **Register Device** button" survives
any re-layout.
- **One canonical label per control.** Two spellings of the same control is
a defect, not a synonym.
- **Status updates get a what-it-means table**, not a color description.
- **Screenshots are never load-bearing.** Default is none.
## Tone by audience
- **User Guide (`docs/user/`) — plain language, deliberately.** Short
sentences. Everyday words. No developer jargon; any term the reader
genuinely needs (say, *rule*) is explained once, in place, then used
consistently. The reader is assumed to know books, while the app's own
words are the only technical vocabulary allowed.
- **Developer and Administration sections — tone is a separate discussion,
not yet decided.** Write them plainly and hand-off-rich as well; do not
import opinions about their voice until that pass happens.
## Generate what has a registry
Anything enumerable gets generated from source with a currency test, not
hand-typed. The precedent: every filter field is
[generated](../user/filters/fields.md) by `TestFieldReferenceDocIsCurrent`
from the same registry the menus render — the doc cannot disagree with the
UI. Hand-typed lists drift fastest; when no registry exists yet, say so in
the page and lean on the pattern layers.
## The gates
- `TestUIControlLabels` (`internal/docs/ui_labels_test.go`): every control in
the [glossary](ui-controls.md) must exist as a literal in
`templates/*.templ` (or `web/src` for TS-rendered surfaces, marked
`source: ts`). Renames fail `make test` in the same commit as the rename.
- `TestDocsRelativeLinks`: every relative link in the docs tree resolves to
a real file.
Naming a new control in prose? Add its glossary entry in the same commit.
Form labels inside one modal (Name, Description…) are cited inline and not
glossary-tracked; the glossary carries the controls people navigate by —
buttons, tabs, badges, page titles.
## The process rule
**A UI change is not finished until the docs audit runs.** Moved card,
renamed button, restructured page — all of it triggers: labels still
resolve, links still resolve, affected pages re-read against the new
surface. The pattern layers keep users oriented between audits; the audit
keeps layers 3–5 honest.
## What this does not replace
The filter documentation (and the older filtering overview) is hands-off
exemplar territory — read it as the model, never restyle it here. New UI
surfaces (scanning, devices, settings, podcasts, the reader, collections)
are written this way from the start.
@@ -623,6 +623,6 @@ This is a great example of how user feedback can improve implementation design!
## See Also
- [User Guide: Calibre Integration](../../user/calibre-integration.md)
- [Implementation Plan](../../CALIBRE_OPF_IMPLEMENTATION.md)
- [Scanner Source Code](../../internal/services/media_scanner.go)
- [User Guide: Calibre Integration](../user/calibre-integration.md)
- The original implementation plan file was removed from the repo when this page absorbed its content.
- The scanner implementation lives in `internal/services/` (not hyperlinked — the docs site serves only docs).
+43 -80
View File
@@ -13,57 +13,60 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- **Device Setup**
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
- [Android App](user/devices/android-app.md)
- **Sync Configuration**
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
- **Frontend & Settings** (Coming Soon)
- Frontend Guide
- User Areas Guide
- Settings Guide
- Admin Guide
- **Filters**
- [Filters — the complete guide](user/filters/index.md) - rules, groups (ALL/ANY), saved filters, smart collections
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
- **Guides**
- [Reader Guide](user/reader-guide.md) - reading, highlights, notes
- [Library Browsing](user/library-browsing.md) - browsing and searching your library
- [Collections](user/collections.md) - manual and ⚡ smart shelves, books and episodes
- [Podcasts](user/podcasts.md) - feeds, downloads, listening
- [Dashboard](user/dashboard.md)
- [Themes](user/themes.md)
- [Calibre Integration](user/calibre-integration.md)
- [Profile Guide](user/profile-guide.md)
- [Admin Guide](user/admin-guide.md)
### 👨‍💻 For Developers
**[Developer Documentation Portal](developer/development.md)** - Technical documentation & API reference
- **API Documentation**
- [Complete API Reference](developer/api-reference.md) - Monolithic REST API reference (1,300+ lines)
- [Split Endpoint Docs](developer/api/api-reference.md) - Individual endpoint docs
- [Collections API](developer/api/collections/list_collections.md) - Collections management API
- [Admin API](developer/api/admin/) - User management (admin operations)
- [Media Items API](developer/api/media-items/) - Media item operations including bulk operations and downloads
- [Devices API](developer/api/devices/) - Device sync and shelf management
- [Conflicts API](developer/api/conflicts/) - Sync conflict resolution endpoints
- [Queue API](developer/api/queue/) - Sync queue management endpoints
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
- [WebSocket API](developer/api/websocket/) - Real-time sync events
- **API Documentation** — every endpoint is documented individually; the links below are entry points
- [API Reference overview](developer/api/api-reference.md) - index of all endpoint docs
- [Collections API](developer/api/collections/list_collections.md) - collections management API
- [Media Items API](developer/api/media-items/list_media_items.md) - media item operations including bulk operations and downloads
- [Devices API](developer/api/devices/list_devices.md) - Device sync and shelf management
- [Conflicts API](developer/api/conflicts/list_conflicts.md) - Sync conflict resolution endpoints
- [Queue API](developer/api/queue/list_all_queue_items.md) - Sync queue management endpoints
- [Scanner API](developer/api/scanner/overview.md) - Library scanning and automated watch mode (admin)
- [KOReader API](developer/api/koreader/get_library.md) - KOReader sync protocol endpoints
- [Kobo API](developer/api/kobo/sync_from_server.md) - Kobo sync protocol endpoints
- [Admin API](developer/api/admin/list_users.md) - User management (admin operations); content-duplicate cleanup lives in [hash conflicts](developer/api/admin/hash_conflicts_api.md)
- **Protocol Specifications**
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
- [WebSocket API](developer/websocket-api.md) - Real-time events
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
- **Writing UI docs?** Read [Writing UI documentation without screenshots](developer/writing-ui-docs.md) first.
### 🔧 For Operations
**[Operations Documentation Portal](operations/operations.md)** - Deployment & maintenance
- **Deployment**
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
- **Maintenance** (Coming Soon)
- Deployment Guide
- Maintenance Guide
- Monitoring Guide
### 🤝 For Contributors
**[Contributing Portal](contributing/contributing.md)** - Development workflow
- [Development Guide](contributing/development.md) - Architecture, setup, testing
- [../../AGENTS.md](../../AGENTS.md) - Development rules and standards
- [Development Guide](contributing/development.md) - Architecture, setup, testing, and the project rules
---
@@ -71,11 +74,11 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| Want to... | Go to |
| -------------------- | ---------------------------------------------------------------------- |
| **Get started** | [README.md](../README.md) - Project overview and quick start |
| **Get started** | [Development Guide](contributing/development.md) - Architecture and quick start |
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
| **Use the API** | [API Reference overview](developer/api/api-reference.md) |
| **Deploy Bookhoard** | [Operations → Troubleshooting](operations/troubleshooting.md) |
| **Contribute code** | [Development Guide](contributing/development.md) |
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
@@ -87,65 +90,25 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
| Question | Answer |
| --------------------------- | ------------------------------------------------------ |
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
| ...use the API? | [API Reference](developer/api-reference.md) |
| ...use the API? | [API Reference overview](developer/api/api-reference.md) |
| ...write UI docs? | [Writing UI documentation](developer/writing-ui-docs.md) |
| ...contribute code? | [Development Guide](contributing/development.md) |
### "Where is..."
| Information | Location |
| ---------------------- | ----------------------------------------------------------- |
| ...features list? | [README.md](../README.md) |
| ...database schema? | [database/schema/schema.sql](../database/schema/schema.sql) |
| ...API endpoints? | [API Reference](developer/api-reference.md) |
| ...secrets config? | [.env.example](../.env.example) |
| ...operational config? | [docker-compose.yml](../docker-compose.yml) |
| ...deployment issues? | [Troubleshooting Guide](operations/troubleshooting.md) |
---
## 🎯 Common Workflows
### Set up a new device
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
2. Understand sync: [Sync Guide](user/sync-guide.md)
3. Troubleshoot: Device-specific guides
### Integrate with Bookhoard API
1. Start with [API Reference](developer/api-reference.md)
2. Explore [Collections API](developer/api/collections/list_collections.md)
3. Review [WebSocket API](developer/websocket-api.md)
4. Test with [bruno/](../bruno/) collections
### Deploy to production
1. Follow [README.md](../README.md) quick start
2. Configure environment: [.env.example](../.env.example)
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
4. Check [Development Guide](contributing/development.md) for performance tuning
---
## 📝 Contributing to Documentation
When adding new features:
1. **User-facing features** → Update relevant User docs
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
3. **Backend changes** → Update [Development Guide](contributing/development.md)
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
Keep [../../AGENTS.md](../../AGENTS.md) in mind for documentation standards.
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
1. **User-facing features** → Update the relevant User docs
2. **API endpoints** → Update the endpoint docs under `developer/api/` (start from the [API Reference overview](developer/api/api-reference.md))
3. **Backend changes** → Update the [Development Guide](contributing/development.md)
4. **Deployment changes** → Update the [Operations Portal](operations/operations.md)
5. **New UI controls in docs** → Add them to the [UI controls glossary](developer/ui-controls.md)
6. **UI appearances in docs** → Follow [Writing UI documentation without screenshots](developer/writing-ui-docs.md)
+3 -3
View File
@@ -15,19 +15,19 @@ Welcome to the Bookhoard operations documentation. This section contains guides
### Additional Deployment Guides
**[Deployment Guide](deployment.md)** - Comprehensive deployment guide
**Deployment Guide** - Comprehensive deployment guide
- _Coming Soon_
## 🛠️ Maintenance
**[Maintenance Guide](maintenance.md)** - Ongoing operations and maintenance
**Maintenance Guide** - Ongoing operations and maintenance
- _Coming Soon_
## 📊 Monitoring
**[Monitoring Guide](monitoring.md)** - Monitoring and alerting
**Monitoring Guide** - Monitoring and alerting
- _Coming Soon_
+1 -1
View File
@@ -13,4 +13,4 @@ This guide will cover:
---
_In the meantime, check out our [Operations Portal](../operations/)_
_In the meantime, check out our [Operations Portal](../operations/operations.md)_
+4 -5
View File
@@ -139,7 +139,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Steps**:
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
2. Trigger a scan from the admin interface's scan controls, or let watch mode pick up the changed files automatically
3. Bookhoard imports all 500 books with:
- Correct titles and authors
- Series information (e.g., "Harry Potter #2")
@@ -168,7 +168,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Steps**:
1. Edit metadata in Calibre (it updates `metadata.opf`)
2. In Bookhoard, trigger a rescan:
- Via the **Scanner API** (`POST /api/scanner/scan`), or
- Via the admin interface's scan controls, or
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
@@ -183,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
**Solutions**:
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
3. **Force rescan**: Trigger a rescan from the admin interface to re-import all metadata (watch mode also picks up changed files automatically)
4. **Check logs**: Review Bookhoard logs for parsing errors
### Incorrect Metadata
@@ -332,11 +332,10 @@ file is never touched if anything fails (temp file + atomic rename).
- [Calibre Website](https://calibre-ebook.com/)
- [Calibre User Manual](https://manual.calibre-ebook.com/)
- [Calibre Forum](https://www.mobileread.com/forums/forumdisplay.php?f=166)
- [Bookhoard GitHub](https://github.com/yourusername/bookhoard)
## Need Help?
- **Documentation**: See [User Guide](user-guide.md) for general Bookhoard usage
- **Troubleshooting**: See [Operations Guide](../operations/operations.md) for common issues
- **Developer Docs**: See [Developer Portal](../developer/development.md) for technical details
- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/yourusername/bookhoard/issues)
- **Issues**: Report bugs or request features on your repository's issue tracker
+101 -25
View File
@@ -1,42 +1,118 @@
# Collections
Collections allow you to organize books across multiple libraries.
A collection is a personal shelf you name yourself. You put your books and
podcast episodes on it, and it appears as a section on your dashboard.
## Viewing Collections
That is the whole idea. Everything below is variations on it.
### From Collections Page
## The one pattern, four flavors
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
Every collection behaves the same way on the outside: it has a name, an icon,
a color, and a list of items. The flavors differ in how items *join*:
### From Dashboard
| Flavor | How items join | Right for |
| --- | --- | --- |
| **Static** | You pick each item by hand | Curated lists — "books for the trip", "new house ideas" |
| **⚡ Smart** (books) | Rules match books live, forever | "everything by Asimov", "still not started", "over 400 pages" |
| **⚡ Smart** (podcast episodes) | Rules match episodes live — and they can play back-to-back | "catch up on my feeds", "episodes to finish" |
| **System** (built-in) | Bookhoard maintains them | Continue Reading, Recently Added, Recently Read, Not Started, Continue Series |
When viewing a specific library's dashboard, collections only show books from that library. Clicking "View All" preserves this context and shows only books from that library in the collection.
A collection holds **books and podcast episodes** — mix them if you like.
### Library Filtering
The **⚡ Smart** badge means the same thing everywhere it appears: *membership
is matched by rules right now — always current, nothing to refresh*.
- **No filter** (`/collections/favorites`): Shows all books across all libraries
- **With filter** (`/collections/favorites?library_id=xxx`): Shows only books from the specified library
## Creating a collection
## Creating Collections
1. Open the **Collections** page (sidebar navigation)
2. Click **New Collection**
3. Choose the **Type**:
- **Static** — pick items by hand
- **⚡ Smart** — match by rules, always live
4. If you picked **⚡ Smart**, choose the **Rules scope**: **📚 Books** or
**🎧 Podcast episodes**
5. Fill in the rest: **Name**, **Description**, an **Icon** you can search or
pick, and a **Color**
6. Click **Create Collection**
1. Go to **Collections** (sidebar navigation)
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
3. Fill in the details:
- **Name** - Collection name
- **Description** - Optional description
- **Icon** - Pick from the icon grid
- **Color** - Pick a color swatch
4. Click **Create Collection**
Starting from a completely empty account? The button **Create Your First
Collection** does the same thing.
## Managing Collections
## Adding items by hand (Static collections)
Each collection in the list has icon buttons on its card:
1. Open the collection from the **Collections** page
2. Click **Add Items**
3. Pick the tab for what you want:
- **📚 Books** — searchable by the **Search books...** box, and the
**Author** and **Genre** boxes
- **🎧 Episodes** — the episodes of the feeds you subscribe to
4. Click one or more items — a checkmark marks what you picked; the bottom
of the window counts them for you
5. Click **Add Selected**
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
- **Delete** - Removes the collection after a confirmation prompt.
To take items out, check them on the collection page and click
**Remove Selected** (it asks first).
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
While checking what is in the collection, the **Search within collection...**
box narrows the view — it never removes anything.
### Dashboard Sections
## Rules (how ⚡ Smart collections work)
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
A smart collection keeps its own list of rules, the same rules that power the
filter drawer. On the collection's page, click **Edit Rules** to open the
rules editor: build your groups and rules in the **Filter tree**, then click
**Apply to Collection**. From that point the collection matches those rules
by itself — add a book that matches and it appears without you doing
anything.
Rules live in one guide, so this page does not repeat them. The full how-to
walks through groups, ALL/ANY, and value picking in
[Filters — the complete guide](filters/index.md), ready-made examples are in
the [cookbook](filters/cookbook.md), and
[every filter field](filters/fields.md) lists what you can ask about.
*Finding it again later:* open any collection's page and look for the
**Edit Rules** button; the **⚡ Smart** badge in the page title marks a
collection that is already matching.
## Playing an episode collection
When a collection holds a downloaded episode, it grows a **Play as Sequence**
button: the episodes play one after another. Next to it, the order picker
decides the sequence — **Feed order**, **Feed order (reversed)**,
**Oldest first (pub date)**, or **Newest first (pub date)**. Your choice is
remembered on the collection.
## Changing a collection
The pencil button on a collection card, or the edit control on the
collection page, opens the edit form:
- Change the **Name**, **Description**, **Icon**, **Color**
- **Hide finished books (reading-queue mode)** — finished books drop off
this shelf, which keeps a to-read list short
- **Show Up Next strip** — show the collection's Up Next strip where it is
used
Click **Update Collection** to save. Deleting a collection asks first, and
deleting only removes the shelf — never the books or episodes inside it.
## System collections
The five built-in shelves — **Continue Reading**, **Recently Added**,
**Recently Read**, **Not Started**, **Continue Series** — work like any
collection on the outside, but Bookhoard decides their membership by what
happens while you read. They appear on your dashboard automatically.
Deleted one and want it back? Click **Restore System** on the **Collections**
page, pick the shelf from the list, and click **Restore**.
## Your dashboard shelves
Collections appear as sections on your dashboard, and you choose which ones
show and in what order from the dashboard's own settings. That page's
details live in [Dashboard](dashboard.md).
*Finding your way back:* **Collections** is the sidebar page whose top has
the only **New Collection** button — from a dashboard shelf's **View All**,
the collection detail page is the place with **Add Items**.
+45 -35
View File
@@ -1,52 +1,62 @@
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
The dashboard is your home screen: horizontal shelves ("sections") of your
books, one row after another. What each shelf contains depends on the library
you are looking at — including the **All Libraries** view, which mixes
everything you can see.
## Sections
## The three kinds of section
### Smart Sections
| Kind | What fills it |
| --- | --- |
| **System shelves** | Bookhoard maintains them from your reading: **Continue Reading**, **Recently Added**, **Recently Read**, **Not Started**, **Continue Series** |
| **Your collections** | Every collection you create appears as its own shelf |
| **Custom sections** | Shelves you define yourself — by filter rules or by hand-picking books |
Smart sections are automatically generated based on your reading activity:
System shelves and collections are covered by their own guides (see
[Sync Guide](sync-guide.md) for what "continue reading" means across devices,
and [Collections](collections.md) for the shelves you build). This page is
about arranging the rows.
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
- **In Progress**: Books you've started (progress > 0%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've completed (100% progress)
- **Not Started**: Books you haven't read yet
### This Week
### User Collections
The dashboard also shows a small **This Week** summary — your reading time
and activity for the week so far. Click it to expand the detail.
Your collections appear as sections on the dashboard. To show or hide a collection's section:
## Customizing your shelves
1. Select the library in the **Library** bar
2. Click the **Customize Dashboard** icon button
3. Toggle the collection on or off
4. Click "Save Changes"
1. Pick the library in the **Library** bar (a specific library, or
**All Libraries**) — settings are saved per library
2. Click the **Customize Dashboard** button in the Library bar
3. In the **Customize Dashboard** panel: drag sections into the order you
want, switch sections on or off, and set how many items each section
shows (10–50)
4. Click **Save Changes**
### Customizing Your Dashboard
The **Refresh** button next to it re-reads your libraries without a full
page reload.
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
3. **Drag sections** to reorder them
4. **Toggle visibility** with the switches
5. **Adjust items per section** (10-50 items)
6. Click "Save Changes"
## Custom sections
Settings are saved per library.
A custom section is a shelf whose contents you define — either with the same
filter rules the **Filters** drawer uses, or by picking the books yourself.
You build one on the **Create Custom Section** page (at `/custom-section` on
your server); once saved it behaves like any other shelf and can be ordered
and hidden with everything else.
### Library Switching
## Reading and listening from the dashboard
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
Point at a shelf and chevron buttons appear to scroll it. Every item carries
a direct action: **Read** opens the web reader, **Listen** opens the audio
player. If the same book has conflicting progress from two devices, the item
grows a resolve action that takes you to conflict resolution — see the
[Sync Guide](sync-guide.md).
### Keyboard Navigation
## Keyboard and touch
- **Tab**: Navigate between sections and books
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book
- **Tab** moves between shelves and books; **Enter** opens the focused book
- **Arrow keys** scroll the focused shelf sideways
- On touch: swipe a shelf to scroll it, tap a cover to open it
Hovering a carousel shows chevron buttons on either side for scrolling.
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details
*Finding your way back:* the dashboard is the page with the **Library** bar —
the only place with the **Customize Dashboard** button.
+26 -19
View File
@@ -1,40 +1,47 @@
# Kobo Device Setup Guide
> ## 🚧 Coming Soon
>
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
There are two ways to use a Kobo with Bookhoard, and they can be chosen
independently per device:
## Using a Kobo With Bookhoard Today
| Way | Status | What you get |
| --- | --- | --- |
| **KOReader on the Kobo** | Works today, fully | Reading position, bookmarks, highlights, notes, OPDS wireless book delivery |
| **Stock Kobo firmware (native sync)** | Server support is complete; device-side enablement is the remaining piece | Reading position and stats, bookmarks/highlights/notes, shelf mappings (your collections as Kobo shelves), automatic EPUB → KEPUB conversion, and the Kobo's own sync experience |
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
## Using a Kobo today
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
**KOReader runs on Kobo hardware** and syncs fully with Bookhoard right
now — the [KOReader Setup Guide](koreader-setup.md) walks the whole path
(plugin install, server approval, OPDS delivery, troubleshooting).
## What's Planned for Native Kobo Sync
## About native Kobo sync
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
The server side of the native protocol is implemented and documented: the
Kobo sync and initialization endpoints, bookmark and markup (highlight and
note) sync, shelf mappings that expose your collections as Kobo shelves,
and the KEPUB sidecar configuration used for conversion. What remains is
the device-side rollout, so stock-firmware sync is not yet something to
rely on for daily use — this guide will gain a full setup walkthrough when
that lands.
- **Reading position sync** — percentages, pages, and reading statistics
- **Bookmarks, highlights, and notes** — synced with the web and other devices
- **OPDS wireless delivery** — browse and download books directly on the Kobo
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
Nothing about native sync takes anything away from KOReader: the two can
coexist, and a KOReader setup keeps working either way.
## FAQ
**Q: Should I buy a Kobo to use with Bookhoard today?**
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
A: Yes, via KOReader — it is the supported path today and covers sync plus
wireless book delivery.
**Q: What happens to my KOReader setup when native sync arrives?**
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
**Q: Will I have to redo anything when native sync lands?**
A: No — KOReader keeps working; native sync just adds the stock-firmware
option.
## Additional Resources
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
- [Bookhoard Universal Sync Guide](../sync-guide.md)
- [Bookhoard API Reference](../../developer/api-reference.md)
- Kobo sync protocol (for developers): [Kobo API docs](../../developer/api/kobo/sync_from_server.md)
---
+134 -28
View File
@@ -1,16 +1,24 @@
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, approval
flow) + the server-side koreader endpoints. Re-verify against the plugin
source when the plugin changes. -->
# KOReader Device Setup Guide
This guide will help you set up KOReader on your e-reader device to sync with Bookhoard.
This guide will help you set up KOReader on your e-reader device to sync with
Bookhoard — reading position, bookmarks, highlights, notes, and even your
ratings, reviews, and reading time.
## What is KOReader?
KOReader is an open-source e-reader application that supports a wide range of e-reader devices including:
KOReader is an open-source e-reader application that supports a wide range of
e-reader devices including:
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
- Kobo devices (Clara, Aura, Nia, etc.)
- PocketBook devices
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
It also runs on Android tablets and phones — though on Android the dedicated
[Bookhoard app](android-app.md) is the better option.
## Prerequisites
@@ -67,17 +75,27 @@ Before you begin, make sure you have:
## Connecting KOReader to Bookhoard
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
Setup is done **on the server**: you approve the device from the Bookhoard
web interface — no usernames, passwords, or tokens to type on the device.
### Step 1: Install the Bookhoard Plugin
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
2. Copy it to your KOReader `plugins/` directory
3. Restart KOReader
Installation is a git clone straight into your KOReader `plugins/` folder —
no download-and-copy step:
```bash
cd koreader/plugins # .adds/koreader/plugins on Kobo
git clone https://git.linuxhg.com/Bookhoard/bookhoard.koplugin
```
Then restart KOReader.
**Updating later** is the same, smaller: in the `bookhoard.koplugin` folder,
`git pull`, then restart KOReader.
### Step 2: Point the Plugin at Your Server
1. Open KOReader, tap the **wrench icon** at the top
1. Open KOReader, tap the **wrench icon** at the top (the tools menu)
2. Find and tap **Bookhoard sync**
3. Tap **Server URL**, enter your server address, then tap **OK**:
@@ -87,34 +105,116 @@ Setup is done **on the server**: you approve the device from the Bookhoard web i
Use your server's LAN IP (or domain if you have one configured).
### Step 3: Approve the Device in Bookhoard
### Step 3: Register the Device
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
2. Refresh the page — your device appears under **Pending Device Registrations**
3. Click **Approve** to connect the device
1. In the same **Bookhoard sync** menu, tap **Register device**
2. On your computer or phone, open Bookhoard and go to the **Devices** page
(sidebar navigation)
3. Your device appears under **Pending Device Registrations** — click
**Approve** to connect it
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
The plugin waits on the device and finishes by itself the moment you
approve. Once approved, its credentials are stored automatically, sync
events are armed, and Bookhoard's OPDS catalog is added to your KOReader —
no further configuration is needed.
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
> **Note:** Pending registrations expire after 5 minutes. If yours expires
> ("Registration expired"), just tap **Register device** again and approve.
### Auth Token (Advanced)
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
The Devices page shows each KOReader device's **Auth Token**. You normally
never need it (the plugin receives it automatically during approval), but it
can be re-entered manually in the plugin settings if you're moving a setup
between devices or debugging.
## The Bookhoard sync menu
Everything the plugin can do lives in one place: **wrench icon → Bookhoard
sync**. The menu changes shape depending on whether you are connected.
### Before you're connected
- **Server URL** — your server's address
- **Register device** — starts the approval flow above
### Once connected — one-tap actions
| Menu item | What it does |
| --- | --- |
| **Push progress from this device** | Force-push this device's position to the server |
| **Pull progress from server** | Force-pull the server's position onto this device |
| **Sync rating, review & time now** | Push your rating, review, and accumulated reading time for the current book immediately |
| **Setup OPDS catalog** | (Re-)adds Bookhoard's catalog to KOReader's OPDS list — normally done for you at approval |
| **Device info** | Shows this device's ID and the server it talks to |
| **Disconnect** | Forgets the device's credentials (after a confirmation) |
### Automatically push progress
This toggle is the on/off switch for background sync — **it starts off**, so
after approval, turn it on to get automatic syncing. With it on, progress
pushes as you read and on close/suspend; the plugin may prompt about turning
Wi-Fi on at those moments depending on your device.
Two related settings sit under it:
- **Periodically sync every # pages** — how many page turns between updates
(default 50; set 0 to disable periodic updates)
- **Sync mode** — **Immediate** pushes right away, **Checkpoint** batches
changes for slower connections or battery saving
### Sync behavior — who wins when states differ
These two settings decide what the device does when its progress and the
server's disagree, without you opening the Conflicts page:
- **Sync to a newer state** — the server has *newer* progress than this
device (default **Prompt**: the device asks before adopting it; alternatives
are **Silently** or **Never**)
- **Sync to an older state** — this device has *newer* progress than the
server (default **Never**: the device never rolls itself back)
Set both to **Silently** for hands-off syncing, or leave the defaults if you
want a say whenever progress would move.
### What to sync
Seven toggles, all on by default, each controlling one kind of data:
- **Reading progress**
- **Bookmarks**
- **Highlights**
- **Notes**
- **Ratings** — your book ratings made in KOReader
- **Book review** — your review text
- **Reading time** — time-spent statistics
Turn off anything you'd rather keep local; the rest keeps flowing both ways.
## What Syncs
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
Once connected and enabled, the following sync in both directions between
KOReader and Bookhoard (web, Android, and other devices), each governed by
its toggle above:
- **Reading position** — percentage, chapter, and EPUB CFI where available
- **Bookmarks**
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
- **Highlights** — including highlight colors, mapped between the web and
KOReader palettes
- **Notes** — standalone and attached to highlights
- **Ratings, review, and reading time** — pushed to the server (push any
time from the menu with **Sync rating, review & time now**)
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
Books are matched automatically using UUIDs, file hashes (SHA-256,
format-aware so converted files still match), file aliases, and
title/author fallback. If a book can't be matched, it shows up under the
device's **Unlinked Books** in Bookhoard, where you can link it manually.
## OPDS Wireless Book Delivery
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
At approval the plugin adds Bookhoard's OPDS catalog to KOReader (and
**Setup OPDS catalog** re-adds it any time), so you can browse and download
books wirelessly — no USB cable needed.
### Browse and Download Books
@@ -129,7 +229,8 @@ Once your device is approved, the plugin also registers Bookhoard's OPDS catalog
- **PDF**: Fixed-layout documents
- **CBZ**: Comic book archives
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
Books downloaded via OPDS are automatically matched to your library, so
their progress syncs from the first page.
## Troubleshooting
@@ -142,7 +243,7 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
1. Verify the Server URL is correct (no trailing path — just the base address)
2. Make sure KOReader is connected to Wi-Fi
3. Check the Bookhoard server is reachable from the device's network
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
4. Registrations expire after 5 minutes — tap **Register device** again and approve quickly
### Connection Refused
@@ -161,11 +262,18 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
**Solutions**:
- Trigger a manual sync from the plugin menu
- **Automatically push progress** defaults to off — turn it on in the menu, or push once with **Push progress from this device**
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
- Verify the book appears as an unlinked book for the device and link it if needed
- Check Bookhoard server logs for errors
### Turning on auto-sync asks about Wi-Fi
If enabling **Automatically push progress** shows a message about
**"Action when Wi-Fi is off"**, open KOReader's Network settings and set
that action to **turn on** — auto-sync needs permission to bring Wi-Fi up by
itself.
### Conflicts Detected
**Problem**: Sync conflicts when reading the same book on multiple devices
@@ -174,7 +282,10 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
2. Review the progress reported by each device
3. Choose which device's progress to keep
3. Choose a resolution: keep one side's progress, keep the server's, or let the furthest progress win
For day-to-day control of the same question on the device itself, use the
**Sync behavior** settings (newer state / older state).
## Security Best Practices
@@ -189,8 +300,3 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
- [Bookhoard Universal Sync Guide](../sync-guide.md)
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
---
**Last Updated**: August 2026
**Bookhoard Version**: 1.0
+78
View File
@@ -0,0 +1,78 @@
# Filter cookbook
Real questions, exact clicks. Every recipe uses the same builder described in
[the guide](index.md) — same fields, same rules, wherever filters live.
Recipes marked **collection** become smart collections that stay current forever; the rest are
drawer filters you can save with a name.
---
## "New chapters of 7th Son, automatically" — collection
1. **Collections** → open (or create) a collection → **Rules**.
2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick
*7th Son: Book One* (it becomes a chip — hit the × if you pick wrong).
3. Add a second rule: **Title** → **contains** → `Chapter`.
4. The header should read **MATCH ALL** (it does by default — every rule must match).
5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order
starts at **Feed order (reversed)** — chapter 1 first.
---
## "Lewis Carroll or George Orwell" — drawer
1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`.
2. Add a second rule: **Author** → **contains** → `Orwell`.
3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND`
chip between the two rules, same effect).
4. **Apply Filters**. Either author is enough.
*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter
will tell you so before you apply.*
---
## "Long fantasy books I haven't started" — drawer
1. **Genre** → **is** → `fantasy`.
2. **Page count** → **at least** → `400`.
3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the
status list is the fixed dropdown on that field).
4. **MATCH ALL** stays on: all three must hold.
5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever.
---
## "Downloaded podcast episodes I haven't played" — collection
1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want
whole feeds).
2. **Downloaded** → **is true**.
3. **Played** → **is false**.
4. **MATCH ALL** on. **Apply to Collection**.
---
## "Added to my library this month" — drawer
1. **Date added** → **within the last** → `30` (the days box — the field's page in
[Every filter field](fields.md) has its exact questions).
2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library.
3. Save as *"New arrivals"*.
---
## "A dashboard shelf that builds itself" — dashboard
1. Create a collection with the rules above (or open one).
2. On the dashboard, add a section pointed at that collection.
3. The shelf re-fills itself as your library changes — no maintenance.
---
## Sharing a filtered view
Apply any filter, then copy the address bar. The filter rides in the URL — send it to another
user of your server and they open the same narrowed view (they see it through their own
libraries, not yours).
+755
View File
@@ -0,0 +1,755 @@
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
# Every filter field
This is the complete list of everything the filters can ask about, generated straight from the
same source the app's menus render — it cannot drift from what you see in the UI.
Filters come in two worlds:
- **Books** — rules about books and your reading of them.
- **Episodes** — rules about podcast episodes (feeds, downloaded, played).
Every rule is **a field + a question + your values**. The "Ask" column is the question;
the "Your values" column is what you provide. The **default** question is what a new rule
starts with.
## Books
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Subtitle** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Summary** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Page count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Total characters** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Community rating** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Web URL** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Metadata notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| isn't | matches anything but this |
| has | has a value *(default)* |
| missing | is empty or missing |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Abridged** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manually edited** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Cover** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick |
| has | has a value *(default)* |
| missing | is empty or missing |
### Comics & manga
**Series** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Series number** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Series count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Volume** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
**Reflowable** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Fixed layout** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Black & white** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Reading status** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**Pages read** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### People
**Author** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Narrator** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Contributor** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
### Publishing
**Publisher** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Imprint** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Edition** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
### Topics
**Genre** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Tags** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Story arc** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
| is empty | — |
## Podcast episodes
### Book facts
**Title** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Description** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Language** — Type words.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is | matches exactly |
| isn't | matches anything but this |
| is empty | — |
**Chapter count** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Date added** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**Date imported** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**File size** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**File availability** — Choose from a fixed list of options.
| Ask | Means |
| --- | --- |
| is | matches exactly *(default)* |
| isn't | matches anything but this |
**Explicit** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
### Comics & manga
**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### Episodes
**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| has | has a value |
| missing | is empty or missing |
**Downloaded** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on *(default)* |
| is false | is switched off |
**Played** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| is true | is switched on |
| is false | is switched off *(default)* |
### Library
**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| is | is one of the ones you pick *(default)* |
| isn't | matches anything but this |
### My reading
**Progress percent** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Last read** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
**My rating** — Type a number.
| Ask | Means |
| --- | --- |
| is | matches exactly |
| at least | is at least this *(default)* |
| at most | is at most this |
| unrated | — |
**Has review** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
**Notes** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Highlights** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| has | has a value *(default)* |
| missing | is empty or missing |
| within the last | — |
**Note text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Highlight text** — No value needed — the question is the whole rule.
| Ask | Means |
| --- | --- |
| contains | contains these words *(default)* |
| doesn't contain | does NOT contain these words |
| is empty | — |
**Time spent (minutes)** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several.
| Ask | Means |
| --- | --- |
| in | is one of the ones you pick (any one is enough) *(default)* |
| isn't in | is none of the ones you pick |
| in any | is in at least one of the collections you pick |
| in none | — |
### Publishing
**Published year** — Type a number.
| Ask | Means |
| --- | --- |
| between | falls between two numbers |
| at least | is at least this *(default)* |
| at most | is at most this |
| is empty | — |
**Published date** — Type a number of days.
| Ask | Means |
| --- | --- |
| within the last | — *(default)* |
| between | falls between two numbers |
| is empty | — |
---
*Anything here that doesn't match what the app actually shows is a bug — this page is generated
from the app's own menus and a test fails if they ever disagree.*
+91
View File
@@ -0,0 +1,91 @@
# Filters — the complete guide
Filters answer one question: *of everything in my library, show me the ones that match.*
You'll meet the same filter system in three places:
| Place | What it's for |
| --- | --- |
| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. |
| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. |
| **Dashboard sections** | The same rules, feeding a shelf on your home screen. |
One system, one set of skills, three payoffs.
## Two worlds: Books and Episodes
Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes**
(feeds, downloaded, played) are two different worlds. The field menus only show the fields that
make sense where you are — you'll never see a podcast field on the All Books page. The complete
list of every field is in [Every filter field](fields.md).
## Anatomy of a rule
Every rule is **a field + a question + your values**:
> **Author** *contains* `asc`
> **Feed** *in* `7th Son: Book One`
> **Page count** *at least* `400`
The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*.
Your values are *what counts as a match*. The complete questions per field are in
[Every filter field](fields.md).
## Picking values
Two kinds of value boxes:
- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear,
with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little
**×** to remove it. Pick as many as you like; *any one of them* matching is enough.
- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these
words"; number boxes take numbers; date boxes take a number of days.
The dropdown's counts are **live under your other rules**: if you've already narrowed to
*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds
has. Rules already picked disappear from their own menu (removing a chip brings them back).
## Groups: ALL and ANY
When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they
combine:
- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.)
- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for
"Lewis Carroll **or** George Orwell".
**Click the header to switch it.** It says what it does, and clicking flips it.
`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes
build naturally:
> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title
> contains `Wonderland`)
For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them —
click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these
chips are two different tools: the header sets the group's overall combine; a chip makes just two
neighboring rules special.)
## The live count
While you build, the counter at the bottom — *N books match* — runs your rules against the real
library. It's the exact number you'll get when you apply.
## Defaults worth knowing
- **Podcast listening order**: podcast pages and new podcast collections start at
**Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from
the order dropdown; your choice sticks.
- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library
that's hidden from you.
- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists).
## Keeping a filter
- **Save** (drawer): name it; it appears in the **Load** menu from then on.
- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever.
- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens
the same view.
-16
View File
@@ -1,16 +0,0 @@
# Frontend Guide
**Coming Soon**
This guide will cover:
- Navigating the Bookhoard web interface
- Browsing and searching your library
- Managing collections
- Viewing reading progress
- Using notes and highlights
- Personalizing your experience
---
_In the meantime, check out our [Device Setup Guides](devices/)_
+32 -47
View File
@@ -1,58 +1,43 @@
# Saving Custom Filters
# Browsing your library
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
Everything starts from the **All Books** page: every book you can see in the
current library, as a wall of covers. From there you narrow, sort, and open.
## Bookshelf Toolbar
## The All Books toolbar
The All Books page has a toolbar with:
- The **search box** finds titles, authors, and more as you type
- The **Sort** dropdown orders the wall: **Title (A-Z)**, **Title (Z-A)**,
**Author (A-Z)**, **Date Added**, or **Page Count**
- The **Filters** button opens the filter drawer — authors, tags, series,
progress, and every other field (the full how-to lives in
[Filters — the complete guide](filters/index.md); this page sticks to
browsing)
- **Save**, **Load**, and **Clear** manage your saved filters: capture the
current drawer state under a name, re-apply it any time, and delete ones
you no longer want. Saved filters are private to your account.
- A **search input** for quick text searches
- A **sort** dropdown (title, author, date added, page count)
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
- **Save**, **Load**, and **Clear** buttons for filter presets
Saved filters pair naturally with [smart collections](collections.md): a
filter answers "what matches right now?", a smart collection keeps matching
forever.
## How to Save a Filter
## Opening a book
1. Navigate to the **All Books** page
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
3. Click the **Save** button in the toolbar
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
5. Click **Save**
Click any cover to open the book's detail page — description, files, your
progress, actions to read or listen, and the places to edit metadata or fix
what a scan guessed wrong. The detail page is the hub: series links,
reviews, bookmarks, and the edit modal all live there.
## Loading Saved Filters
## Series
1. Click the **Load** button to open the **Saved Filters** dropdown
2. Click a filter's name to apply it
3. The filter values are applied instantly, without a page reload
A book's detail page links to its **series page** — every book in that
series, in order, for the same library. Series are one of the fields the
filter drawer can match on too.
## Managing Saved Filters
## Podcasts are separate
**Delete a filter:**
Podcast feeds and episodes live on the **Podcasts** page, not the book wall —
see [Podcasts](podcasts.md).
1. Click the **Load** button to open the **Saved Filters** dropdown
2. Click the trash icon next to the filter you want to remove
3. Confirm deletion
**Filter Privacy:**
Saved filters are **private to your account**. Other users cannot see or modify your filters.
## Common Use Cases
**Reading by Genre:**
1. Filter by tag: "Science Fiction"
2. Save as "Sci-Fi Books"
3. Quickly access all your sci-fi collection anytime
**Author Collections:**
1. Filter by author: "Isaac Asimov"
2. Save as "Asimov Books"
3. Switch between different author collections instantly
**Series Tracking:**
1. Filter by series: "Foundation"
2. Save as "Foundation Series"
3. Track your progress through a series
*Finding your way back:* **All Books** is the wall-of-covers page with the
**Filters** button; the same toolbar pattern is what you'll see on the
Podcasts page.
+47
View File
@@ -0,0 +1,47 @@
# Podcasts
Bookhoard is not just books: it also runs your podcast subscriptions. You
subscribe to feeds, episodes download for offline listening, and you can
play a run of episodes back-to-back — a feed, or a hand-picked sequence.
## The Podcasts page
Open **Podcasts** from the sidebar. The page is your subscriptions:
- **Continue** — the episode you were in the middle of, ready to resume
- **All Feeds** — every feed you subscribe to
- **Search** narrows your feeds and episodes
- Each feed's page holds its episodes, with **Download All** for grabbing
everything at once
Subscriptions live in your **Podcasts library** (feeds manage it themselves —
you won't see it in the regular library switcher, and it never mixes into
the book wall on **All Books**).
## Subscribing to a feed
1. Click **Add Feed** on the Podcasts page
2. In the **Add Podcast Feed** panel, paste the show's **RSS feed URL**
3. Choose the **Library** the podcast belongs to
4. Optionally: check **Download all episodes now** to grab the back catalog
immediately, and pick the **Download order** for new episodes — **Feed
order**, **Feed order (reversed)**, **Oldest first (pub date)**, or
**Newest first (pub date)**
5. Click **Add & Subscribe**
From then on Bookhoard watches the feed: new episodes appear as the show
publishes them, downloaded according to the order you picked.
## Playing episodes
Click an episode to open the audio player. The player keeps your place,
supports bookmarks, a chapter list where the show provides chapters, and
playback speed — the same speed and resume-rewind defaults you set on your
[profile](profile-guide.md) apply here.
To play many episodes in a row, use a collection: episode collections have a
**Play as Sequence** button and their own listening order — see
[Collections](collections.md).
*Finding your way back:* **Podcasts** is the sidebar page whose header reads
"Your subscriptions" — the only page with the **Add Feed** button.
+37 -87
View File
@@ -1,99 +1,49 @@
# Profile Management Guide
# Your profile
## Updating Your Profile
Your profile page holds everything that is about *you* rather than your
books: account details, playback preferences, your password, and — if you
ever need it — account deletion.
Your profile contains your account information and preferences.
Open it from the sidebar: click your **username** at the bottom, then
**Profile**.
### How to Update
## Account information
1. Click on your **username** at the bottom of the sidebar to expand the account menu
2. Select **Profile**
3. Edit any fields in the "Account Information" section
4. Click **Save Changes**
5. Changes take effect immediately
| Field | What it does |
| --- | --- |
| **Username** | Your login name — must stay unique |
| **Email Address** | Must stay unique |
| **First Name / Last Name** | Optional display names |
| **Theme** | A quick theme picker (seven favorites — see below for the full set) |
| **Default Playback Speed** | The speed audiobook playback starts at |
| **Resume Rewind** | Jumps back a few seconds when you resume playback (**Off (exact resume)**, 5, or 10 seconds) |
| **Keep Screen Awake** | Stops the screen sleeping during playback |
| **Timezone** | All times the server shows you (activity, stats, history) are rendered in this zone |
### Fields You Can Update
Changes save from the form's save button and apply immediately.
- **Username** - Your login name (must be unique)
- **Email** - Your email address (must be unique)
- **First Name** - Optional display name
- **Last Name** - Optional display name
- **Theme** - Your preferred color scheme
About the two theme doors: the profile's **Theme** select offers seven
quick picks. The sidebar's **Appearance** panel offers the full collection —
[Themes and Wood Paneling](themes.md) covers it, including the bookshelf
wood textures, which are per-browser and not part of your account.
## Changing Your Password
## Changing your password
Regular password changes are recommended for account security.
On the same page, the **Change Password** section asks for your **Current
Password**, a **New Password**, and **Confirm New Password**. The
requirements are checked as you type; click **Update Password** to save.
### How to Change
## Deleting your account
1. Go to **Profile** page
2. Scroll to "Change Password" section
3. Enter your **current password**
4. Enter your **new password**
5. **Confirm** your new password
6. Click **Update Password**
The **Danger Zone** at the bottom holds **Remove My Account**. Deletion
removes your account and everything personal to it — profile, reading
progress and history, device registrations, your collections — and keeps the
libraries themselves, since books belong to the library, not to you. It
cannot be undone.
### Password Requirements
The last administrator cannot delete their own account — one admin must
always remain.
- Minimum 8 characters
- Must contain uppercase, lowercase, number, and special character
- Must match confirmation field
- Current password must be correct
## Deleting Your Account
**⚠️ WARNING:** This action cannot be undone.
### What Gets Deleted
When you delete your account:
- Your profile information
- Reading progress and history
- Device connections
- Collection preferences
- All personal data
### What Stays
- Library books (media belongs to the library, not you)
- System settings
- Other users' accounts
### How to Delete
1. Go to **Profile** page
2. Scroll to "Danger Zone" (bottom of page)
3. Click **Remove My Account**
4. Confirm the deletion prompt
**Note:** If you're the last admin, you cannot delete your account for security reasons.
## Theme Options
Personalize your reading experience with different color themes.
### Quick Theme Switch
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Select a theme from the list; your active theme is marked with a checkmark
3. Changes apply instantly
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
### Available Themes
- **Tokyo Night** (default) - Blue/purple accents
- **Dracula** - Purple/pink tones
- **Nord** - Arctic, bluish-gray
- **Solarized Dark** - Warm, precise contrast
- **Monokai** - Classic vibrant colors
- **One Dark Pro** - Atom editor inspired
- **Material Dark** - Google Material Design
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
## For Admin Users
If you're an administrator, you can also manage other users' profiles from the admin panel.
See [Admin User Management](admin-guide.md) for details.
Administrators can also edit other users' profiles (including resetting a
password without the current one) from the admin area; that is covered with
the rest of the admin docs.
+24 -1
View File
@@ -2,7 +2,8 @@
Bookhoard's built-in web reader works in any modern browser — desktop or
mobile — and keeps your position, highlights, bookmarks, and notes in sync
with the Android app and KOReader.
with the Android app and KOReader. Audiobooks get their own player, covered
at the end of this guide.
## Opening a Book
@@ -79,3 +80,25 @@ to jump to it.
Position, highlights, bookmarks, and notes sync automatically through the
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
like — the reader resumes exactly where you left off on any device.
## Listening to audiobooks and podcast episodes
Books with audio files, and podcast episodes, open in the audio player
instead of the page reader. It is one player for both:
- The **seek bar** is your position; drag it, or use the **previous /
next episode** controls when you are playing a sequence
- **Bookmarks** opens a panel of your markers for this title — tap one to
jump back
- **Chapters** opens the chapter list (shows "currently chapter N of M");
audiobooks with embedded chapters and podcast shows with chapter marks
both land here
- **Player settings** holds the per-player preferences: **Autoplay on
open**, **Default playback speed**, and **Resume rewind** (a few seconds
back when you resume — the defaults come from your
[profile](profile-guide.md) and this panel overrides them per player)
- The **Shortcuts & interactions** panel (the **?** control) lists every
keyboard shortcut
Progress, bookmarks, and your speed carry across devices through the same
sync as everything else.
+9
View File
@@ -35,3 +35,12 @@ You can combine tags with other filters:
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
- Best matching tags appear first in results
- Autocomplete shows existing tags as you type with relevance scores
## The complete filter guide
The filters described above are one system that also powers smart collections and dashboard
sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in
[this folder](filters/index.md): the [guide](filters/index.md), the
[cookbook](filters/cookbook.md) of ready-made recipes, and the generated
[field reference](filters/fields.md).
-15
View File
@@ -1,15 +0,0 @@
# Settings Guide
**Coming Soon**
This guide will cover:
- Account settings
- Display preferences (themes, fonts)
- Sync configuration
- Device management
- Privacy options
---
_In the meantime, check out our [Device Setup Guides](devices/)_
+99 -555
View File
@@ -1,591 +1,135 @@
# Bookhoard Universal Sync - User Guide
# Universal Sync — how your devices stay in step
## Table of Contents
Sync is the promise that makes Bookhoard more than a web page: read on
whatever is in your hands, and every other device catches up. One server
holds the truth for each book — your position, bookmarks, highlights, and
notes — and every registered device pushes and pulls against it.
1. [What is Universal Sync?](#what-is-universal-sync)
2. [Supported Devices](#supported-devices)
3. [Getting Started](#getting-started)
4. [Device Setup](#device-setup)
5. [Using Sync Features](#using-sync-features)
6. [Book Matching](#book-matching)
7. [Managing Conflicts](#managing-conflicts)
8. [Best Practices](#best-practices)
## Supported devices today
---
| Platform | Status | How it syncs |
| --- | --- | --- |
| **Web browser** | ✅ Fully supported | Live, as you read — the reader saves position and annotations continuously |
| **Android app** | ✅ Fully supported | The native client: progress, highlights, bookmarks, notes; reads offline and queues changes until you are back online |
| **KOReader** (Kindle, Kobo, PocketBook) | ✅ Fully supported | The Bookhoard KOReader plugin, over Wi-Fi |
| **Kobo, stock firmware** | 🚧 Server support complete | The native Kobo protocol is implemented and documented server-side; device-side enablement is the remaining piece — [Kobo Setup](devices/kobo-setup.md) has the details |
## What is Universal Sync?
Kindle and reMarkable are under consideration; KOReader already covers
several of those devices today.
**Universal Sync** is Bookhoard's cross-platform synchronization system that keeps your reading progress, highlights, and notes in sync across all your devices - automatically and in real-time.
## Getting a device into the circle
### Key Features
1. Connect the device to your server (each guide above walks its own path)
2. Approve the pending registration on the **Devices** page — the Android
app registers itself on first login; KOReader asks from its plugin
3. Done. Sync starts as soon as a device is approved
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
The **Devices** page is mission control: see every registered device and
when it was last seen, remove a device (revoking its access instantly), and
work through **unlinked books** — device files that could not be matched to
a library book yet.
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
## Book matching
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
When a device reports a book, Bookhoard tries to match it to your library
by fingerprint, strongest first:
⚡ **Real-Time Updates** - See your progress update across all devices instantly
1. **Bookhoard UUID** — 100% confidence
2. **OPF UUID** — 95%
3. **SHA-256 hash** — 90%
4. **OPF identifier** — 85%
5. **ISBN / ASIN** — 80%
6. **Title + Author + file size** — 50%
7. **Title only** — 30% (last resort, only tried when nothing else matched)
🔒 **Secure** - No passwords on devices, web-based authentication only
Books that match automatically never bother you. The ones that can't
(side-loaded files, converted formats like EPUB → KEPUB, mismatched
metadata) land in the device's **unlinked books** list, where you can:
📴 **Offline Support** - Queue changes when offline, sync when reconnected
- **Auto-link** everything above a confidence threshold
- Review **suggestions** first, then link
- **Link by hand** — pick the right library book yourself
---
## What syncs
## Supported Devices
**Reading progress** — page, percentage, chapter position, EPUB CFI, last
read time. EPUBs sync by percentage and CFI, PDFs and comics by page, so
the same book lines up on devices with different page counts.
### Currently Supported ✅
**Highlights and notes** — the highlighted text, your note, its color, and
where it sits. Locations are stored in several forms at once (page, EPUB
CFI, percentage, character offset), which is why a highlight made on your
phone lands in the right place on an e-reader with different pagination.
| Platform | Status | Sync Method | Notes |
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
**Bookmarks** — location, title, and when you made them.
### Coming Soon 🚧
Everything above is per-user and private: your devices see your data
because you approved them, other users see theirs, and revoking a device
ends its access on the spot.
| Platform | Status |
| --------------------- | ------------------------------------------------------------- |
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
| **Mobile Apps** | Android/iOS apps coming later |
## Conflicts
### On the Roadmap 🔭
If the same book is read on two devices close together, Bookhoard does not
silently pick a winner — it records a **sync conflict** and shows it to
you:
| Platform | Status |
| --------------------- | ---------------------------------------- |
| **Kindle Devices** | Under consideration (no date yet) |
| **Remarkable Tablet** | Under consideration (no date yet) |
- The book's detail page grows a **Sync Progress** button (with a
"Progress conflict detected" notice)
- The **Conflicts** page (`/conflicts`) lists everything unresolved
---
Resolving means choosing a strategy: keep one side's progress, keep the
server's current value, or let the **furthest progress win**. Until you
resolve, the conflict stays listed — nothing is lost, it just waits for
your call.
## Getting Started
## Offline reading
### Prerequisites
Devices keep working without the server. The Android app reads fully
offline and queues your changes, draining the queue when you reconnect.
E-readers push their progress on the next connection. The server holds a
per-device queue for inbound work and retries failures automatically; if
items pile up (a device offline for weeks), an admin can retry or clear a
device's queue from the server side.
1. **Bookhoard Server** - Self-hosted instance running and accessible
2. **Network Connection** - Devices must be able to reach your Bookhoard server
3. **User Account** - Created and logged in to Bookhoard web interface
## Reading history
### Quick Start
1. **Set up your first device** - See [Device Setup](#device-setup) below
2. **Start reading** - Open any book in the web interface or on your device
3. **Turn pages** - Progress syncs automatically
4. **Switch devices** - Pick up any other device - your progress is there!
---
## Device Setup
For detailed device configuration instructions, see the appropriate setup guide:
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
### Quick Overview
**Registration Process** (KOReader):
1. Install the Bookhoard plugin and enter your server URL in KOReader
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
3. That's it — sync starts automatically once approved
**Device Management**:
```
Devices page (sidebar navigation)
```
You can:
- View all your registered devices
- See last sync time and status
- Disable or remove devices
- View unlinked books that need matching
- Manage file aliases
---
## Book Matching
### What is Book Matching?
When devices sync books, Bookhoard tries to automatically match them using:
- **SHA-256 hash** (most reliable) - Content-based fingerprint
- **ISBN** - Standard book identifier
- **UUID** - Unique identifier from EPUB metadata
- **Title + Author** (fallback) - Last resort matching
### Unlinked Books
Sometimes a book on your device can't be automatically matched to your library. This happens when:
- The book was side-loaded (not downloaded via Bookhoard)
- The file format was converted (EPUB → KEPUB)
- The metadata doesn't match exactly
### Viewing Unlinked Books
```
Devices page → select device → unlinked books
```
### Resolving Unlinked Books
1. **Auto-Link** - Automatically link books with 80%+ confidence
2. **Get Suggestions** - Review potential matches before linking
3. **Manual Link** - Choose the correct match yourself
### Book Matching Priority
1. **Bookhoard UUID** - 100% confidence
2. **OPF UUID** - 95% confidence
3. **SHA-256 hash** - 90% confidence
4. **OPF identifier** - 85% confidence
5. **ISBN/ASIN** - 80% confidence
6. **Title + Author + File Size** - 50% confidence
---
## Managing Conflicts
### Reading Progress
**What Syncs**:
- Current page number
- Reading percentage
- Chapter progress
- EPUB CFI (for EPUB files)
- Last read timestamp
- Reading position (viewport, zoom, scroll)
**How It Works**:
```
You turn page → Device sends progress → Server updates database
↓
Broadcasts to all your devices
↓
Other devices update their display
```
**Supported Progress Types**:
- **EPUB/MOBI**: Percentage + EPUB CFI + Chapter
- **PDF/DJVU**: Page number + Viewport position
- **CBZ/CBR**: Page number + Panel coordinates
### Highlights & Notes
**What Syncs**:
- Highlighted text
- Notes and annotations
- Bookmark locations
- Colors and formatting
- Chapter/paragraph references
**Universal Location References**:
All highlights are stored with multiple location types:
- Page:offset (traditional)
- EPUB CFI (EPUB files)
- Percentage (0-100%)
- Character offset (exact position)
- Chapter references
This ensures your highlights work across all devices, even with different page counts!
### Bookmarks
**What Syncs**:
- Bookmark locations
- Bookmark titles
- Date created
- Reading position context
---
## Sync Modes
### Immediate Mode (Default)
**Best For**: Normal reading, page turns
**Behavior**:
- Syncs every page turn
- Real-time updates
- Low latency
- Higher bandwidth usage
**Recommended Settings**:
- Auto-sync: ON
- Sync frequency: Every page turn
### Checkpoint Mode
**Best For**: Slow connections, battery saving
**Behavior**:
- Batches changes
- Syncs every 5 minutes or when connection allows
- Lower bandwidth
- Better for offline reading
**Recommended Settings**:
- Auto-sync: ON
- Sync frequency: Checkpoint mode
---
## Offline Support
### How Offline Sync Works
1. **Offline Reading**:
- Read normally without connection
- All progress tracked locally
2. **Queue Changes**:
- Progress changes queued automatically
- Annotations saved locally
- Up to 100 items in offline queue
3. **Reconnection**:
- Connect to Wi-Fi
- Queue processes automatically
- Priority items first (book completion, notes)
- All devices updated
### Offline Indicators
**In Web Interface**:
- Yellow status icon: Device offline
- Last seen timestamp
- "Pending sync" badge on books
**On Devices**:
- Sync icon: Gray = offline
- Sync icon: Blue = syncing
- Sync icon: Green = synced
---
Every reading session is logged — when, how long, on which device — and
feeds your [dashboard](dashboard.md) and stats. Your history is visible on
the **Progress** page and on each book's detail page, and it is yours
alone.
## Troubleshooting
### Sync Not Working
**Progress not updating**
Check the device is still on the **Devices** page and was approved; check
the server URL in the device's settings; confirm the device can reach the
server (open the server URL in the device's browser as a test).
**Symptoms**: Progress not updating across devices
**"Device not found"**
The device was removed or its token revoked — register it again and
approve it.
**Solutions**:
**A conflict keeps coming back**
Two devices are genuinely fighting over the same book. Decide which device
is the reader for that book, resolve with a strategy above, and avoid
simultaneous reading of the same title.
1. Check device is online: Devices page (sidebar navigation)
2. Verify sync is enabled for the device
3. Check sync URL is correct
4. Ensure device has network connection
5. Try manual sync: Open book → Menu → Sync Now
**High battery or data use**
That is the cost of continuous sync on a chatty device; e-reader plugins
let you space out their sync checks from the plugin's own settings.
### "Device Not Found" Error
## Device limits
**Cause**: Device not registered or authorization revoked
**Solutions**:
1. Re-register the device
2. Check device hasn't been removed
3. Verify correct device type selected
### "Rate Limit Exceeded" Error
**Cause**: Too many sync requests
**Solutions**:
1. Wait a few seconds
2. Switch to checkpoint mode
3. Contact admin to increase limits
### Conflicts Detected
**Symptoms**: "Sync conflict" notification
**Cause**: Same book being read on multiple devices simultaneously
**Solutions**:
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
2. Review both device progress
3. Choose which device's progress to keep
4. Or choose "Merge" (keeps furthest progress)
### High Battery Usage
**Cause**: Immediate sync mode with frequent page turns
**Solutions**:
1. Switch to checkpoint mode
2. Increase sync interval
3. Sync less frequently
Each account has a device limit — **10 by default**, and administrators can
raise or lower it per user. Removing a device you no longer use frees a
slot.
---
## Best Practices
### For Optimal Performance
✅ **DO**:
- Use checkpoint mode when on slow connections
- Keep device firmware updated
- Use Wi-Fi when available
- Approve only devices you own
- Regularly check conflict resolution
❌ **DON'T**:
- Read same book on multiple devices simultaneously
- Ignore conflict notifications
- Register public/shared devices
- Exceed device limits (max 10 per user)
### Organizing Your Library
**For Best Sync Experience**:
- Use consistent metadata (titles, authors)
- Avoid duplicate books in library
- Match files by ISBN when possible
- Use clean file structure
### Managing Multiple Devices
**Recommended Setup**:
- **Primary Device**: KOReader on e-reader
- **Secondary Device**: Web browser (work/home)
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
**Sync Strategy**:
1. Read mainly on primary device
2. Check progress on web/secondary devices
3. Let auto-sync handle updates
4. Resolve conflicts promptly
---
## Advanced Features
### Conflict Resolution
**Automatic Resolution**:
- Most recent progress wins
- Timestamp-based comparison
- 5-minute window for conflict detection
**Manual Resolution**:
```
Book detail → Sync Progress → choose winner
```
**Options**:
- **Keep Device A**: Use this device's progress
- **Keep Device B**: Use other device's progress
- **Merge**: Keep furthest progress (combination)
- **Auto-Resolve Future**: Always prefer this device
### Sync Queue Management
**View Queue Status**:
```
Devices page → sync queue section
```
**Queue Stats**:
- Pending: Waiting to sync
- Processing: Currently syncing
- Failed: Retry scheduled
- Completed: Successfully synced
**Manual Actions**:
- **Retry All**: Retry all failed items
- **Clear Queue**: Remove all pending items
- **Priority Sync**: Sync specific book immediately
### Reading History
**Automatic Tracking**:
- Every sync session logged
- Time spent reading calculated
- Pages read tracked
- Device used recorded
**View History**:
```
Progress page (sidebar navigation), or the book's detail page
```
**Privacy**:
- Only you can see your history
- History kept for 365 days
- Exportable for backup
---
## Security & Privacy
### Device Authentication
**Secure by Design**:
- ✅ No passwords stored on devices
- ✅ Web-based approval required
- ✅ Unique tokens per device
- ✅ Revocable at any time
- ✅ Token encryption in transit
### Data Protection
**What We Store**:
- Reading progress (page, percentage)
- Highlights and notes
- Device identifiers
- Sync timestamps
**What We DON'T Store**:
- Passwords on devices
- Reading content (your books)
- Unencrypted personal data
- Location data (GPS)
### Access Control
**Your Data**:
- Only you can see your progress
- Admins cannot read your annotations
- Shared only with devices you approve
**Device Access**:
- Each device sees only your libraries
- Devices cannot access other users
- Revoking removes all access
---
## Performance Tips
### For Faster Sync
1. **Use 5GHz Wi-Fi** - Faster than 2.4GHz
2. **Keep server nearby** - Low latency = better sync
3. **Regular updates** - Keeps firmware current
4. **Checkpoint mode** - For batch processing
### For Better Battery Life
1. **Checkpoint mode** - Fewer sync requests
2. **Increase sync interval** - Fewer updates
3. **Close when not reading** - Reduces background activity
---
## FAQ
### General Questions
**Q: Does sync work with all book formats?**
A: Yes! EPUB, PDF, MOBI, CBZ, CBR and more.
**Q: Can multiple users share a device?**
A: No, devices are tied to individual accounts for security.
**Q: What happens if I delete a book?**
A: All sync data for that book is removed from the server.
**Q: Can I export my reading data?**
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
**Q: Does sync work over the internet?**
A: Yes, if your server is publicly accessible with HTTPS.
**Q: How much data does sync use?**
A: Approximately 1KB per page turn, 50KB per annotation.
### Technical Questions
**Q: How does sync handle different page counts?**
A: Uses percentage and EPUB CFI for universal positioning.
**Q: Can I sync with Calibre anymore?**
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
**Q: What if I lose my device?**
A: Revoke it in settings and register a new one.
**Q: Is sync end-to-end encrypted?**
A: Yes, HTTPS/TLS 1.3 for all sync traffic.
---
## Getting Help
### Documentation
- **API Docs**: `/docs/api.md`
- **Device Guides**: `/docs/devices/`
- **Self-Hosting**: `/docs/install.md`
### Support
- **Issues**: Report bugs at GitHub Issues
- **Discussions**: Join GitHub Discussions
- **Email**: support@bookhoard.example.com
### Community
- **Forum**: community.bookhoard.example.com
- **Matrix**: #bookhoard:matrix.org
- **Discord**: discord.gg/bookhoard
---
## Changelog
### Version 1.0.x (2026)
- ✅ Web sync support
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
- ✅ Conflict resolution
- ✅ Offline queue
- ✅ Real-time WebSocket sync
- 🚧 Native Kobo sync (coming soon)
- 🚧 Mobile apps (coming later)
---
**Last Updated**: August 2026
**Version**: 1.0
**License**: AGPL-3.0
More detail on any device lives in its guide:
[Android](devices/android-app.md) ·
[KOReader](devices/koreader-setup.md) ·
[Kobo](devices/kobo-setup.md) ·
[Calibre import](calibre-integration.md) ·
and the [filter rules](filters/index.md) that power matching-adjacent
shelves like [smart collections](collections.md).
+8 -2
View File
@@ -34,7 +34,7 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
**Light**
- **Lupine** - Clean light with violet-blue accents (default light theme)
- **Lupine** - Clean light with violet-blue accents
- **Tokyo Night Day** - Daylight counterpart of the default
- **Solarized Light** - Precision contrast on warm paper
- **One Light** - Atom's light theme
@@ -52,11 +52,17 @@ full set of [Omarchy](https://omarchy.org) defaults alongside the classics:
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
2. Use the **Dark / Light** switch at the top to show the half you want —
it opens on the half your current theme lives in
it opens on the half your current theme lives in, and each side shows
how many themes it holds. The switch only filters the list; it never
changes your theme by itself
3. Pick a theme from the list — each option shows its color swatch, and your
active theme is marked with a checkmark
4. Your choice is saved automatically and synced across devices
There is exactly one default: **Tokyo Night** on a fresh install. Nothing
else is pre-selected for you — pick whichever theme you like from either
half.
On small screens, open the sidebar with the menu button in the top bar first.
## Wood Paneling
-15
View File
@@ -1,15 +0,0 @@
# User Areas Guide
**Coming Soon**
This guide will cover:
- Managing your personal library
- Uploading and organizing books
- Creating and managing collections
- Syncing across devices
- Personal settings
---
_In the meantime, check out our [Sync Guide](sync-guide.md)_
+38 -70
View File
@@ -1,79 +1,47 @@
# User Documentation
# Bookhoard — start here
Welcome to the Bookhoard user documentation. This section contains guides for using Bookhoard features, setting up devices, and configuring sync.
Bookhoard is a self-hosted home for the things you read and hear: your book
libraries live on your own server, you read them in the browser or on your
devices, and everything keeps track of where you are.
## 📱 Device Setup
The moving parts, in one breath:
Learn how to configure your e-reader devices to sync with Bookhoard:
- **Libraries** hold your books (and your podcasts run as feeds of their own)
- The **dashboard** is your home screen — shelves that update themselves
- **Collections** are shelves you build, by hand or by rules
- **Filters** answer "which books match?" — the same rules power saved
filters, smart collections, and dashboard shelves
- **Devices** — the Android app, KOReader on e-readers, and stock Kobo
firmware — stay in step with the server through **sync**
- Your **profile** holds your preferences: theme, playback defaults, password
- **[Android App Guide](devices/android-app.md)** - The native Android client
- Installing and upgrading the APK
- Connecting to a self-hosted server over LAN
- The Android 16+ local-network permission
- Troubleshooting login failures
## Where to go
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
- Installation on Kindle/Kobo/PocketBook
- Plugin setup with server-side device approval
- Progress, bookmark, highlight, and note sync
- OPDS catalog access
- Troubleshooting
| You want to... | Read |
| --- | --- |
| Learn the layout: dashboard, shelves, customizing | [Dashboard](dashboard.md) |
| Browse, search, and sort your books | [Browsing your library](library-browsing.md) |
| Build shelves — by hand or with rules | [Collections](collections.md) |
| Narrow with rules, save them, reuse them | [Filters — the complete guide](filters/index.md) |
| Subscribe to shows, download, listen | [Podcasts](podcasts.md) |
| Read and annotate in the browser | [Web Reader Guide](reader-guide.md) |
| Change themes and bookshelf textures | [Themes and Wood Paneling](themes.md) |
| Edit your account, password, playback defaults | [Your profile](profile-guide.md) |
| Get the Android app talking to your server | [Android App](devices/android-app.md) |
| Use KOReader on an e-reader | [KOReader Setup](devices/koreader-setup.md) |
| Use a Kobo | [Kobo Setup](devices/kobo-setup.md) |
| Understand how it all stays in sync | [Universal Sync Guide](sync-guide.md) |
| Import from Calibre | [Calibre Integration](calibre-integration.md) |
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
- In the meantime, KOReader works great on Kobo hardware
New here? The usual path is: browse [your library](library-browsing.md),
open a book, and let the [dashboard](dashboard.md) keep track of you. Sync
comes when you add a [device](devices/android-app.md).
## 🔄 Sync Configuration
## A note on admins
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
- What is Universal Sync?
- Supported devices
- Book matching and auto-linking
- Conflict resolution
- Best practices
Some screens (user management, server settings, library folders) are admin
territory; [Admin Guide](admin-guide.md) is the door, and the admin docs are
being expanded separately.
## 📚 Calibre Integration
- **[Calibre Integration Guide](calibre-integration.md)** - Integrating with Calibre libraries
- What is Calibre?
- Automatic metadata import
- Setting up your Calibre library
- Supported metadata and file types
- Workflow examples
- Troubleshooting
- Best practices
## 📖 Reading
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
- Touch navigation (swipe to page, tap for menu)
- Selecting text: long-press, drag, handles
- Highlights, notes, copy, and editing
- PDFs and comics
## 🎨 Frontend Guide
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
- _Coming Soon_
## 👤 User Areas
**[User Areas Guide](user-areas.md)** - Managing your personal library and settings
- _Coming Soon_
## ⚙️ Settings
**[Settings Guide](settings-guide.md)** - Configuring your Bookhoard preferences
- _Coming Soon_
## 🔐 Admin Features
**[Admin Guide](admin-guide.md)** - Administrative functions and management
- _Coming Soon_
---
**Looking for developer documentation?** See the [Developer Portal](../developer/development.md)
**Looking for developer documentation?** See the
[Developer Portal](../developer/development.md).
+2 -1
View File
@@ -34,7 +34,7 @@ type Collections struct {
Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
@@ -654,4 +654,5 @@ type Users struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
}
+27 -9
View File
@@ -262,11 +262,25 @@ type Querier interface {
// rename can't spawn duplicates on the next scan.
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
// The collection queue's downloaded episodes in collection order
// (plan item 2.3 "Play collection as sequence"): position-first, then
// added_at. INNER JOIN — the chain steps only through episodes the
// requester can see (RLS drops unsubscribed feeds) and that are
// downloaded (invariant 6: un-downloaded episodes are never tracked).
GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error)
// Get collection items
// A2c: position-first ordering (playlist reading order); un-positioned
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
// which walked a playlist backwards.
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
// Episode-collections detail (plan item 2.3): the collection's full
// membership for the SSR detail page, with per-row visibility. LEFT
// JOIN + RLS: rows whose media is invisible to the requester
// (unsubscribed episode — or a family-lens hidden book) survive with
// NULL media columns and visible=false, so the page can render an
// "unavailable" hint instead of silently vanishing them. Rows visible
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error)
// The excluded filter lives here (was: callers filtered ci.excluded in
// Go over a 69-field row + a 45-field converter to MediaItems; embed
// returns the real row so neither exists).
@@ -585,12 +599,6 @@ type Querier interface {
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
// membership comes from the candidate book set instead of collection_items.
// The set is scoped to the requesting user's visible libraries (family
// lens enforced at the source) and live items only — the same filters the
// static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
@@ -762,6 +770,14 @@ type Querier interface {
RevokeOpdsToken(ctx context.Context, token string) error
RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error
SearchAuthorValues(ctx context.Context, arg SearchAuthorValuesParams) ([]SearchAuthorValuesRow, error)
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
// The podcasts exclusion in SearchMediaItemsUnified stays (books
// surfaces never list podcasts — plan §9); THIS query is the episode
// picker's dedicated scope. Visibility is the engine's job (D20): the
// media_items policy drops episodes from unsubscribed feeds and the
// podcast_feeds join is governed the same way — non-subscribers see
// nothing here without any Go-side clause.
SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error)
SearchGenreValues(ctx context.Context, arg SearchGenreValuesParams) ([]SearchGenreValuesRow, error)
SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error)
// Search Media Items queries
@@ -804,11 +820,12 @@ type Querier interface {
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
// of the JSONB without touching metadata stale clients did not send).
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
// A2c §2.4: provenance + playlist flags on an existing collection without
// touching the rest of the row.
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
// collections has no updated_at column — the scanner's series-import
// call has been failing silently, its error discarded. Fixed by
// dropping the phantom column write.)
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
@@ -877,6 +894,7 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserKeepScreenAwakeParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
+237 -201
View File
@@ -671,9 +671,9 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create
const CreateCollection = `-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateCollectionParams struct {
@@ -682,9 +682,9 @@ type CreateCollectionParams struct {
Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
}
// COLLECTIONS QUERIES
@@ -696,9 +696,9 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
arg.Description,
arg.Color,
arg.Icon,
arg.AutoAssignRules,
arg.ViewSettings,
arg.ShowOnDashboard,
arg.FilterTree,
)
var i Collections
err := row.Scan(
@@ -708,7 +708,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -2250,9 +2250,9 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt
}
const CreateSystemCollection = `-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type CreateSystemCollectionParams struct {
@@ -2285,7 +2285,7 @@ func (q *Queries) CreateSystemCollection(ctx context.Context, arg CreateSystemCo
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4383,7 +4383,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
}
const GetCollection = `-- name: GetCollection :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE id = $1
`
// Get collection
@@ -4397,7 +4397,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4410,7 +4410,7 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
}
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND view_settings->>'imported_from_series' = $2::text
AND deleted_at IS NULL
@@ -4435,7 +4435,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4448,7 +4448,7 @@ func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetColl
}
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
LIMIT 1
`
@@ -4468,7 +4468,7 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4480,6 +4480,55 @@ func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollect
return i, err
}
const GetCollectionEpisodesOrdered = `-- name: GetCollectionEpisodesOrdered :many
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.source = 'podcast_episode'
AND mi.download_state = 'downloaded'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
`
type GetCollectionEpisodesOrderedRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
}
// The collection queue's downloaded episodes in collection order
// (plan item 2.3 "Play collection as sequence"): position-first, then
// added_at. INNER JOIN — the chain steps only through episodes the
// requester can see (RLS drops unsubscribed feeds) and that are
// downloaded (invariant 6: un-downloaded episodes are never tracked).
func (q *Queries) GetCollectionEpisodesOrdered(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionEpisodesOrderedRow, error) {
rows, err := q.db.Query(ctx, GetCollectionEpisodesOrdered, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetCollectionEpisodesOrderedRow{}
for rows.Next() {
var i GetCollectionEpisodesOrderedRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FeedPosition,
&i.DatePublished,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCollectionItems = `-- name: GetCollectionItems :many
SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, ci.position, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
FROM collection_items ci
@@ -4542,6 +4591,73 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
return items, nil
}
const GetCollectionItemsDetailed = `-- name: GetCollectionItemsDetailed :many
SELECT ci.media_item_id, ci.position, ci.added_at,
(mi.id IS NOT NULL)::bool AS visible,
mi.title, mi.author, mi.cover_image_path, mi.format_group,
mi.source, mi.download_state,
pf.title AS feed_title
FROM collection_items ci
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE ci.collection_id = $1
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
`
type GetCollectionItemsDetailedRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Position pgtype.Int4 `db:"position" json:"position"`
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
Visible bool `db:"visible" json:"visible"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
FormatGroup pgtype.Text `db:"format_group" json:"format_group"`
Source pgtype.Text `db:"source" json:"source"`
DownloadState pgtype.Text `db:"download_state" json:"download_state"`
FeedTitle pgtype.Text `db:"feed_title" json:"feed_title"`
}
// Episode-collections detail (plan item 2.3): the collection's full
// membership for the SSR detail page, with per-row visibility. LEFT
// JOIN + RLS: rows whose media is invisible to the requester
// (unsubscribed episode — or a family-lens hidden book) survive with
// NULL media columns and visible=false, so the page can render an
// "unavailable" hint instead of silently vanishing them. Rows visible
// but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
func (q *Queries) GetCollectionItemsDetailed(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsDetailedRow, error) {
rows, err := q.db.Query(ctx, GetCollectionItemsDetailed, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetCollectionItemsDetailedRow{}
for rows.Next() {
var i GetCollectionItemsDetailedRow
if err := rows.Scan(
&i.MediaItemID,
&i.Position,
&i.AddedAt,
&i.Visible,
&i.Title,
&i.Author,
&i.CoverImagePath,
&i.FormatGroup,
&i.Source,
&i.DownloadState,
&i.FeedTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
@@ -4845,7 +4961,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe
const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one
SELECT
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
c.id, c.user_id, c.name, c.description, c.color, c.icon, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at,
COUNT(ci.id) as book_count
FROM collections c
LEFT JOIN collection_items ci ON c.id = ci.collection_id
@@ -4860,7 +4976,7 @@ type GetCollectionWithBookCountRow struct {
Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
ViewSettings []byte `db:"view_settings" json:"view_settings"`
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
QueryType pgtype.Text `db:"query_type" json:"query_type"`
@@ -4882,7 +4998,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4896,7 +5012,7 @@ func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID
}
const GetCollectionsByUser = `-- name: GetCollectionsByUser :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
`
// Get collections by user
@@ -4916,7 +5032,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -4936,7 +5052,7 @@ func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID)
}
const GetCollectionsForBook = `-- name: GetCollectionsForBook :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at
FROM collections c
JOIN collection_items ci ON c.id = ci.collection_id
WHERE ci.media_item_id = $1
@@ -4960,7 +5076,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -10665,7 +10781,7 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (
const GetSystemCollectionByQueryType = `-- name: GetSystemCollectionByQueryType :one
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND query_type = $2
AND is_system_collection = true
@@ -10691,7 +10807,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -10704,7 +10820,7 @@ func (q *Queries) GetSystemCollectionByQueryType(ctx context.Context, arg GetSys
}
const GetSystemCollectionsForDashboard = `-- name: GetSystemCollectionsForDashboard :many
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
SELECT id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
WHERE user_id = $1
AND is_system_collection = true
AND show_on_dashboard = true
@@ -10729,7 +10845,7 @@ func (q *Queries) GetSystemCollectionsForDashboard(ctx context.Context, userID p
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -11291,6 +11407,7 @@ SELECT
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
u.audio_resume_rewind_seconds,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1
@@ -11310,6 +11427,7 @@ type GetUserRow struct {
Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
KeepScreenAwake bool `db:"keep_screen_awake" json:"keep_screen_awake"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
DeviceCount int64 `db:"device_count" json:"device_count"`
}
@@ -11330,6 +11448,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.Timezone,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
&i.AudioResumeRewindSeconds,
&i.DeviceCount,
)
return i, err
@@ -11435,7 +11554,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
}
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.filter_tree, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
WHERE c.user_id = $1
AND c.is_system_collection = false
AND c.deleted_at IS NULL
@@ -11462,7 +11581,7 @@ func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgt
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -12222,136 +12341,6 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp
return items, nil
}
const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.title ASC
`
type GetVisibleLiveMediaForRulesParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
// membership comes from the candidate book set instead of collection_items.
// The set is scoped to the requesting user's visible libraries (family
// lens enforced at the source) and live items only — the same filters the
// static collection paths apply.
func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) {
rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []MediaItems{}
for rows.Next() {
var i MediaItems
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Subtitle,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Narrators,
&i.Authors,
&i.AuthorsSearch,
&i.Publishers,
&i.PublishersSearch,
&i.Abridged,
&i.Explicit,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch,
&i.ContributorsSearch,
&i.NarratorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
&i.Source,
&i.SourceFeedID,
&i.EpisodeGuid,
&i.FeedPosition,
&i.SeasonNumber,
&i.EpisodeNumber,
&i.EnclosureUrl,
&i.DeclaredDurationSeconds,
&i.TranscriptUrl,
&i.ChaptersUrl,
&i.Hosts,
&i.Guests,
&i.DownloadState,
&i.DownloadError,
&i.DownloadAttempts,
&i.DownloadQueuePos,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
SELECT EXISTS(
SELECT 1 FROM sync_conflicts
@@ -16332,7 +16321,7 @@ SET name = $2,
show_on_dashboard = true,
deleted_at = NULL
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type RestoreSystemCollectionRowParams struct {
@@ -16363,7 +16352,7 @@ func (q *Queries) RestoreSystemCollectionRow(ctx context.Context, arg RestoreSys
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -16478,6 +16467,67 @@ func (q *Queries) SearchAuthorValues(ctx context.Context, arg SearchAuthorValues
return items, nil
}
const SearchEpisodesForPicker = `-- name: SearchEpisodesForPicker :many
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
mi.format_group, mi.download_state,
pf.title AS feed_title
FROM media_items mi
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE mi.source = 'podcast_episode'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($1::text = '' OR mi.title ILIKE '%' || $1::text || '%')
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
LIMIT 60
`
type SearchEpisodesForPickerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FormatGroup string `db:"format_group" json:"format_group"`
DownloadState string `db:"download_state" json:"download_state"`
FeedTitle string `db:"feed_title" json:"feed_title"`
}
// Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
// The podcasts exclusion in SearchMediaItemsUnified stays (books
// surfaces never list podcasts — plan §9); THIS query is the episode
// picker's dedicated scope. Visibility is the engine's job (D20): the
// media_items policy drops episodes from unsubscribed feeds and the
// podcast_feeds join is governed the same way — non-subscribers see
// nothing here without any Go-side clause.
func (q *Queries) SearchEpisodesForPicker(ctx context.Context, q_ string) ([]SearchEpisodesForPickerRow, error) {
rows, err := q.db.Query(ctx, SearchEpisodesForPicker, q_)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SearchEpisodesForPickerRow{}
for rows.Next() {
var i SearchEpisodesForPickerRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.CoverImagePath,
&i.LibraryID,
&i.FormatGroup,
&i.DownloadState,
&i.FeedTitle,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const SearchGenreValues = `-- name: SearchGenreValues :many
SELECT
mi.genre as value,
@@ -18015,10 +18065,10 @@ SET
description = $3,
color = $4,
icon = $5,
auto_assign_rules = $6,
view_settings = $7
view_settings = $6,
filter_tree = $7
WHERE id = $1
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
RETURNING id, user_id, name, description, color, icon, filter_tree, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type UpdateCollectionParams struct {
@@ -18027,8 +18077,8 @@ type UpdateCollectionParams struct {
Description pgtype.Text `db:"description" json:"description"`
Color pgtype.Text `db:"color" json:"color"`
Icon pgtype.Text `db:"icon" json:"icon"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings []byte `db:"view_settings" json:"view_settings"`
FilterTree []byte `db:"filter_tree" json:"filter_tree"`
}
// Update collection
@@ -18039,8 +18089,8 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
arg.Description,
arg.Color,
arg.Icon,
arg.AutoAssignRules,
arg.ViewSettings,
arg.FilterTree,
)
var i Collections
err := row.Scan(
@@ -18050,40 +18100,7 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
&i.Priority,
&i.IsSystemCollection,
&i.CreatedAt,
&i.DeletedAt,
)
return i, err
}
const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
`
type UpdateCollectionRulesJSONParams struct {
ID pgtype.UUID `db:"id" json:"id"`
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
}
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
// of the JSONB without touching metadata stale clients did not send).
func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) {
row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules)
var i Collections
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.Color,
&i.Icon,
&i.AutoAssignRules,
&i.FilterTree,
&i.ViewSettings,
&i.ShowOnDashboard,
&i.QueryType,
@@ -18096,7 +18113,7 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
}
const UpdateCollectionViewSettings = `-- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1
UPDATE collections SET view_settings = $2 WHERE id = $1
`
type UpdateCollectionViewSettingsParams struct {
@@ -18106,6 +18123,10 @@ type UpdateCollectionViewSettingsParams struct {
// A2c §2.4: provenance + playlist flags on an existing collection without
// touching the rest of the row.
// (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
// collections has no updated_at column — the scanner's series-import
// call has been failing silently, its error discarded. Fixed by
// dropping the phantom column write.)
func (q *Queries) UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error {
_, err := q.db.Exec(ctx, UpdateCollectionViewSettings, arg.ID, arg.ViewSettings)
return err
@@ -20269,6 +20290,20 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err
}
const UpdateUserAudioResumeRewind = `-- name: UpdateUserAudioResumeRewind :exec
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserAudioResumeRewindParams struct {
ID pgtype.UUID `db:"id" json:"id"`
AudioResumeRewindSeconds int32 `db:"audio_resume_rewind_seconds" json:"audio_resume_rewind_seconds"`
}
func (q *Queries) UpdateUserAudioResumeRewind(ctx context.Context, arg UpdateUserAudioResumeRewindParams) error {
_, err := q.db.Exec(ctx, UpdateUserAudioResumeRewind, arg.ID, arg.AudioResumeRewindSeconds)
return err
}
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
`
@@ -20299,7 +20334,7 @@ func (q *Queries) UpdateUserKeepScreenAwake(ctx context.Context, arg UpdateUserK
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed, keep_screen_awake, audio_resume_rewind_seconds
`
type UpdateUserMaxDevicesParams struct {
@@ -20325,6 +20360,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.UpdatedAt,
&i.DefaultAudioSpeed,
&i.KeepScreenAwake,
&i.AudioResumeRewindSeconds,
)
return i, err
}
+70 -24
View File
@@ -30,6 +30,7 @@ SELECT
u.timezone,
u.default_audio_speed,
u.keep_screen_awake,
u.audio_resume_rewind_seconds,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u
WHERE u.id = $1;
@@ -526,6 +527,9 @@ UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserKeepScreenAwake :exec
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserAudioResumeRewind :exec
UPDATE users SET audio_resume_rewind_seconds = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
@@ -932,6 +936,26 @@ ORDER BY
mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: SearchEpisodesForPicker :many
-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
-- The podcasts exclusion in SearchMediaItemsUnified stays (books
-- surfaces never list podcasts — plan §9); THIS query is the episode
-- picker's dedicated scope. Visibility is the engine's job (D20): the
-- media_items policy drops episodes from unsubscribed feeds and the
-- podcast_feeds join is governed the same way — non-subscribers see
-- nothing here without any Go-side clause.
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
mi.format_group, mi.download_state,
pf.title AS feed_title
FROM media_items mi
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE mi.source = 'podcast_episode'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
LIMIT 60;
-- name: SearchAuthorValues :many
SELECT
mi.author as value,
@@ -3469,14 +3493,18 @@ DELETE FROM device_file_aliases WHERE id = $1;
-- Create collection
-- name: CreateCollection :one
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
INSERT INTO collections (user_id, name, description, color, icon, view_settings, show_on_dashboard, filter_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- A2c §2.4: provenance + playlist flags on an existing collection without
-- touching the rest of the row.
-- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
-- (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
-- collections has no updated_at column — the scanner's series-import
-- call has been failing silently, its error discarded. Fixed by
-- dropping the phantom column write.)
UPDATE collections SET view_settings = $2 WHERE id = $1;
-- Get collection
-- name: GetCollection :one
@@ -3504,16 +3532,11 @@ SET
description = $3,
color = $4,
icon = $5,
auto_assign_rules = $6,
view_settings = $7
view_settings = $6,
filter_tree = $7
WHERE id = $1
RETURNING *;
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
-- of the JSONB without touching metadata stale clients did not send).
-- name: UpdateCollectionRulesJSON :one
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
-- Delete collection
-- name: DeleteCollection :exec
DELETE FROM collections WHERE id = $1;
@@ -3576,6 +3599,42 @@ WHERE ci.collection_id = $1
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionItemsDetailed :many
-- Episode-collections detail (plan item 2.3): the collection's full
-- membership for the SSR detail page, with per-row visibility. LEFT
-- JOIN + RLS: rows whose media is invisible to the requester
-- (unsubscribed episode — or a family-lens hidden book) survive with
-- NULL media columns and visible=false, so the page can render an
-- "unavailable" hint instead of silently vanishing them. Rows visible
-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
SELECT ci.media_item_id, ci.position, ci.added_at,
(mi.id IS NOT NULL)::bool AS visible,
mi.title, mi.author, mi.cover_image_path, mi.format_group,
mi.source, mi.download_state,
pf.title AS feed_title
FROM collection_items ci
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
WHERE ci.collection_id = $1
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- name: GetCollectionEpisodesOrdered :many
-- The collection queue's downloaded episodes in collection order
-- (plan item 2.3 "Play collection as sequence"): position-first, then
-- added_at. INNER JOIN — the chain steps only through episodes the
-- requester can see (RLS drops unsubscribed feeds) and that are
-- downloaded (invariant 6: un-downloaded episodes are never tracked).
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
FROM collection_items ci
JOIN media_items mi ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.source = 'podcast_episode'
AND mi.download_state = 'downloaded'
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
-- Get collections for book
-- name: GetCollectionsForBook :many
SELECT c.*
@@ -3967,8 +4026,8 @@ RETURNING *;
DELETE FROM collection_items WHERE collection_id = $1;
-- name: CreateSystemCollection :one
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
RETURNING *;
-- name: ResetSystemCollectionMetadata :exec
@@ -4084,19 +4143,6 @@ LIMIT sqlc.narg('limit');
-- name: CountCollectionItems :one
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
-- membership comes from the candidate book set instead of collection_items.
-- The set is scoped to the requesting user's visible libraries (family
-- lens enforced at the source) and live items only — the same filters the
-- static collection paths apply.
-- name: GetVisibleLiveMediaForRules :many
SELECT mi.* FROM media_items mi
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.title ASC;
-- Live item count for a STATIC collection (rule-free cards on the
-- collections page); same live filter as GetCollectionItems, plus the
-- future-hybrid excluded exclusion and an optional library scope.
+31 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"bookhoard/templates"
@@ -24,7 +25,23 @@ type DocsHandler struct {
docsFS fs.FS
}
// resolveDocsPath — the docs folder lives at the repo root, but `go test`
// runs test binaries from the PACKAGE directory (/cmd/server/tests), so a
// plain relative "docs" resolves to a folder that doesn't exist there and
// every /docs route 404s in tests (2026-11-01). Walk upward, bounded,
// until the folder is real; prod (/app) matches on the first try.
func resolveDocsPath(p string) string {
for i := 0; i < 6; i++ {
if fi, err := os.Stat(filepath.Join(p, "index.md")); err == nil && !fi.IsDir() {
return p
}
p = filepath.Join("..", p)
}
return "docs"
}
func NewDocsHandler(docsPath string) *DocsHandler {
docsPath = resolveDocsPath(docsPath)
// Create goldmark renderer with syntax highlighting
md := goldmark.New(
goldmark.WithExtensions(
@@ -74,11 +91,24 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
}
}
// Read the markdown file
// Read the markdown file. Folder URLs resolve to their index page:
// /docs/user/filters → user/filters/index.md (2026-11-01 — the
// filter manual is a folder: guide + cookbook + generated field
// reference).
content, err := fs.ReadFile(h.docsFS, docPath)
if err != nil {
if !strings.HasSuffix(docPath, "index.md") {
alt := strings.TrimSuffix(docPath, ".md") + "/index.md"
if altContent, altErr := fs.ReadFile(h.docsFS, alt); altErr == nil {
docPath = alt
content = altContent
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
} else {
return nil, fmt.Errorf("failed to read document: %w", err)
}
}
// Convert markdown to HTML
var buf bytes.Buffer
+29 -14
View File
@@ -8,40 +8,55 @@ import (
"testing"
)
// TestDocsRelativeLinks keeps the developer docs tree internally navigable:
// every relative markdown link must point at a file that exists. The
// api-reference overview once shipped 20+ directory-style links
// (authentication/ etc.) that 404'd — this gate makes that class of rot
// fail make test. (docs/user is excluded: end-user prose has its own pass.)
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
// relative markdown link must point at a file that exists. The api-reference
// overview once shipped 20+ directory-style links (authentication/ etc.)
// that 404'd — this gate makes that class of rot fail make test.
//
// Rules:
// - http(s):, mailto:, and pure #anchors are skipped
// - every other target must end in .md and resolve (relative to the
// linking file) to an existing file inside the tree
// linking file) to an existing file inside the docs tree
// - directory-style targets ("area/") are the exact bug this test exists
// for and always fail
//
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
// are not scanned as link sources, so no future gate failure can force an
// edit there. Files linking INTO them are still checked — resolution needs
// file existence only, never edits to their content.
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
func TestDocsRelativeLinks(t *testing.T) {
// Walk developer docs; escape-check against the whole docs tree —
// cross-area links into docs/user are legitimate (same site).
devRoot, err := filepath.Abs("../../docs/developer")
if err != nil {
t.Fatalf("abs developer root: %v", err)
var handsOffFilterDocs = []string{
"user/filters/",
"user/searching-and-filtering.md",
}
func TestDocsRelativeLinks(t *testing.T) {
treeRoot, err := filepath.Abs("../../docs")
if err != nil {
t.Fatalf("abs docs root: %v", err)
}
err = filepath.WalkDir(devRoot, func(path string, d os.DirEntry, err error) error {
err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
return err
}
rel, err := filepath.Rel(treeRoot, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
for _, off := range handsOffFilterDocs {
if strings.HasPrefix(relSlash, off) {
return nil
}
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
rel, _ := filepath.Rel(docsRoot, path)
absPath, err := filepath.Abs(path)
if err != nil {
return err
+15 -2
View File
@@ -321,10 +321,23 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
return nav
}
// containsURL reports whether currentPath is one of the given doc URLs.
// normalizeDocURL — folder-index pages SERVE at their directory URL
// (/docs/user/filters) while their nav entry carries the /index suffix
// (/docs/user/filters/index): same page, two spellings. Both sidebar
// checks (expansion + highlight) compare normalized forms, so folder
// pages expand and highlight like file pages — no per-page special
// cases (2026-11-01, the filter manual's folder).
func normalizeDocURL(u string) string {
u = strings.TrimRight(u, "/")
return strings.TrimSuffix(u, "/index")
}
// containsURL reports whether currentPath is one of the given doc URLs
// (folder-index URLs normalized — see normalizeDocURL).
func containsURL(currentPath string, urls []string) bool {
current := normalizeDocURL(currentPath)
for _, u := range urls {
if currentPath == u {
if current == normalizeDocURL(u) {
return true
}
}
+40
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"bookhoard/templates"
)
func TestPlaceDoc(t *testing.T) {
@@ -205,3 +207,41 @@ func TestBuildNavigationNothingCurrent(t *testing.T) {
t.Errorf("expected zero sections open when nothing matches (docs home / 404 renders fully closed), got %d", open)
}
}
// TestBuildNavigationFolderIndex (2026-11-01): folder-index pages serve
// at their directory URL (/docs/user/filters) while their nav entries
// carry /index (/docs/user/filters/index) — the sidebar must EXPAND the
// containing section/group and HIGHLIGHT the index item for the folder
// URL, exactly like a file page. The filter manual's folder is the
// first page of this shape.
func TestBuildNavigationFolderIndex(t *testing.T) {
// The REAL docs tree: the Filters group only exists because the
// filter manual (guide/cookbook/fields) lives in user/filters/.
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs/user/filters")
var userGuide *templates.NavSection
for i := range nav.Sections {
if nav.Sections[i].Title == "User Guide" {
userGuide = &nav.Sections[i]
}
}
if userGuide == nil {
t.Fatal("User Guide section missing")
}
if userGuide.Collapsed {
t.Error("User Guide section must expand for /docs/user/filters")
}
var filters *templates.NavGroup
for i := range userGuide.Groups {
if userGuide.Groups[i].Title == "Filters" {
filters = &userGuide.Groups[i]
}
}
if filters == nil {
t.Fatal("Filters group missing from User Guide")
}
if filters.Collapsed {
t.Error("Filters group must expand for its folder URL")
}
}
+8 -25
View File
@@ -49,7 +49,7 @@ var Schemas = map[string]Schema{
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "collection_type": "",
"auto_assign_rules": null, "view_settings": {},
"view_settings": {},
"book_count": 0, "created_at": ""
}`),
},
@@ -62,7 +62,7 @@ var Schemas = map[string]Schema{
"GET /api/collections/:id": {
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "auto_assign_rules": null,
"color": "", "icon": "",
"view_settings": {}, "books": [], "book_count": 0,
"is_smart": false, "up_next": [], "created_at": ""
}`),
@@ -71,7 +71,7 @@ var Schemas = map[string]Schema{
Request: handlers.UpdateCollectionRequest{},
Response: json.RawMessage(`{
"id": "", "user_id": "", "name": "", "description": "",
"color": "", "icon": "", "auto_assign_rules": null,
"color": "", "icon": "",
"view_settings": {}, "created_at": ""
}`),
},
@@ -601,15 +601,14 @@ var Schemas = map[string]Schema{
Request: json.RawMessage(`{"item_ids": []}`),
Response: json.RawMessage(`{"status": "reordered"}`),
},
"PUT /api/collections/:id/rules/:ruleId": {
// Handler is SetRuleEnabled: toggle only, request {enabled required},
// response {collection_id, is_smart}.
Request: json.RawMessage(`{"enabled": false}`),
"PUT /api/collections/:id/view-settings": {
// Merges chain_order into the collection's view_settings JSONB
// (playlist toggles share the doc and are preserved).
Request: json.RawMessage(`{"chain_order": "newest"}`),
Response: json.RawMessage(`{
"collection_id": "", "is_smart": false
"id": "", "chain_order": "", "view_settings": {}
}`),
},
// --- Beam batch 7: scanner ---
"POST /api/scanner/scan": {
Request: handlers.ScanLibraryRequest{},
@@ -835,22 +834,6 @@ var Schemas = map[string]Schema{
"DELETE /api/devices/:id/file-aliases/:aliasId": {
Response: json.RawMessage(`{"message": ""}`),
},
"POST /api/collections/:id/rules": {
// First rule converts a static collection to smart.
Request: handlers.AddRuleRequest{},
Response: json.RawMessage(`{
"collection_id": "", "rule": {}, "is_smart": false
}`),
},
"DELETE /api/collections/:id/rules/:ruleId": {
Response: json.RawMessage(`{
"collection_id": "", "is_smart": false
}`),
},
"POST /api/collections/test-rules": {
Request: handlers.TestRulesRequest{},
Response: json.RawMessage(`{"matches": [], "total": 0}`),
},
"POST /api/collections/:id/books": {
// Bulk add: {operations: [{collection_id, book_ids:[...]}]}.
Request: json.RawMessage(`{
+215
View File
@@ -0,0 +1,215 @@
package docs
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestUIControlLabels holds the UI documentation to the source of the UI.
// The glossary (docs/developer/ui-controls.md) is the canonical spelling of
// every control the docs cite; this test greps that spelling against the
// server-rendered templates, so a renamed button fails make test in the
// same commit as the rename — the class of rot screenshots could never be
// gated. Dynamic (Alpine-composed) labels are OR-checked with their `also`
// fragments instead of requiring the composed string to exist.
//
// Scope is substring-level (not word-boundary): the point is catching
// renames and deletions, not stopping coincidental substrings. New controls
// cited in prose get a glossary entry in the same commit (writing-ui-docs.md
// is the standard; the filter docs are the hands-off exemplar).
const (
labelGlossaryRel = "../../docs/developer/ui-controls.md"
labelTemplRootRel = "../../templates"
labelTSRootRel = "../../web/src"
)
type docGlossaryEntry struct {
label string
where string
does string
source string
reason string
also []string
skip bool
}
// docCorpus concatenates every file under root ending in suffix.
func docCorpus(t *testing.T, root string, suffix string) string {
t.Helper()
abs, err := filepath.Abs(root)
if err != nil {
t.Fatalf("abs %s: %v", root, err)
}
var b strings.Builder
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, suffix) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
b.Write(raw)
b.WriteString("\n")
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
return b.String()
}
func corpusName(source string) string {
if source == "ts" {
return "web/src"
}
return "templ"
}
func parseUIControlsBlock(t *testing.T, raw string) []docGlossaryEntry {
t.Helper()
var block strings.Builder
inBlock, foundAny := false, false
for _, line := range strings.Split(raw, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "```controls"):
inBlock, foundAny = true, true
continue
case trimmed == "```" && inBlock:
inBlock = false
continue
}
if inBlock {
block.WriteString(line)
block.WriteString("\n")
}
}
if !foundAny {
t.Fatalf("no ```controls block found in %s", labelGlossaryRel)
}
var entries []docGlossaryEntry
haveCur := false
cur := docGlossaryEntry{}
flush := func() {
if haveCur {
entries = append(entries, cur)
haveCur = false
}
}
for _, line := range strings.Split(block.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
t.Fatalf("malformed controls line %q (want `key: value`)", line)
}
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
switch key {
case "label":
flush()
haveCur = true
cur = docGlossaryEntry{label: val}
case "where":
cur.where = val
case "does":
cur.does = val
case "source":
cur.source = val
case "also":
for _, frag := range strings.Split(val, "|") {
if frag = strings.TrimSpace(frag); frag != "" {
cur.also = append(cur.also, frag)
}
}
case "skip":
cur.skip = val == "true"
case "reason":
cur.reason = val
case "note":
// free text for humans
default:
t.Fatalf("unknown field in controls block: %q (allowed: label|where|does|source|also|skip|reason|note)", key)
}
}
flush()
return entries
}
func TestUIControlLabels(t *testing.T) {
raw, err := os.ReadFile(labelGlossaryRel)
if err != nil {
t.Fatalf("read glossary: %v", err)
}
entries := parseUIControlsBlock(t, string(raw))
if len(entries) == 0 {
t.Fatalf("glossary has no entries — add at least one before citing controls")
}
tmplRequired, tsRequired := false, false
for _, e := range entries {
if e.source == "ts" {
tsRequired = true
} else {
tmplRequired = true
}
}
corpora := map[string]string{}
if tmplRequired {
corpora[corpusName("")] = docCorpus(t, labelTemplRootRel, ".templ")
}
if tsRequired {
corpora[corpusName("ts")] = docCorpus(t, labelTSRootRel, ".ts")
}
seen := map[string]int{}
for _, e := range entries {
if e.label == "" {
t.Errorf("entry with empty label (where: %q, does: %q)", e.where, e.does)
continue
}
seen[e.label]++
if e.where == "" || e.does == "" {
t.Errorf("control %q: `where` and `does` are required", e.label)
}
if e.skip && e.reason == "" {
t.Errorf("control %q: skip=true requires a reason", e.label)
}
switch e.source {
case "", "templ", "ts":
default:
t.Errorf("control %q: unknown source %q (templ|ts)", e.label, e.source)
}
if e.skip {
continue
}
fragments := append([]string{e.label}, e.also...)
found := false
for _, frag := range fragments {
if strings.Contains(corpora[corpusName(e.source)], frag) {
found = true
break
}
}
if !found {
t.Errorf("control %q (%s): none of the fragments [%s] found in %s sources — the label was renamed or the glossary is stale. Update ui-controls.md in the same commit as the rename.",
e.label, e.where, strings.Join(fragments, " | "), corpusName(e.source))
}
}
for label, n := range seen {
if n > 1 {
t.Errorf("control %q listed %d times — one entry per control", label, n)
}
}
}
+39 -2
View File
@@ -6,6 +6,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/setupstatus"
"context"
"errors"
@@ -15,6 +16,7 @@ import (
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
@@ -122,9 +124,15 @@ type UserProfile struct {
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
// Whether the web reader/player should hold a Screen Wake Lock.
// Response-only struct: omit nothing — default_audio_speed is NOT NULL
// DEFAULT 1.0 (writes validate 0.25–4), so a real profile always emits
// it; omitempty here would drop a contract field from the docs gate.
DefaultAudioSpeed float64 `json:"default_audio_speed"` // Whether the web reader/player should hold a Screen Wake Lock.
KeepScreenAwake bool `json:"keep_screen_awake"`
// Resume rewind: seconds before the saved position the audio
// player starts (open-resume and every genuine unpause). 0 is a
// real value (exact resume) — never omitted.
AudioResumeRewind int `json:"audio_resume_rewind_seconds"`
}
type ProfileUpdateRequest struct {
@@ -141,6 +149,11 @@ type ProfileUpdateRequest struct {
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
// Resume rewind seconds, as the form select sends it ("0"/"5"/"10").
// The string shape (keep_screen_awake precedent) lets the form
// select an explicit 0=Off while JSON clients that omit it decode
// as "" and leave the column untouched.
AudioResumeRewind string `json:"audio_resume_rewind_seconds,omitempty" form:"audio_resume_rewind_seconds" validate:"omitempty,oneof=0 5 10"`
}
// Register handles POST /api/auth/register
@@ -550,6 +563,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
KeepScreenAwake: user.KeepScreenAwake,
AudioResumeRewind: int(user.AudioResumeRewindSeconds),
})
}
@@ -714,6 +728,24 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Resume-rewind preference. The form select always sends it
// ("0"/"5"/"10"); JSON clients that omit it decode as "" and leave
// the column untouched. Strict allow-list — the three choices the
// UI offers are the only values the API accepts.
if req.AudioResumeRewind != "" {
rewind, err := strconv.Atoi(req.AudioResumeRewind)
if err != nil || (rewind != 0 && rewind != 5 && rewind != 10) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "audio_resume_rewind_seconds must be 0, 5, or 10"})
}
err = h.db.UpdateUserAudioResumeRewind(c.Request().Context(), database.UpdateUserAudioResumeRewindParams{
ID: targetUserUUID,
AudioResumeRewindSeconds: int32(rewind),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Keep-screen-awake preference. The form dropdown always sends it
// ("true"/"false"); JSON clients that omit it decode as "" and leave
// the column untouched.
@@ -1145,6 +1177,11 @@ func (h *AuthHandler) generateJWT(userID string) (string, error) {
}
func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userID pgtype.UUID) error {
// Registration runs BEFORE the JWT exists — the request context
// carries no identity, and the collections RLS insert policy
// (2026-10-31) would reject the five system-collection seeds.
// Declare the new user as the actor: owner == actor, policy passes.
ctx = rls.WithUser(ctx, userID)
defaultCollections := []struct {
Name string
Description string
+49 -2
View File
@@ -43,6 +43,21 @@ type BookInfo struct {
// it empty rather than pay a join).
ViewerPercentage *float64 `json:"viewer_percentage"`
LibraryName string `json:"library_name"`
// Episode-collections extras (plan item 2.3): populated only by the
// collection-detail adapter. IsEpisode renders the episode-row
// variant in the grid; FeedTitle disambiguates same-named episodes;
// DownloadState gates the play affordance. Hidden=true means the
// LEFT JOIN missed under RLS (unsubscribed episode, or a
// family-lens hidden book) — the row renders as an "unavailable"
// hint instead of silently vanishing. Adapters that don't load
// visibility leave Hidden false (cards stay fully rendered). The
// four keys are NOT omitempty: every BookInfo surface emits the
// same stable key set (the viewer_percentage precedent —
// contract-pin widening, docs/contract_pins.md 2026-10-30).
IsEpisode bool `json:"is_episode"`
FeedTitle string `json:"feed_title"`
DownloadState string `json:"download_state"`
Hidden bool `json:"hidden"`
}
// LibraryTypeMap builds libraryID→library-type-name for badge resolution
@@ -151,8 +166,7 @@ func BookCardsFromCollectionDashRows(rows []database.GetCollectionItemsForDashbo
ID: uuid.UUID(mi.ID.Bytes),
Title: mi.Title,
Author: utils.PgTextString(mi.Author),
LibraryID: mi.LibraryID,
CoverPath: mi.CoverImagePath,
LibraryID: mi.LibraryID, CoverPath: mi.CoverImagePath,
FormatGroup: mi.FormatGroup,
LibraryType: libTypes[uuid.UUID(mi.LibraryID.Bytes).String()],
}))
@@ -180,6 +194,39 @@ func BookCardsFromCollectionItemRows(rows []database.GetCollectionItemsRow, libT
return cards
}
// BookCardsFromCollectionDetailed builds cards from the
// GetCollectionItemsDetailed rows (plan item 2.3): the collection's
// full membership with per-row visibility. RLS-invisible rows
// (unsubscribed episode, family-lens hidden book) become Hidden cards
// — the grid renders them as "unavailable" hint rows; episode rows
// carry feed title + download state for the play affordance. Unknown
// library types resolve no badge; hidden rows need none anyway.
func BookCardsFromCollectionDetailed(rows []database.GetCollectionItemsDetailedRow, libTypes map[string]string) []BookInfo {
cards := make([]BookInfo, 0, len(rows))
for _, r := range rows {
if !r.Visible {
cards = append(cards, BookInfo{
MediaItemID: uuid.UUID(r.MediaItemID.Bytes).String(),
Hidden: true,
})
continue
}
isEpisode := r.Source.String == "podcast_episode"
card := buildBookCard(cardSource{
ID: uuid.UUID(r.MediaItemID.Bytes),
Title: utils.PgTextString(r.Title),
Author: utils.PgTextString(r.Author),
CoverPath: r.CoverImagePath,
FormatGroup: r.FormatGroup.String,
})
card.IsEpisode = isEpisode
card.FeedTitle = r.FeedTitle.String
card.DownloadState = r.DownloadState.String
cards = append(cards, card)
}
return cards
}
// BookCardsFromUnified builds cards from search-unified rows. Those
// rows carry the library type name as a SQL column, so this adapter
// needs no LibraryTypeMap query at all — and the viewer's own
+314 -288
View File
@@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -26,10 +25,10 @@ type CollectionHandler struct {
connManager *wsync.ConnectionManager
}
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
return &CollectionHandler{
db: db,
collectionService: services.NewCollectionService(db),
collectionService: services.NewCollectionService(db, search...),
libraryService: libraryService,
connManager: connManager,
}
@@ -40,17 +39,113 @@ type CreateCollectionRequest struct {
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart"
// Custom-section builder payload (adapted 2026-10-31): the page
// posts the legacy rules vocabulary — rules as a JSON-encoded array
// (a string), a match_type select, hand-picked book ids, and the
// dashboard pin. The server converts rules → filter tree at write
// time and attaches the manual picks; storage stays tree-only.
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
MatchType string `json:"match_type"`
ManualBookIDs []string `json:"manual_book_ids"`
LibraryID string `json:"library_id"` // selection context only — not stored
ShowOnDashboard bool `json:"show_on_dashboard"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
// auto_assign_rules. Persisted as-is (any). RawMessage for
// round-trip safety. JSON bodies bind FilterTree; FORM bodies
// (the create modal's urlencoded hx-post) bind FilterTreeForm —
// echo's binder cannot set an any-typed field from a form string
// (the create-modal seed POST 400'd "invalid request" on it,
// 2026-10-31).
FilterTree any `json:"filter_tree"`
// FilterTreeForm mirrors filter_tree for application/x-www-form-
// urlencoded submissions: the raw JSON as a string, validated by
// the same parseRequestFilterTree path.
FilterTreeForm string `form:"filter_tree"`
}
// parseBuilderRules decodes the custom-section builder's rules payload:
// the page posts JSON.stringify(rules) — a JSON string CONTAINING the
// array (it has done so since the page was written, 2026-02-19) — while
// JSON clients may post the array directly. Null/empty = no rules.
func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var rules []services.Rule
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return nil, err
}
if strings.TrimSpace(encoded) == "" {
return nil, nil
}
return rules, json.Unmarshal([]byte(encoded), &rules)
}
return rules, json.Unmarshal(raw, &rules)
}
// treeFilterJSON returns the request's filter tree as JSON storage
// bytes (nil when none): object payloads marshal; strings (a legacy
// double-encoded client, the custom-section builder's known bug class)
// are written verbatim after object-attempt.
func treeFilterJSON(payload any) ([]byte, error) {
switch v := payload.(type) {
case nil:
return nil, nil
case map[string]interface{}:
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return b, nil
case string:
return []byte(v), nil
default:
return nil, fmt.Errorf("filter_tree must be an object")
}
}
// parseRequestFilterTree validates the request's tree against the
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
// a stored 500 at render time.
func parseRequestFilterTree(payload any) ([]byte, error) {
b, err := treeFilterJSON(payload)
if err != nil || b == nil {
return b, err
}
if _, err := services.ParseFilterTree(string(b)); err != nil {
return nil, fmt.Errorf("invalid filter_tree: %w", err)
}
return b, nil
}
// resolveTreePayload — the tree payload from either transport: JSON
// bodies carry the object in FilterTree, form bodies (the create
// modal's urlencoded hx-post) the raw JSON string in FilterTreeForm.
func resolveTreePayload(jsonTree any, formTree string) any {
if formTree != "" {
return formTree
}
return jsonTree
}
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler). JSON bodies bind FilterTree; form bodies bind
// FilterTreeForm (echo's binder cannot set an any-typed field from
// a form string — see CreateCollectionRequest).
FilterTree any `json:"filter_tree"`
FilterTreeForm string `form:"filter_tree"`
Description string `form:"description" json:"description"`
Color string `form:"color" json:"color"`
Icon string `form:"icon" json:"icon"`
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// A2c §3.3: playlist toggles — merged into view_settings server-side
// (nil = untouched). []string because the form posts a hidden "false"
@@ -94,7 +189,6 @@ type CollectionSummary struct {
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
CreatedAt string `json:"created_at"`
BookCount int `json:"book_count"`
IsSmart bool `json:"is_smart"`
@@ -138,6 +232,55 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Custom-section builder adapter: convert the posted rules to a
// filter tree at WRITE time (the save-fix — the page's payload had
// never once bound successfully before this).
builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules)
if rulesErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()})
}
matchMode := req.MatchType
if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny {
matchMode = services.RuleMatchAny
}
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
if treeJSON == nil && len(builderRules) > 0 {
tree, cerr := services.RulesToFilterTree(builderRules, matchMode)
if cerr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()})
}
b, merr := json.Marshal(tree)
if merr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()})
}
// The strict-parse guarantee holds for converted trees too: an
// invalid tree is a 400 at write, never a stored 500 at render.
if _, verr := services.ParseFilterTree(string(b)); verr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()})
}
treeJSON = b
}
// New podcast smart collections are BORN with the reversed listening
// default (2026-11-01 user request): seed chain_order into
// view_settings at creation — the dropdown shows "Feed order
// (reversed)" selected and the chain/page machinery reads the stored
// value exactly as if picked. Episodes-scope trees only (books have
// no episode chain); merge semantics never clobber a value the
// request itself sent; existing collections are untouched.
if treeJSON != nil {
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
t.FilterScope() == services.ScopeEpisodes {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
if _, exists := req.ViewSettings["chain_order"]; !exists {
req.ViewSettings["chain_order"] = "reverse"
}
}
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
@@ -145,8 +288,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
req.ShowOnDashboard,
treeJSON,
)
if err != nil {
if errors.Is(err, services.ErrCollectionNameTaken) {
@@ -155,8 +299,19 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Manual picks: a section = rules + hand-picked books. Attach after
// create; unparsable ids are skipped (same leniency as AddBooks).
for _, bookIDStr := range req.ManualBookIDs {
bookID, perr := uuid.Parse(bookIDStr)
if perr != nil {
continue
}
_ = h.collectionService.AddBookToCollection(c.Request().Context(),
uuid.UUID(collection.ID.Bytes), bookID, userUUID)
}
bookCount := int32(0)
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
isSmart := req.CollectionType == "smart" || treeJSON != nil
response := map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
@@ -165,7 +320,6 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
@@ -197,7 +351,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
response := make([]CollectionSummary, 0, len(collections))
for _, col := range collections {
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
isSmart := services.IsCollectionSmart(col)
if !includeAuto && isSmart {
// Legacy default for API clients (the Android app pre-dates
// smart collections): rule-driven collections stay opt-in so
@@ -213,12 +367,12 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
}
}
if isSmart {
rules := h.collectionService.ParseRules(col.AutoAssignRules)
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil {
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr == nil {
bookCount = len(matches)
@@ -240,7 +394,6 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
Description: utils.PgTextString(col.Description),
Color: utils.PgTextString(col.Color),
Icon: utils.PgTextString(col.Icon),
AutoAssignRules: col.AutoAssignRules,
CreatedAt: col.CreatedAt.Time.String(),
BookCount: bookCount,
IsSmart: isSmart,
@@ -286,17 +439,17 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
}
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
isSmart := services.IsCollectionSmart(collection)
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
if isSmart {
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
LibraryID: libUUID,
VisibleIDs: visibleIDs,
ViewerUserID: MustGetAuthenticatedUser(c).ID,
})
if evalErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
@@ -305,7 +458,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
dashboardSvc := services.NewDashboardService(h.db)
dashboardSvc := services.NewDashboardService(h.db, h.collectionService.Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
@@ -409,8 +562,8 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": viewSettings,
"filter_tree": treeEnvelope(collection.FilterTree),
"books": bookList,
"book_count": len(bookList),
"is_smart": isSmart,
@@ -450,6 +603,31 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.ViewSettings["show_up_next"] = *v
}
treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm))
if treeErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
}
// Applying a tree to a collection with NO curated listening order
// seeds the reversed default (2026-11-01 — the creation seed's twin):
// a static collection becoming smart lands with the same default a
// born-smart one gets. Triple-guarded: the client's explicit
// chain_order wins, a STORED chain_order is never clobbered, and
// only episodes-scope trees seed (books have no episode chain).
if treeJSON != nil && (req.ViewSettings == nil || req.ViewSettings["chain_order"] == nil) {
if t, perr := services.ParseFilterTree(string(treeJSON)); perr == nil &&
t.FilterScope() == services.ScopeEpisodes {
if existing, gerr := h.collectionService.GetCollection(c.Request().Context(), collectionID); gerr == nil {
var stored map[string]interface{}
_ = json.Unmarshal(existing.ViewSettings, &stored)
if stored == nil || stored["chain_order"] == nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["chain_order"] = "reverse"
}
}
}
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
@@ -457,10 +635,16 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
treeJSON,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Collections RLS: another user's collection is INVISIBLE —
// the update touched no rows. Answer 404, not a 500 that
// reads like a bug (2026-10-31 collections RLS).
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -471,7 +655,6 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
"description": utils.PgTextString(collection.Description),
"color": utils.PgTextString(collection.Color),
"icon": utils.PgTextString(collection.Icon),
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
"view_settings": json.RawMessage(collection.ViewSettings),
"created_at": collection.CreatedAt.Time.String(),
}
@@ -484,6 +667,99 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusCreated, response)
}
// ValidChainOrders — the collection chain's listening-order vocabulary
// (plan §10 R2: a collection's playback order is CURATED on the
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
// "" means native (the pre-chain-order default).
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
// CollectionChainOrder resolves a collection row's curated chain order
// from its view_settings JSONB — "" (native) when absent/unknown. The
// episode chain producer routes OrderEpisodes through this; the
// view-settings PUT writes the key (SetCollectionChainOrder).
func CollectionChainOrder(viewSettings []byte) string {
if len(viewSettings) == 0 {
return ""
}
var vs map[string]interface{}
if json.Unmarshal(viewSettings, &vs) != nil {
return ""
}
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
return s
}
return ""
}
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
// {"chain_order": "..."}: merges the key into the collection's stored
// view_settings (the playlist toggles share that JSONB — a blind write
// would clobber them). The episode chain producer
// (buildSmartCollectionSequenceContext) honors it.
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
var req struct {
ChainOrder string `json:"chain_order"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if !ValidChainOrders[req.ChainOrder] {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "chain_order must be one of: native, newest, oldest, reverse"})
}
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
// Same owner gate as the sibling config mutation (ReorderItems) —
// my endpoint writes the row, so it must not let a non-owner flip
// someone else's chain order.
if u, ok := c.Get("user").(database.Users); ok {
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes))
if err != nil || !owns {
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
}
} else {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"})
}
settings := map[string]interface{}{}
if len(collection.ViewSettings) > 0 {
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
}
}
if req.ChainOrder == "" || req.ChainOrder == "native" {
// Both spellings mean the default — drop the key entirely so the
// stored doc stays clean.
delete(settings, "chain_order")
} else {
settings["chain_order"] = req.ChainOrder
}
blob, err := json.Marshal(settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewSettings: blob,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"chain_order": req.ChainOrder,
"view_settings": settings,
})
}
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -851,278 +1127,25 @@ func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uu
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
}
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
if services.IsCollectionSmart(collection) {
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are tree-based — membership is managed by the collection's filter tree, not by hand")
}
return nil
}
type AddRuleRequest struct {
Field string `json:"field" validate:"required"`
Operator string `json:"operator" validate:"required"`
Value string `json:"value" validate:"required"`
Enabled *bool `json:"enabled"`
Priority int `json:"priority"`
// treeEnvelope renders the stored filter tree for the envelope: nil
// (static collections) serializes as null, trees as the object.
func treeEnvelope(t []byte) any {
if len(t) == 0 {
return nil
}
var v interface{}
if json.Unmarshal(t, &v) == nil {
return v
}
return nil
}
// AddRule handles POST /api/collections/:id/rules — appends one rule to
// the ruleset. The first rule converts a static collection to smart.
func (h *CollectionHandler) AddRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
collection, err := h.GetCollectionData(c, collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
if collection.IsSystemCollection.Bool {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
}
var req AddRuleRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
Field: req.Field,
Operator: req.Operator,
Value: req.Value,
Enabled: req.Enabled,
Priority: req.Priority,
})
if err != nil {
var ruleErr *services.RuleValidationError
if errors.As(err, &ruleErr) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"rule": rule,
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
// the enabled toggle; the payload may only carry {"enabled": bool}.
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
var req struct {
Enabled *bool `json:"enabled" validate:"required"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
ruleID := c.Param("ruleId")
if ruleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
}
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
if err != nil {
if strings.Contains(err.Error(), "rule not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
})
}
type TestRulesRequest struct {
Rules []map[string]interface{} `json:"rules" validate:"required"`
}
type BookMatch struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
MatchReason string `json:"match_reason"`
}
func (h *CollectionHandler) TestRules(c *echo.Context) error {
var req TestRulesRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Candidates mirror what smart collections actually evaluate against:
// the requester's visible libraries and live items only — the preview
// must not promise matches the live render will never produce.
visibleIDs, err := h.userVisibleLibraryIDs(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
var visibleArg []pgtype.UUID
if len(visibleIDs) > 0 {
visibleArg = visibleIDs
}
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
LibraryID: pgtype.UUID{},
VisibleLibraryIds: visibleArg,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
}
var matches []BookMatch
for _, item := range mediaItems {
matchReason := h.checkRulesAgainstBook(item, req.Rules)
if matchReason != "" {
author := ""
if item.Author.Valid {
author = item.Author.String
}
matches = append(matches, BookMatch{
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
Title: item.Title,
Author: author,
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
MatchReason: matchReason,
})
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"matches": matches,
"total": len(matches),
})
}
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, rules []map[string]interface{}) string {
for _, rule := range rules {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
value, _ := rule["value"].(string)
if h.evaluateRule(item, field, operator, value) {
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
}
}
return ""
}
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
var itemValue string
switch field {
case "genre":
if item.Genre.Valid {
itemValue = item.Genre.String
}
case "series":
if item.Series.Valid {
itemValue = item.Series.String
}
case "author":
if item.Author.Valid {
itemValue = item.Author.String
}
case "language":
if item.Language.Valid {
itemValue = item.Language.String
}
case "publisher":
if item.Publisher.Valid {
itemValue = item.Publisher.String
}
case "copyright_year":
if item.CopyrightYear.Valid {
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
}
case "tags":
if len(item.Tags) > 0 {
itemValue = strings.Join(item.Tags, ", ")
}
}
return h.compareValues(itemValue, operator, value)
}
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
switch operator {
case "equals":
return itemValue == ruleValue
case "not_equals":
return itemValue != ruleValue
case "contains":
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "not_contains":
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "starts_with":
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "ends_with":
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
case "greater_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum > ruleNum
case "less_than":
itemNum, err1 := strconv.Atoi(itemValue)
ruleNum, err2 := strconv.Atoi(ruleValue)
if err1 != nil || err2 != nil {
return false
}
return itemNum < ruleNum
}
return false
}
// Bulk Operations
// POST /api/collections/bulk-add-books
// Bulk add books to multiple collections
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
@@ -1268,9 +1291,12 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Book-scope preview (the rules page remains books-first until the
// filter-unification pass); no viewer progress loaded here, so
// played/unplayed rules preview at the no-progress default.
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
@@ -0,0 +1,30 @@
package handlers
import "testing"
// The collection chain's curated order (plan §10 R2): the resolver
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
// refuses unknown orders (they mean native).
func TestCollectionChainOrder(t *testing.T) {
cases := []struct {
name string
vs string
want string
}{
{"absent doc", "", ""},
{"empty object", `{}`, ""},
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
{"newest", `{"chain_order":"newest"}`, "newest"},
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
{"non-string means native", `{"chain_order":3}`, ""},
{"corrupt doc means native", `not json`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
}
})
}
}
-415
View File
@@ -1,415 +0,0 @@
package handlers
import (
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
// Test collection rule matching with various operators
func TestEvaluateRule_Equals_StringMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_Equals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_NotEquals_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.True(t, result)
}
func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Contains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
}
func TestEvaluateRule_Contains_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_NotContains_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
result := handler.evaluateRule(item, "author", "not_contains", "clarke")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Foun")
assert.True(t, result)
}
func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "starts_with", "Robots")
assert.False(t, result)
}
func TestEvaluateRule_EndsWith_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Series: pgtype.Text{String: "Foundation", Valid: true},
}
result := handler.evaluateRule(item, "series", "ends_with", "tion")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_LessThan_Matches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.True(t, result)
}
func TestEvaluateRule_LessThan_NoMatch(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
result := handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestEvaluateRule_NullField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{Valid: false}, // NULL
}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.False(t, result, "Should return false for NULL fields")
}
func TestCompareValues_CaseInsensitive(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "contains", "science fiction")
assert.True(t, result, "Contains should be case-insensitive")
result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION")
assert.True(t, result)
}
func TestCompareValues_EmptyString(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("", "contains", "test")
assert.False(t, result, "Empty string should not contain anything")
result = handler.compareValues("test", "contains", "")
assert.True(t, result, "Non-empty string contains empty string")
}
func TestCompareValues_NumericEdgeCases(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("0", "greater_than", "-1")
assert.True(t, result)
result = handler.compareValues("9999", "less_than", "10000")
assert.True(t, result)
}
// Test rule checking with multiple rules
func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match at least one rule")
assert.Contains(t, reason, "genre")
}
func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.NotEmpty(t, reason, "Should match second rule")
assert.Contains(t, reason, "author")
}
func TestCheckRulesAgainstBook_NoMatches(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Mystery", Valid: true},
Author: pgtype.Text{String: "Agatha Christie", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true},
}
rules := []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction",
},
{
"field": "author",
"operator": "contains",
"value": "Asimov",
},
}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Should not match any rules")
}
func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []map[string]interface{}{}
reason := handler.checkRulesAgainstBook(item, rules)
assert.Empty(t, reason, "Empty rules should return empty reason")
}
// Test complex rule scenarios
func TestComplexRule_MultipleConditions(t *testing.T) {
handler := &CollectionHandler{}
// Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951
item := database.MediaItems{
Title: "Foundation",
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true},
Series: pgtype.Text{String: "Foundation", Valid: true},
}
tests := []struct {
name string
field string
operator string
value string
expected bool
}{
{
name: "Match genre exactly",
field: "genre",
operator: "equals",
value: "Science Fiction",
expected: true,
},
{
name: "Match author substring",
field: "author",
operator: "contains",
value: "asimov",
expected: true,
},
{
name: "Match year greater than",
field: "copyright_year",
operator: "greater_than",
value: "1950",
expected: true,
},
{
name: "Match year less than",
field: "copyright_year",
operator: "less_than",
value: "2000",
expected: true,
},
{
name: "Match series starts with",
field: "series",
operator: "starts_with",
value: "Found",
expected: true,
},
{
name: "Match series ends with",
field: "series",
operator: "ends_with",
value: "tion",
expected: true,
},
{
name: "No match for genre",
field: "genre",
operator: "equals",
value: "Fantasy",
expected: false,
},
{
name: "No match for author",
field: "author",
operator: "contains",
value: "Tolkien",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := handler.evaluateRule(item, tt.field, tt.operator, tt.value)
assert.Equal(t, tt.expected, result)
})
}
}
// Test edge cases and error handling
func TestEvaluateRule_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction")
assert.False(t, result, "Invalid operator should return false")
}
func TestEvaluateRule_NonExistentField(t *testing.T) {
handler := &CollectionHandler{}
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
result := handler.evaluateRule(item, "nonexistent_field", "equals", "value")
assert.False(t, result, "Non-existent field should return false")
}
func TestCompareValues_InvalidOperator(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("test", "invalid_op", "value")
assert.False(t, result, "Invalid comparison operator should return false")
}
func TestCompareValues_NumericConversion(t *testing.T) {
handler := &CollectionHandler{}
// Valid numeric strings
result := handler.compareValues("100", "greater_than", "99")
assert.True(t, result)
result = handler.compareValues("99", "less_than", "100")
assert.True(t, result)
// Invalid numeric strings (should return false)
result = handler.compareValues("abc", "greater_than", "100")
assert.False(t, result, "Non-numeric string should not compare as greater than")
result = handler.compareValues("100", "greater_than", "xyz")
assert.False(t, result, "Non-numeric value should not allow comparison")
}
-83
View File
@@ -3,95 +3,12 @@ package handlers
import (
"testing"
"bookhoard/internal/database"
"bookhoard/internal/utils"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestCompareValues_Equals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "equals", "Science Fiction")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "equals", "Fantasy")
assert.False(t, result)
}
func TestCompareValues_Contains(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Isaac Asimov", "contains", "asimov")
assert.True(t, result)
result = handler.compareValues("Isaac Asimov", "contains", "clarke")
assert.False(t, result)
}
func TestCompareValues_GreaterThan(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("2021", "greater_than", "2000")
assert.True(t, result)
result = handler.compareValues("1999", "greater_than", "2000")
assert.False(t, result)
}
func TestCompareValues_NotEquals(t *testing.T) {
handler := &CollectionHandler{}
result := handler.compareValues("Science Fiction", "not_equals", "Fantasy")
assert.True(t, result)
result = handler.compareValues("Science Fiction", "not_equals", "Science Fiction")
assert.False(t, result)
}
func TestEvaluateRule_Genre(t *testing.T) {
item := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "genre", "equals", "Science Fiction")
assert.True(t, result)
result = handler.evaluateRule(item, "genre", "equals", "Fantasy")
assert.False(t, result)
}
func TestEvaluateRule_Author(t *testing.T) {
item := database.MediaItems{
Author: pgtype.Text{String: "Isaac Asimov", Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "author", "contains", "asimov")
assert.True(t, result)
result = handler.evaluateRule(item, "author", "contains", "clarke")
assert.False(t, result)
}
func TestEvaluateRule_CopyrightYear(t *testing.T) {
item := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true},
}
handler := &CollectionHandler{}
result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000")
assert.True(t, result)
result = handler.evaluateRule(item, "copyright_year", "less_than", "2000")
assert.False(t, result)
}
func TestTextToString(t *testing.T) {
tests := []struct {
name string
+2 -2
View File
@@ -18,10 +18,10 @@ type DashboardHandler struct {
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
func NewDashboardHandler(db *database.Queries, search ...*services.SearchService) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
dashboardService: services.NewDashboardService(db, search...),
}
}
+45 -2
View File
@@ -243,6 +243,36 @@ func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *se
// ExecuteSearch performs search and returns results with count
// Public wrapper for shared search logic used by both JSON and HTML endpoints
// SearchService exposes the shared search engine for SSR surfaces
// that render a filter tree (the collection editor and bookshelf pages
// resolve id labels through it — 2026-11-01).
func (h *MediaHandler) SearchService() *services.SearchService { return h.searchService }
// ResolveFilterLabels — GET /api/filter/labels?field=X&ids=a,b,c names
// id-kind filter values straight from their source tables: the client
// path that never touches a server render (applying a SAVED filter
// from a cold bookshelf) would otherwise fall back to the suggest
// facet, whose rows are media-constrained and label only the ids whose
// media matches the tree's sibling rules (2026-11-01 feed repro).
// Visibility/RLS ride the request identity, same as the SSR embed.
func (h *MediaHandler) ResolveFilterLabels(c *echo.Context) error {
user := c.Get("user").(database.Users)
field := c.QueryParam("field")
if !services.IsIDValuedField(field) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "field is not an id-valued filter field"})
}
ids := services.ParseUUIDValues(strings.Split(c.QueryParam("ids"), ","))
if len(ids) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "ids required"})
}
if len(ids) > services.MaxResolveIDs {
ids = ids[:services.MaxResolveIDs]
}
labels := h.SearchService().ResolveFieldLabels(
c.Request().Context(), user.ID, field, ids)
return c.JSON(http.StatusOK, map[string]interface{}{"labels": labels})
}
func (h *MediaHandler) ExecuteSearch(ctx context.Context, params services.SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
return h.searchService.ExecuteSearch(ctx, params)
}
@@ -2559,6 +2589,13 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
tree, err := services.ParseFilterTree(qf)
if err != nil {
params.FilterParseErr = err.Error()
} else if len(tree.Children) == 0 {
// A qf with no rules is not a browse query: the deep-link
// contract answers 400 (pinned). The empty root gained
// meaning only as a STORED collection seed — where the
// composers define it as matching nothing. As a query it
// would silently mean "everything", so reject at the wire.
params.FilterParseErr = "qf tree has no rules"
} else {
params.FilterTree = tree
}
@@ -3064,14 +3101,19 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
// 1.0 IN the payload so the player applies it with zero extra round
// trips; default_playback_speed powers the dropdown's "(default)"
// marker. Speed is a preference, so this rides the authenticated
// user — anonymous readers just get 1.0.
// user — anonymous readers just get 1.0. The resume rewind rides
// the same user row (0 = exact resume).
playbackSpeed, defaultSpeed := 1.0, 1.0
resumeRewind := 0
if userID, ok := c.Get("user_id").(string); ok {
if userUUID, err := uuid.Parse(userID); err == nil {
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err == nil && userRow.DefaultAudioSpeed > 0 {
if err == nil {
if userRow.DefaultAudioSpeed > 0 {
defaultSpeed = float64(userRow.DefaultAudioSpeed)
}
resumeRewind = int(userRow.AudioResumeRewindSeconds)
}
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
@@ -3093,6 +3135,7 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
"tracks": tracks,
"playback_speed": playbackSpeed,
"default_playback_speed": defaultSpeed,
"resume_rewind_seconds": resumeRewind,
})
}
+5 -2
View File
@@ -41,10 +41,13 @@ func SecurityHeadersMiddleware() echo.MiddlewareFunc {
// the component and asserts the hash matches.
//
// style-src keeps 'unsafe-inline' for htmx-injected styles and
// Alpine :style attribute writes.
// Alpine :style attribute writes, plus blob: for the
// reader-generated stylesheets foliate injects at open time
// (blocked since the CSP migration — every /readers load
// logged a violation; selection/highlight styling rode on it).
// font/img blob: — the reader renders EPUB-embedded fonts and
// images from blob: URLs inside the foliate content frame.
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' "+ScriptHashThemeFlashGuard+"; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: https: blob:; font-src 'self' data: blob:; frame-src 'self' blob:")
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+3 -4
View File
@@ -17,17 +17,16 @@ func registerCollectionsRoutes(cfg *Config) {
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
// The chain's listening order lives ON the collection (plan §10 R2):
// merges into view_settings; the episode chain producer honors it.
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
collections.POST("/test-rules", cfg.CollectionHandler.TestRules)
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
// Smart collection rule CRUD (the rules builder persists through
// these; type follows rules presence — first rule = smart).
collections.POST("/:id/rules", cfg.CollectionHandler.AddRule)
collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule)
collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule)
// Device shelf mapping routes
deviceCollections := protected.Group("/devices/:id/collections")
+49 -6
View File
@@ -409,9 +409,19 @@ func registerFrontendRoutes(cfg *Config) {
}
}
// SSR id-label resolution for a URL-carried qf tree: the drawer's
// value cells show NAMES from first paint instead of raw uuids
// (2026-11-01 — same story as the collection editor above).
labelsJSON := "{}"
if qfRaw := c.QueryParam("qf"); qfRaw != "" {
if t, perr := services.ParseFilterTree(qfRaw); perr == nil {
labelsJSON = resolveTreeLabelsJSON(c, cfg, user.ID, t)
}
}
var buf bytes.Buffer
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -562,13 +572,17 @@ func registerFrontendRoutes(cfg *Config) {
colData := make([]templates.CollectionData, len(collections))
colService := cfg.CollectionHandler.CollectionService()
for i, col := range collections {
isSmart := colService.IsSmartCollection(col.AutoAssignRules)
isSmart := services.IsCollectionSmart(col)
bookCount := 0
if isSmart {
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
col, services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
ViewerUserID: func() pgtype.UUID {
uid, _ := uuid.Parse(user.ID)
return uuidToPGType(uid)
}(),
})
if evalErr == nil {
bookCount = len(matches)
@@ -696,6 +710,35 @@ func registerFrontendRoutes(cfg *Config) {
return renderErrorPage(c, "System collections cannot have rules", "not_allowed")
}
// The editor's vocabulary + prefill (item 3.4): the unified
// builder in the collection's scope. A stored tree renders
// as-is. (The legacy rules-array preload retired 2026-10-31
// with the rules-array storage — the tree is the only form.)
scope := "books"
treeJSON := "{}"
var parsedTree *services.FilterTree
if len(collection.FilterTree) > 0 {
if t, perr := services.ParseFilterTree(string(collection.FilterTree)); perr == nil {
scope = t.FilterScope()
if b, merr := json.Marshal(t); merr == nil {
treeJSON = string(b)
}
parsedTree = t
}
}
fieldsPayload, merr := json.Marshal(services.FilterFieldsPayload(scope))
if merr != nil {
fieldsPayload = []byte("[]")
}
fieldsJSON := string(fieldsPayload)
// SSR id-label resolution: the stored tree arrives as bare ids;
// embed the id→name map so value cells render NAMES at first
// paint (2026-11-01: the client's pick-time cache dies with the
// page, and the quiet suggest fetch can't label ids whose media
// doesn't match the tree's sibling rules — raw uuids on refresh).
labelsJSON := resolveTreeLabelsJSON(c, cfg, user.ID, parsedTree)
colData := templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -703,11 +746,11 @@ func registerFrontendRoutes(cfg *Config) {
Color: utils.PgTextString(collection.Color),
Icon: utils.PgTextString(collection.Icon),
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules),
IsSmart: services.IsCollectionSmart(collection),
}
var buf bytes.Buffer
err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf)
err = templates.CollectionRules(user, colData, fieldsJSON, treeJSON, scope, labelsJSON).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+72 -7
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
@@ -75,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
AudioResumeRewind: int(userDB.AudioResumeRewindSeconds),
KeepScreenAwake: userDB.KeepScreenAwake,
ShowPodcastsNav: showPodcasts,
}, nil
@@ -270,6 +272,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
chainOrder := ""
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if s, ok := vs["chain_order"].(string); ok {
chainOrder = s
}
}
}
return templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -278,6 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
Icon: collection.Icon.String,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
ChainOrder: chainOrder,
BookCount: bookCount,
}
}
@@ -286,25 +298,72 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
// detail page and the books-grid partial: smart evaluation, a dashboard
// query section, or manual membership. Callers resolve the scope:
// libUUID/libScope/isAll/scoped per their own resolution rules.
// resolveTreeLabelsJSON — SSR id-label resolution for a page-rendered
// filter tree: walks the tree's id-kind rules, resolves uuids to display
// names through the search engine (RLS/visibility ride the request
// identity), and returns the JSON map embedded into the builder mount.
// Best-effort: any failure yields "{}" and the page renders with the
// client's fallbacks intact (2026-11-01, filter_labels.go for the WHY).
func resolveTreeLabelsJSON(c *echo.Context, cfg *Config, userID string, tree *services.FilterTree) string {
if tree == nil || len(tree.Children) == 0 {
return "{}"
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return "{}"
}
labels := cfg.MediaHandler.SearchService().FilterTreeLabels(
c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true}, tree)
if len(labels) == 0 {
return "{}"
}
b, merr := json.Marshal(labels)
if merr != nil {
return "{}"
}
return string(b)
}
func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, collUUID pgtype.UUID, collection database.Collections, collectionID string, libUUID pgtype.UUID, libScope pgtype.UUID, scoped bool, visibleIDs []pgtype.UUID) (books []handlers.BookInfo, isSmart bool, errCode string) {
colService := cfg.CollectionHandler.CollectionService()
isSmart = colService.IsSmartCollection(collection.AutoAssignRules)
isSmart = services.IsCollectionSmart(collection)
libTypes := handlers.LibraryTypeMap(c.Request().Context(), cfg.Queries)
if isSmart {
// Smart (dynamic) collection: membership is evaluated live —
// visible libraries, live items only, any enabled rule matches.
matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(),
colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{
// visible libraries, live items only, rules applied under the
// collection's match mode (item 2.4: any|all), with the
// viewer's progress feeding played/unplayed rules.
matches, evalErr := colService.EvaluateSmartMembership(c.Request().Context(),
collection, services.SmartEvalOptions{
LibraryID: libScope,
VisibleIDs: visibleIDs,
ViewerUserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if evalErr != nil {
return nil, isSmart, "books_load_error"
}
// The visible order IS the play order: the curated listening
// order (view_settings.chain_order, §10 R2) re-orders the
// episode cards exactly as the sequence producer will play
// them, so the select's effect is visible and Play launches
// from the card the user sees first (2026-11-01 user report:
// the select changed nothing on the page).
services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings))
books = handlers.BookCardsFromMediaItems(matches, libTypes)
// Episode-awareness for smart matches (item 2.5): episode cards
// carry the play affordance (chain via ?queue=collection:).
// BookCardsFromMediaItems preserves match order, so a parallel
// walk is enough; feed titles stay empty here (batch lookup is
// polish, not acceptance).
for i := range books {
if i < len(matches) && matches[i].Source == "podcast_episode" {
books[i].IsEpisode = true
books[i].DownloadState = matches[i].DownloadState
}
}
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
dashboardSvc := services.NewDashboardService(cfg.Queries)
dashboardSvc := services.NewDashboardService(cfg.Queries, cfg.CollectionHandler.CollectionService().Search())
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
if secErr != nil {
return nil, isSmart, "books_load_error"
@@ -331,11 +390,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID,
books = handlers.BookCardsFromCollectionDashRows(collItems, libTypes)
}
} else {
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), collUUID)
// Episode collections (plan item 2.3): the detailed query
// keeps RLS-invisible rows as Hidden cards (unsubscribed
// episodes render an "unavailable" hint) and tags episode
// rows with feed title + download state. Same position
// ordering; the legacy INNER-JOIN shape remains on the
// library-scoped branch above.
collItems, collErr := cfg.Queries.GetCollectionItemsDetailed(c.Request().Context(), collUUID)
if collErr != nil {
collItems = nil
}
books = handlers.BookCardsFromCollectionItemRows(collItems, libTypes)
books = handlers.BookCardsFromCollectionDetailed(collItems, libTypes)
}
}
return books, isSmart, ""
+10 -3
View File
@@ -381,6 +381,13 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
}
// podcastOrderForFeed reads the caller's order override (D5).
//
// The UNSET default is "reverse" (2026-11-01 user request): the platform
// is audiobook-first and feed document order runs newest-first, so
// native starts a book at its LAST chapter. This is the same per-feed
// setting the Order dropdown shows — page list, player chain, and
// download queue all resolve through it. Stored overrides are untouched:
// anyone who picked an order keeps exactly what they picked.
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
user := handlers.MustGetAuthenticatedUser(c)
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
@@ -388,16 +395,16 @@ func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
SettingKey: "podcast_order:" + feedID,
})
if err != nil {
return "native"
return "reverse"
}
var v struct {
Order string `json:"order"`
}
if err := json.Unmarshal(row, &v); err != nil {
return "native"
return "reverse"
}
if !services.ValidPodcastEpisodeOrder(v.Order) {
return "native"
return "reverse"
}
return v.Order
}
+189 -2
View File
@@ -10,6 +10,7 @@ import (
"bytes"
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -166,6 +167,17 @@ func registerReaderRoutes(cfg *Config) {
// reads; non-subscribers never reach this path in the first place.
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
// Queue override (plan item 2.3): ?queue=collection:<id> plays the
// episode inside an Up Next collection chain. Absent/malformed/
// not-owned ids fall back to the feed queue — the reader page must
// never hard-fail on a stale link.
if q := c.QueryParam("queue"); strings.HasPrefix(q, "collection:") {
if collID, err := uuid.Parse(strings.TrimPrefix(q, "collection:")); err == nil {
if seq := buildCollectionSequenceContext(c, cfg, item, userID, collID); seq != nil {
return seq
}
}
}
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
if err != nil || len(eps) == 0 {
return nil
@@ -175,8 +187,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
return nil
}
// Effective order override (D5): reader_settings KV.
order := "native"
// Effective order override (D5): reader_settings KV. UNSET defaults
// to "reverse" (2026-11-01: audiobook-first — native document order
// starts a book at its last chapter); the same default the podcast
// page's Order dropdown shows. Stored overrides are untouched.
order := "reverse"
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
UserID: userID,
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
@@ -233,6 +248,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
seq := &templates.PodcastSequenceContext{
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
FeedTitle: feed.Title,
// R2: this producer is the feed queue. The player's order
// control renders only for this source.
Source: "feed",
FeedID: uuid.UUID(item.SourceFeedID.Bytes).String(),
Order: order,
EpisodeIndex: idx + 1,
IndexTotal: len(ordered),
}
@@ -245,3 +265,170 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
}
return seq
}
// buildCollectionSequenceContext is the COLLECTION queue producer (plan
// item 2.3, §10 R2: same context shape, different owner). The queue is
// the collection's ordered downloaded episodes — the collection's
// order IS content (R1), so no feed-order override applies here and
// the player's order control stays hidden (source=collection).
// Ownership gates the chain: collections are user-scoped app state
// (no RLS on the table), and a foreign or deleted collection id falls
// back to the feed queue. Prev/next URLs carry ?queue=collection:<id>
// so the chain survives hops; backToDetail lands on the collection.
func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, collID uuid.UUID) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
owns, err := cfg.CollectionHandler.CollectionService().UserOwnsCollection(ctx, collID, uuid.UUID(userID.Bytes))
if err != nil || !owns {
return nil
}
coll, err := cfg.Queries.GetCollection(ctx, pgtype.UUID{Bytes: collID, Valid: true})
if err != nil {
return nil
}
// Smart collections compute their membership live (R1: computed
// order, never written back) — evaluate the rules for THIS viewer
// and chain the downloaded episode matches in native feed order
// (R3: every listening order routes through OrderEpisodes).
if services.IsCollectionSmart(coll) {
return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll)
}
// Downloaded episodes in collection order (SQL orders; RLS scopes
// the join — unsubscribed feeds drop out and the chain skips them).
eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true})
if err != nil || len(eps) == 0 {
return nil
}
// The curated listening order applies to static episode playlists
// too (§10 R2): native/"" keeps the manual position order (the SQL
// order above); reverse flips it; oldest/newest re-key on
// publication date with feed position as the tiebreak.
switch order := handlers.CollectionChainOrder(coll.ViewSettings); order {
case "reverse":
for i, j := 0, len(eps)-1; i < j; i, j = i+1, j-1 {
eps[i], eps[j] = eps[j], eps[i]
}
case "oldest", "newest":
services.OrderEpisodes(eps, order,
func(e database.GetCollectionEpisodesOrderedRow) int32 { return e.FeedPosition.Int32 },
func(e database.GetCollectionEpisodesOrderedRow) string {
if e.DatePublished.Valid {
return e.DatePublished.Time.Format("2006-01-02")
}
return ""
})
}
currentID := uuid.UUID(item.ID.Bytes).String()
idx := -1
for i, ep := range eps {
if uuid.UUID(ep.ID.Bytes).String() == currentID {
idx = i
break
}
}
if idx < 0 {
return nil // current episode not downloaded (or not in sequence)
}
queueParam := "?queue=collection:" + collID.String()
seq := &templates.PodcastSequenceContext{
FeedURL: "/collections/" + collID.String(),
FeedTitle: coll.Name,
Source: "collection",
EpisodeIndex: idx + 1,
IndexTotal: len(eps),
}
if idx > 0 {
seq.PrevURL = "/readers/" + uuid.UUID(eps[idx-1].ID.Bytes).String() + queueParam
}
if idx < len(eps)-1 {
seq.NextURL = "/readers/" + uuid.UUID(eps[idx+1].ID.Bytes).String() + queueParam
seq.NextTitle = eps[idx+1].Title
}
return seq
}
// buildSmartCollectionSequenceContext chains a SMART collection's live
// membership (plan item 2.5 acceptance: "Daily Fiction" → Play =
// today's episodes back-to-back). The rules evaluate for the requesting
// user (mode from the collection; family lens via the visible set);
// episode matches that are downloaded chain in native feed order.
// Books and un-downloaded matches never enter the queue (invariant 6).
func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, coll database.Collections) *templates.PodcastSequenceContext {
ctx := c.Request().Context()
colService := cfg.CollectionHandler.CollectionService()
visibleIDs, err := cfg.CollectionHandler.UserVisibleLibraryIDs(c)
if err != nil {
visibleIDs = nil
}
matches, err := colService.EvaluateSmartMembership(ctx, coll, services.SmartEvalOptions{
VisibleIDs: visibleIDs,
ViewerUserID: userID,
})
if err != nil {
return nil
}
type seqEp struct {
id string
title string
date string
pos int32
}
ordered := make([]seqEp, 0, len(matches))
currentID := uuid.UUID(item.ID.Bytes).String()
for _, m := range matches {
if m.Source != "podcast_episode" || m.DownloadState != "downloaded" {
continue
}
date := ""
if m.DatePublished.Valid {
date = m.DatePublished.Time.Format("2006-01-02")
}
ordered = append(ordered, seqEp{
id: uuid.UUID(m.ID.Bytes).String(),
title: m.Title,
date: date,
pos: m.FeedPosition.Int32,
})
}
if len(ordered) == 0 {
return nil
}
// Plan §10 R2: the collection's CURATED listening order (stored in
// view_settings.chain_order, set from the detail header) — not a
// player-side knob. "" / native / unknown all mean feed order.
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date },
)
idx := -1
for i, ep := range ordered {
if ep.id == currentID {
idx = i
break
}
}
if idx < 0 {
return nil
}
collID := uuid.UUID(coll.ID.Bytes).String()
queueParam := "?queue=collection:" + collID
seq := &templates.PodcastSequenceContext{
FeedURL: "/collections/" + collID,
FeedTitle: coll.Name,
Source: "collection",
EpisodeIndex: idx + 1,
IndexTotal: len(ordered),
}
if idx > 0 {
seq.PrevURL = "/readers/" + ordered[idx-1].id + queueParam
}
if idx < len(ordered)-1 {
seq.NextURL = "/readers/" + ordered[idx+1].id + queueParam
seq.NextTitle = ordered[idx+1].title
}
return seq
}
+22
View File
@@ -16,6 +16,10 @@ func registerSearchRoutes(cfg *Config) {
// JWT middleware for protected routes
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Filter label resolution (2026-11-01): names id-kind filter values
// from their source tables for trees the server didn't render (the
// cold saved-filter apply). Visibility/RLS ride the request identity.
protected.GET("/filter/labels", cfg.MediaHandler.ResolveFilterLabels)
// Search endpoint - returns JSON or HTML based on HX-Request header
protected.GET("/media-items/search", func(c *echo.Context) error {
// Check if HTMX request (expects HTML)
@@ -41,6 +45,24 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Episode picker scope (plan item 2.3): ?scope=episodes&show_checkbox=true
// swaps the book cards for YOUR subscriptions' episodes — visibility
// is the RLS policy's job (D20), the Go side adds nothing. The books
// branch keeps its podcasts exclusion; this is the only door in.
if c.QueryParam("scope") == "episodes" && c.QueryParam("show_checkbox") == "true" {
eps, err := cfg.Queries.SearchEpisodesForPicker(c.Request().Context(), c.QueryParam("q"))
if err != nil {
log.Printf("Episode picker search error: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
var buf bytes.Buffer
if err := templates.EpisodePickerGrid(eps).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously
+237 -274
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -30,24 +32,6 @@ type Rule struct {
Enabled *bool `json:"enabled,omitempty"`
}
// Known rule fields and operators — the rules builder UI and the
// persisted JSONB documents must agree with this vocabulary.
var ruleFields = map[string]bool{
"genre": true, "series": true, "author": true, "language": true,
"publisher": true, "copyright_year": true, "tags": true,
}
var ruleOperators = map[string]bool{
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
}
// RuleValidationError reports a rule payload the evaluator could never
// honor (unknown field/operator, empty value).
type RuleValidationError struct{ Msg string }
func (e *RuleValidationError) Error() string { return e.Msg }
// RuleEvaluation represents the result of evaluating a rule
type RuleEvaluation struct {
RuleID string `json:"rule_id"`
@@ -58,27 +42,28 @@ type RuleEvaluation struct {
// CollectionService handles collection management
type CollectionService struct {
db *database.Queries
// search powers the unified-engine path (item 3.2): tree-driven
// smart collections evaluate via ComposedLiveMedia. Optional —
// nil disables tree evaluation (legacy-only services, which keeps
// every existing constructor call site valid).
search *SearchService
}
// NewCollectionService creates a new collection service
func NewCollectionService(db *database.Queries) *CollectionService {
return &CollectionService{
db: db,
}
}
// CreateCollection creates a new collection
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
// Convert rules to JSONB ([]byte)
var rulesJSON []byte
if len(autoAssignRules) > 0 {
var err error
rulesJSON, err = json.Marshal(autoAssignRules)
if err != nil {
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
// NewCollectionService creates a new collection service. search is
// optional (variadic for constructor compatibility) — required for
// TREE-driven smart collections (item 3.2), ignored otherwise.
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
svc := &CollectionService{db: db}
if len(search) > 0 {
svc.search = search[0]
}
return svc
}
// CreateCollection creates a new collection. filterTree (item 3.2), when
// present, makes it smart — the qf engine is the only membership source
// (the legacy rules-array storage retired 2026-10-31).
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
// Convert view settings to JSONB ([]byte)
var settingsJSON []byte
if len(viewSettings) > 0 {
@@ -95,8 +80,9 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
Description: pgtype.Text{String: description, Valid: description != ""},
Color: pgtype.Text{String: color, Valid: color != ""},
Icon: pgtype.Text{String: icon, Valid: icon != ""},
AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON,
FilterTree: filterTree,
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
})
if err != nil {
@@ -132,14 +118,17 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
return collections, nil
}
// UpdateCollection updates a collection
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
// UpdateCollection rewrites the collection row. filterTree semantics:
// nil = leave the stored tree untouched (forms don't send trees).
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
// A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive.
if len(viewSettings) > 0 {
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err == nil && len(existing.ViewSettings) > 0 {
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
if filterTree == nil {
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
}
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
merged := stored
@@ -149,18 +138,18 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
viewSettings = merged
}
}
// Same convention as filterTree: a NIL view_settings payload
// means untouched — keep the stored doc. Without this, an edit
// that carries no settings wrote NULL over the row and silently
// dropped every stored key (chain_order, imported provenance);
// the 2026-10-31 chain-order test pinned exactly this clobber.
if viewSettings == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
viewSettings = stored
}
// Convert rules to JSONB ([]byte)
var rulesJSON []byte
if len(autoAssignRules) > 0 {
var err error
rulesJSON, err = json.Marshal(autoAssignRules)
if err != nil {
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
}
}
// Convert view settings to JSONB ([]byte)
var settingsJSON []byte
if len(viewSettings) > 0 {
@@ -177,12 +166,12 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
Description: pgtype.Text{String: description, Valid: description != ""},
Color: pgtype.Text{String: color, Valid: color != ""},
Icon: pgtype.Text{String: icon, Valid: icon != ""},
AutoAssignRules: rulesJSON,
ViewSettings: settingsJSON,
FilterTree: filterTree,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to update collection: %v", err)
return database.Collections{}, fmt.Errorf("failed to update collection: %w", err)
}
return collection, nil
@@ -260,162 +249,7 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID
}
return collections, nil
}
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
// rules. "null", empty, and malformed docs mean "no rules".
func (s *CollectionService) ParseRules(raw []byte) []Rule {
if len(raw) == 0 {
return nil
}
var rules []Rule
if err := json.Unmarshal(raw, &rules); err != nil {
return nil
}
return rules
}
// IsSmartCollection derives the collection type: a non-empty ruleset
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
// (stored collection_items links).
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
return len(s.ParseRules(raw)) > 0
}
// EnabledRules filters a ruleset down to the rules that participate in
// live evaluation (absent enabled = enabled for backward compatibility).
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
enabled := make([]Rule, 0, len(rules))
for _, rule := range rules {
if rule.Enabled == nil || *rule.Enabled {
enabled = append(enabled, rule)
}
}
return enabled
}
// validateRule checks a rule payload the evaluator will have to honor.
func (s *CollectionService) validateRule(rule Rule) error {
if !ruleFields[rule.Field] {
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
}
if !ruleOperators[rule.Operator] {
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
}
if strings.TrimSpace(rule.Value) == "" {
return &RuleValidationError{Msg: "rule value is required"}
}
if rule.Priority < 0 || rule.Priority > 10 {
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
}
return nil
}
// AddRule appends a rule to the collection's ruleset, generating an id
// when the caller did not supply one. Adding the first rule CONVERTS a
// static collection to smart (type is derived from rules presence).
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
if err := s.validateRule(rule); err != nil {
return database.Collections{}, Rule{}, err
}
collection, err := s.GetCollection(ctx, collectionID)
if err != nil {
return database.Collections{}, Rule{}, err
}
rules := s.ParseRules(collection.AutoAssignRules)
if rule.ID == "" {
rule.ID = uuid.NewString()
}
rules = append(rules, rule)
rulesJSON, marshalErr := json.Marshal(rules)
if marshalErr != nil {
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
}
return updated, rule, nil
}
// SetRuleEnabled flips a single rule's enabled flag in place.
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
collection, err := s.GetCollection(ctx, collectionID)
if err != nil {
return database.Collections{}, err
}
rules := s.ParseRules(collection.AutoAssignRules)
found := false
for i := range rules {
if rules[i].ID == ruleID {
rules[i].Enabled = &enabled
found = true
break
}
}
if !found {
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
}
rulesJSON, marshalErr := json.Marshal(rules)
if marshalErr != nil {
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
}
return updated, nil
}
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
// rule converts the collection back to static (type is derived).
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
collection, err := s.GetCollection(ctx, collectionID)
if err != nil {
return database.Collections{}, err
}
rules := s.ParseRules(collection.AutoAssignRules)
kept := make([]Rule, 0, len(rules))
found := false
for _, rule := range rules {
if rule.ID == ruleID {
found = true
continue
}
kept = append(kept, rule)
}
if !found {
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
}
rulesJSON, marshalErr := json.Marshal(kept)
if marshalErr != nil {
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
}
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
AutoAssignRules: rulesJSON,
})
if err != nil {
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
}
return updated, nil
}
// SmartEvalOptions scopes a smart collection's live evaluation.
} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// SmartEvalOptions scopes a smart collection's live evaluation.
type SmartEvalOptions struct {
// LibraryID, when valid, restricts matching to one library (the
// detail page's library switcher).
@@ -423,54 +257,98 @@ type SmartEvalOptions struct {
// VisibleIDs is the requesting user's visible-library set (family
// lens). Empty means no visible-library filter.
VisibleIDs []pgtype.UUID
// ViewerUserID scopes the candidate query's progress join — the
// played/unplayed rule is per-user. Zero UUID = no viewer (played
// rules then see "unplayed" for everything, honestly).
ViewerUserID pgtype.UUID
}
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
// every visible live book matched by at least one enabled rule is in.
// No stored links are consulted — "truly dynamic," always current.
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
enabled := s.EnabledRules(rules)
if len(enabled) == 0 {
return []database.MediaItems{}, nil
// IsCollectionSmart is the ONE smart detection: a collection is smart
// when it carries the unified filter tree (item 3.2). Every surface
// routes through this. (The legacy rules-array branch retired
// 2026-10-31 — the tree is the only smart storage.)
func IsCollectionSmart(coll database.Collections) bool {
return len(coll.FilterTree) > 0
}
var visibleArg []pgtype.UUID
if len(opts.VisibleIDs) > 0 {
visibleArg = opts.VisibleIDs
// Search exposes the composed search engine the service was built with
// (nil when the constructor omitted it) — callers composing secondary
// services that evaluate trees (the dashboard service) pass it through.
func (s *CollectionService) Search() *SearchService { return s.search }
// IsCollectionSmartColl is IsCollectionSmart on the service handle —
// the dashboard service composes this service and calls the detector
// through it (no import cycle, one detection definition).
func (s *CollectionService) IsCollectionSmartColl(coll database.Collections) bool {
return IsCollectionSmart(coll)
}
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
LibraryID: opts.LibraryID,
VisibleLibraryIds: visibleArg,
})
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
// tree-bearing collections evaluate through the unified filter engine.
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
return s.evaluateTreeMembership(ctx, coll, opts)
}
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
tree, err := ParseFilterTree(string(coll.FilterTree))
if err != nil {
return nil, fmt.Errorf("failed to load candidate books: %w", err)
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
}
if s.search == nil {
return nil, errors.New("tree collections require the search service (constructor wiring)")
}
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
}
matches := make([]database.MediaItems, 0, len(items))
for _, item := range items {
if s.mediaItemMatchesAnyRule(item, enabled) {
matches = append(matches, item)
}
}
return matches, nil
// episodeFinished is THE canonical finished definition (same predicate
// as hide_finished): viewer progress percentage >= 0.999.
func episodeFinished(viewerPercentage float64) bool {
return viewerPercentage >= 0.999
}
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
for _, rule := range rules {
if s.mediaItemMatchesRule(item, rule) {
// FieldMatches is THE single rule evaluator (the rules-builder preview
// delegates here too — one vocabulary, one semantics). Episode/user-state
// fields (feed/played/downloaded/published_within) evaluate directly;
// everything else extracts a string and hits the generic comparator.
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
switch field {
case "feed":
if !item.SourceFeedID.Valid {
return false
}
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
for _, raw := range strings.Split(value, ",") {
if strings.TrimSpace(raw) == feedID {
return true
}
}
return false
}
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool {
if rule.Enabled != nil && !*rule.Enabled {
case "played":
wantPlayed := value == "true"
return episodeFinished(viewerPercentage) == wantPlayed
case "downloaded":
wantDownloaded := value == "true"
return (item.DownloadState == "downloaded") == wantDownloaded
case "published_within":
if !item.DatePublished.Valid {
return false
}
days, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || days < 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
return item.DatePublished.Time.After(cutoff)
}
var fieldValue string
switch rule.Field {
switch field {
case "title":
fieldValue = item.Title
case "description":
if item.Description.Valid {
fieldValue = item.Description.String
}
case "genre":
if item.Genre.Valid {
fieldValue = item.Genre.String
@@ -501,11 +379,14 @@ func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule
}
}
return s.evaluateRule(fieldValue, rule.Operator, rule.Value)
return s.evaluateRule(fieldValue, operator, value)
}
// EvaluateRules evaluates auto-assign rules for a media item
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules []Rule) []RuleEvaluation {
// EvaluateRules evaluates auto-assign rules for a media item — the
// rules-builder preview's per-rule view. viewerPercentage is the
// requesting user's progress for this item (0 = none), so played /
// unplayed rules preview honestly (plan item 2.4).
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
var evaluations []RuleEvaluation
// Sort rules by priority (higher priority first)
@@ -527,40 +408,7 @@ func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, rules [
}
eval := RuleEvaluation{
RuleID: rule.ID,
Matches: false,
}
switch rule.Field {
case "genre":
if mediaItem.Genre.Valid {
eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value)
}
case "series":
if mediaItem.Series.Valid {
eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value)
}
case "author":
if mediaItem.Author.Valid {
eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value)
}
case "language":
if mediaItem.Language.Valid {
eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value)
}
case "publisher":
if mediaItem.Publisher.Valid {
eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value)
}
case "copyright_year":
if mediaItem.CopyrightYear.Valid {
yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32)
eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value)
}
case "tags":
if len(mediaItem.Tags) > 0 {
tagsStr := strings.Join(mediaItem.Tags, ", ")
eval.Matches = s.evaluateRule(tagsStr, rule.Operator, rule.Value)
}
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
}
// Calculate confidence based on rule type and priority
@@ -654,6 +502,121 @@ func compareNumbers(a, b, op string) bool {
}
// CreateDeviceShelfMapping creates a shelf mapping for a device
// ── Custom-section-builder adapter (2026-10-31) ──────────────────
// The builder posts the legacy rules vocabulary; these convert it to a
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
// (The 2026-10-31 retirement deleted these as READ-time conversion;
// the builder's tree migration revived them as the single write path.)
// The builder's match_type select: "any" (OR) | "all" (AND).
const (
RuleMatchAny = "any"
RuleMatchAll = "all"
)
// The builder posts the legacy rules vocabulary; these convert it to a
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
// (The 2026-10-31 retirement deleted these as READ-time conversion;
// the builder's tree migration revived them as the single write path.)
// RulesToFilterTree converts a legacy rules array + its match mode
// into a qf tree so a collection upgrades to the unified engine at its
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
// upgrade at write. Episode rules map mechanically (feed→in-list,
// played/downloaded→booleans, published_within→published_date
// within_days); legacy operators without an engine equivalent
// (starts_with/ends_with) fall back to contains — documented, and the
// operator labels stay in the rules docs.
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
children := make([]FilterChild, 0, len(rules))
scope := ""
for _, r := range rules {
child, err := ruleToFilterChild(r)
if err != nil {
return nil, err
}
if episodeOnlyFields[r.Field] {
scope = ScopeEpisodes
}
children = append(children, child)
}
if len(children) == 0 {
return nil, fmt.Errorf("no rules to convert")
}
mode := RuleMatchAny
if matchMode == RuleMatchAll {
mode = RuleMatchAll
}
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
}
func ruleToFilterChild(r Rule) (FilterChild, error) {
child := FilterChild{Field: r.Field}
switch r.Field {
case "feed":
// The rules format stores comma-separated feed IDs in one value.
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
return child, nil
case "downloaded":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "played":
child.Op = isTrueFalse(r.Value == "true")
return child, nil
case "published_within":
child.Field = "published_date"
child.Op = OpWithinDays
child.Values = []string{r.Value}
return child, nil
}
// Registry fields: legacy operators → engine operators.
switch r.Operator {
case "equals":
child.Op = OpIs
case "not_equals":
child.Op = OpIsNot
case "contains":
child.Op = OpContains
case "not_contains":
child.Op = OpNotContains
case "greater_than":
child.Op = OpAtLeast
case "less_than":
child.Op = OpAtMost
case "in":
child.Op = OpIn
child.Values = splitRuleValues(r.Value)
case "starts_with", "ends_with":
// No engine equivalent — contains is the honest nearest.
child.Op = OpContains
default:
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
}
child.Values = []string{r.Value}
return child, nil
}
func isTrueFalse(b bool) string {
if b {
return OpIsTrue
}
return OpIsFalse
}
func splitRuleValues(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
+13 -8
View File
@@ -24,7 +24,7 @@ func TestEvaluateRules_Equality(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -56,7 +56,7 @@ func TestEvaluateRules_NoMatch(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -88,7 +88,7 @@ func TestEvaluateRules_Contains(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -128,7 +128,7 @@ func TestEvaluateRules_PriorityOrder(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
@@ -164,7 +164,7 @@ func TestEvaluateRules_CopyrightYear(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -190,7 +190,7 @@ func TestEvaluateRules_MissingField(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -218,7 +218,7 @@ func TestEvaluateRules_CaseInsensitive(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
@@ -253,7 +253,7 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
},
}
evaluations := service.EvaluateRules(mediaItem, rules)
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
@@ -276,3 +276,8 @@ func TestEvaluateRules_PriorityBoost(t *testing.T) {
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
}
}
// TestRulesToFilterTree retired 2026-10-31 with the function it tested:
// rules-array storage is gone, the filter tree is the only smart
// membership. (Tests of deleted behavior are deleted — the asymmetry
// rule.)
+32 -39
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -17,10 +18,10 @@ type DashboardService struct {
collectionService *CollectionService
}
func NewDashboardService(db *database.Queries) *DashboardService {
func NewDashboardService(db *database.Queries, search ...*SearchService) *DashboardService {
return &DashboardService{
db: db,
collectionService: NewCollectionService(db),
collectionService: NewCollectionService(db, search...),
}
}
@@ -293,52 +294,44 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
// The query filters excluded rows in SQL and embeds the media row,
// so the items are MediaItems as-is.
var manualNonExcluded []database.MediaItems
items := make([]database.MediaItems, 0, len(manualItems))
seen := make(map[uuid.UUID]bool, len(manualItems))
for _, row := range manualItems {
manualNonExcluded = append(manualNonExcluded, row.MediaItems)
id := uuid.UUID(row.MediaItems.ID.Bytes)
if !seen[id] {
items = append(items, row.MediaItems)
seen[id] = true
}
}
var autoItems []database.MediaItems
if len(coll.AutoAssignRules) > 0 {
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
if err == nil {
for _, item := range allLibraryItems {
alreadyInCollection := false
for _, manualItem := range manualNonExcluded {
manualUUID := uuid.UUID(manualItem.ID.Bytes)
itemUUID := uuid.UUID(item.ID.Bytes)
if manualUUID == itemUUID {
alreadyInCollection = true
break
}
}
if alreadyInCollection {
continue
}
evaluations := s.collectionService.EvaluateRules(item, rules)
for _, eval := range evaluations {
if eval.Matches {
autoItems = append(autoItems, item)
break
}
}
// Smart sections (custom sections are rules + manual picks): the
// tree's LIVE membership rides along, scoped to the dashboard's
// active library and the viewing user (played rules are per-user).
// Evaluated items dedupe against the stored ones and the section
// caps at its limit. (Custom-section builder tree migration,
// 2026-10-31 — the rules-array branch this replaces evaluated the
// same way, minus the library scoping.)
if s.collectionService.IsCollectionSmartColl(coll) {
matches, evalErr := s.collectionService.EvaluateSmartMembership(ctx, coll, SmartEvalOptions{
LibraryID: libraryID,
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
})
if evalErr != nil {
log.Printf("dashboard: smart section %s evaluation failed: %v", coll.Name, evalErr)
}
for _, m := range matches {
id := uuid.UUID(m.ID.Bytes)
if !seen[id] {
items = append(items, m)
seen[id] = true
}
}
}
var finalItems []database.MediaItems
finalItems = append(finalItems, manualNonExcluded...)
finalItems = append(finalItems, autoItems...)
if len(finalItems) > limit {
finalItems = finalItems[:limit]
if len(items) > limit {
items = items[:limit]
}
return finalItems, nil
return items, nil
}
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more