Compare commits

..
55 Commits
Author SHA1 Message Date
John O'Keefe 2bec4e60bd test(pins): annotation lifecycles pin the restore GET, not just writes
Release / build-and-push (push) Successful in 5m11s
Answering the owner's round-trip question (highlight/note/progress
survives to the DB and restores on reopen) surfaced a gap: the
annotation lifecycle pins covered POST/PUT/DELETE but never the GET
list — the exact call web/src/reader/reader.ts makes when a book is
reopened. Every lifecycle now pins 'GET list restores the annotation':
the created row must come back by id with the update's fields applied
(highlights assert color, notes content, bookmarks title/cfi), proving
the DB write and the restore read through the same endpoint the UI
loads. Ran green in the rebuilt tests image.

Workflow note surfaced by this change: the tests service builds an
image (no source mount), so test edits need 'docker compose ... build
tests' (or run --build) before the filtered runner sees them — the
first run of the new subtest silently executed the stale image.
2026-11-02 18:29:54 -05:00
John O'Keefe dd963993eb fix(sync): scan events keep instance-wide fan-out as the sanctioned BroadcastToAll
Verifying the per-user chokepoint end to end surfaced a UI regression
it would have caused: web/src/header.ts runs the sitewide scan
indicator on scan_progress/scan_complete events in every browser, and
routing those to the triggering identity alone froze the indicator for
everyone else at its page-load state (active-summary stays the
load-time fallback, but live movement died). The source's own comment
declares the indicator sitewide by design, and scan payloads carry no
user data — job ids, file counts, progress percentages only.

Resolution honors both truths: BroadcastToAll is re-added as the ONE
sanctioned instance-wide fan-out, its doc comment making the decision
explicit (no user-derived data; anything carrying books, notes, or
annotations belongs in BroadcastToUser), and the two scan emitters use
it. TestBroadcastToAllReachesEveryone pins the exception alongside
TestBroadcastIsPerUser — audiences are now named decisions the
compiler and the tests both enforce.
2026-11-02 18:17:37 -05:00
John O'Keefe a431dfa10b fix(sync): websocket events are per-user — chokepoint the fan-out
Owner-flagged after the dev-docs audit: BroadcastProgressUpdate,
BroadcastAnnotationUpdate, and BroadcastConflictNotification fanned out
through the untargeted Broadcast, delivering one user's book progress,
conflict notices, and — worst — annotation payloads (which can carry
note/highlight text) to EVERY connected client on the instance. A
family-lens violation of exactly the kind the RLS move eliminated for
database reads: user B's client received events for user A's books,
including books in libraries hidden from B.

Fix is the RLS philosophy applied to the fan-out layer: enforcement at
one choke point. The untargeted Broadcast method and its channel/loop
machinery are deleted — BroadcastToUser is now the only door, and the
compiler forces every future emitter to name its audience instead of
relying on review. The three helpers take the acting user's ID; the
four sync call sites (progress.go x2, annotations.go x2) already had
the acting user in scope. Scan events were already per-triggering-
identity and collections already per-user — unchanged. Logging is
untouched: the only log line in the diff is the deleted method's own,
so docker logs remains the complete administrator audit trail.

TestBroadcastIsPerUser pins the invariant (alice's two devices receive
her three events; bob's client receives nothing — any message there is
a cross-user leak). Both websocket docs updated in the same commit:
the wire doc's fan-out section now states per-user delivery and the
chokepoint, and the gated endpoint doc's intro matches. Full make test
green.
2026-11-02 18:06:45 -05:00
John O'Keefe e957d385ec chore(devices): regenerate style.css after the modal move
The pairing input moved from the bottom of the Devices page into the
Add Device modal in d15f1155; that page was the sole user of the
mt-12 utility, so the rebuild Tailwind ran in make rebuild-app
afterwards purged the now-unused .mt-12 rule — 23 bytes lost, zero
selectors added or changed. Generated artifacts belong to the source
change that produced them and this one missed the commit; verified
the only delta is .mt-12 and that no source (web/src, templ, generated
files) references it any more.
2026-11-02 18:01:04 -05:00
John O'Keefe 06ec312863 fix(tests): uuid-suffixed websocket collection self-deletes
TestWebSocketUserScopedBroadcast creates "Admin Collection <uuid8>" on
the shared admin — deliberately NOT named with "test" or every run
would 409 on UNIQUE(user_id, name) against stranded past-run rows
(the comment in the test says exactly that), but it never deleted its
row, so the same runs accrued ten "Admin Collection" forever-rows the
name sweep can never touch. cleanupCollectionByID gives tests an
owner-scoped RLS-bridge delete-at-backstory: same minimal contract as
cleanupDeviceByIdentifier, for rows whose name must stay unique across
runs. Deleted the ten standing rows (owner-scoped, exactly what the
cleanup path does).

What CANNOT be swept, audited safe: the dev admin's real hand-made
collections (Saga reading-orders, Podcast 2, Smart Collection (Books))
carry no sweepable names; devices rely on identifier/token prefixes
tests own exclusively; the rls-phone hand account persists because the
transient-user sweep matches only @tests.bookhoard.internal /
@example.com emails.
2026-11-02 17:54:30 -05:00
John O'Keefe 7d88ec5184 fix(tests): test-created devices and fixture dirs now meet the cleanup contract
Test suites that attach devices to the SHARED admin (testuser, the live
dev account) escaped the harness sweeps whenever their names lacked
"test": createContractDeviceNamed named its devices by surface type
("koreader" — the web progress push adopts the device name as sync
source, so conflict tests drive a second named source without a
separate account), the reinstall regression test uses the web flow's
real "Reinstall Device" label, and opds_test.go's folders test named
its device "OPDS Folders Device". None had self-cleanup either, so
every suite run left 3 more rows on the dev account's device grid —
476 by the time anyone looked. TestDeviceSetup.CreateDevice also never
cleanup-registered, so any CURRENT or future helper-created device was
one rename away from the same leak.

Changes:
- TestDeviceSetup.CreateDevice self-deletes its row at test end
  (t.Cleanup): callers owe nothing to name hygiene any more.
- createContractDeviceNamed self-deletes likewise (contract-% prefix).
- device_test.go: core via cleanupDeviceByIdentifier on every device it
  attaches to the admin ("Reinstall Device" by unique identifier —
  the NAME is user-facing-labeled, sweeps must never torch it).
- Harness setup-time admin-device sweep gains the unambiguous harness
  patterns (dev_contract_* tokens, contract-%/opds-%/repro-reinstall-%
  identifiers) as the drift net; ran once with the rebuilt tests image
  and that sweep alone already purged the existing 476 rows.
- metadata_write_test.go now globs ALL test-metadata-write-* dirs in
  its RemoveAll instead of only its own pid's — killed runs left root-
  owned dirs on the user's uploads bind mount (7 found, deleted).

 audits: collections/saved-filters/libraries/sync_queue are clean;
 the only "test"-named media rows left are scans of the user's own
 on-disk test files (Test/, Bookhoard Test/, embedded " test" in two
 real books' metadata) — content, not suite debris.

Verified: filtered runs (contract/OPDS/reinstall/pairing/metadata)
green, zero device residue after the run, uploads mount clean.
2026-11-02 17:39:29 -05:00
John O'Keefe 0c31e9b88d fix(devices): pairing claim accepts urlencoded htmx form posts
The browser's Pair device button submits an application/x-www-form-
urlencoded <form> (htmx), but PairingCodeRequest/CancelRegistration-
Request carried only json: tags. Echo v5 routes form posts to the
'form:' binder, which maps by the form: struct tag exclusively — the
code arrived empty, normalized to "", and every browser claim failed
with the generic 'invalid pairing code' 400 even though the separate
lookup step (which reads c.FormValue directly, no struct) had just
resolved the same code. The API clients (plugin, Android, tests) post
JSON and were never affected.

Both request structs now carry dual form:/json: tags, matching the
auth.go convention. Adds TestPairingClaimAcceptsBrowserFormEncoding to
pin the form path: form-encoded lookup resolves, form-encoded claim
binds, device poll delivers credentials.

Found by the user clicking through the real Add Device modal; the
integration suite missed it because every prior claim test posted
application/json.
2026-11-02 17:12:15 -05:00
John O'Keefe d15f115505 fix(devices): pairing input moves into the Add Device modal
The pairing field sat at the bottom of the Devices page, below the
device grid — unreachable on any account with more than a screenful of
devices (the user's dev account has ~480 accumulated test devices;
scrolling 224k px to reach the field is a non-starter). The Add Device
button at the top of the page is where pairing intent already lives,
so the code input and Find device button now sit inside the Connect a
KOReader Device modal as instruction step 6, with the confirm card
swapping into the same modal (container keeps the pairing-lookup ids;
the htmx error-toast listener scopes to #add-device-modal now).

Verified live: modal renders the full instruction list + input, a
wrong code swaps the templ error partial inside the modal, and claim
still HX-Refreshes on success (modal state resets with the reload,
paired device appears in the grid).

ui-controls glossary where-fields and the COVERAGE row updated with
the control's new home.
2026-11-02 16:56:32 -05:00
John O'Keefe 5211edf264 docs(user): pairing-code flow for device setup guides
The setup guides walked the retired broadcast-list approve ("your device
appears under Pending Device Registrations — click Approve"), which no
longer exists. Both now describe the pairing-code flow: the e-reader
displays a 6-digit code, you type it into Pair a Device on your own
account, and only whoever holds the device can read the code — the
family-server gap that motivated the change. Adds a Pairing Code Not
Accepted troubleshooting case, refreshes the security practices
(claim only codes you can see on a device you are holding), and aligns
the remaining approval/pending wording throughout.
2026-11-02 16:11:31 -05:00
John O'Keefe 3e98fadb9f test(devices): integration coverage for the pairing-code flow
Replaces the reject-route test (behavior removed) with coverage of the
replacement flow, run against the shared dev DB per the doctrine:
fresh per-run regular user for deterministic claims, uuid-suffixed
identifiers so UNIQUE(device_identifier) stays clear across runs, and
device rows cascade away with the fresh user — testuser is untouched.

- TestPairingClaimHappyPath: lookup normalizes spaced digits, does not
  bind; claim binds to the claimer only; the device's status poll then
  delivers a token matching the stored row.
- TestPairingWrongCodeIsGenericAndNonDestructive: a miss answers the
  generic invalid-code error and leaves the real code claimable.
- TestPairingClaimBindsClaimerOnly: the admin account has no device
  row for another user's claim, and the used code answers 409.
- TestPairingReRegisterRetiresPriorCode: the old code and its
  registration UUID both die; the fresh code claims.
- TestPairingCancelByRegistrationUUID: cancel is idempotent, kills only
  its own registration (code and poll), and leaves a sibling
  registration untouched.
- TestPairingAttemptRateLimit: 13th lookup/claim attempt in a minute is
  refused with 429 (uses the fresh regular user — the limiter is
  process-global, so sibling tests sharing a token would otherwise
  pre-spend the budget).
- TestListPendingRegistrations now pins the invisibility invariant: a
  live registration appears in no account's pending list, total 0.
- TestRejectDeviceRegistrationRemoved pins the reject route's removal
  (404/405).
2026-11-02 16:11:25 -05:00
John O'Keefe fef79b1836 feat(devices): pair-a-device card replaces the pending registration list
The Devices page's Pending Device Registrations section (Approve/Reject
cards broadcast to every account) is replaced by an always-visible Pair
a Device field: type the 6-digit code shown on the registering e-reader,
a templ-rendered confirm card shows what will be bound, and confirming
claims the device to the signed-in account.

Rendering ownership (AGENTS.md): the confirm card and every lookup
outcome are templ partials (PairingLookupResult) swapped by htmx from a
new SSR route POST /devices/pairing/lookup — the server decides what UI
looks like, no JSON-built HTML. Errors render as content because htmx
only swaps 2xx bodies (the route is web-only; the JSON API keeps real
status codes), and one delegated htmx:responseError listener in
device-management.ts toasts the non-swap failure path.

The claim confirm button posts straight to /api/devices/claim; success
comes back as HX-Refresh (same pattern as token regeneration), so the
page reloads into the paired state. No new TS module is needed — plain
htmx attributes drive the whole flow.

Ride-alongs: the Add New Device modal loses the device-type select and
the kobo manual form (its post-register UX depended on the broadcast
list and silently produced an unclaimable registration; native Kobo
sync is still server-side-only and will get its own pairing UX when it
lands) — the modal is now a single Connect-a-KOReader-Device guide with
the pairing steps. PendingRegistrationData, convertPending, and the
pending plumbing in the page handler are gone with the section.
ui-controls.md glossary and tests/ui COVERAGE.md updated in the same
commit as the control rename, per the drift gates.
2026-11-02 16:11:15 -05:00
John O'Keefe 8fbacd9f46 feat(devices): pairing codes replace the registration broadcast
A pending device registration used to be announced to EVERY account's
Devices page, and whoever clicked Approve first bound the device — and
its freshly minted sync token — to their account. Worse, the pending
card displayed the registration UUID, which is also the bearer
credential on the unauthenticated /api/devices/register/status poll
channel: any family user who could see the list already held everything
needed to take the device. On a family server that is a
wrong-account-binding bug (accidental or deliberate); this commit
removes the class of problem rather than patching the race.

The new model is the Netflix/YouTube TV device flow: the registering
device displays a 6-digit pairing code, and the only person who can
read it — whoever holds the device — types it into THEIR account on the
Devices page. Possession of the device screen becomes the credential;
nothing is broadcast; the reject-on-someone-else's-behalf hole dies
with the list.

Server changes:
- POST /api/devices/register now mints pairing_code (crypto/rand,
  rejection-sampled for uniformity, collision-checked against live
  registrations) and retires prior unclaimed registrations with the
  same device_identifier, so re-registering leaves exactly one live
  attempt.
- POST /api/devices/claim (JWT) binds the registration behind a code to
  the caller's account, reusing the rotate-or-create approval logic
  factored out of ApproveDevice (shared bindRegistrationToUser helper;
  the Android self-approval route keeps working unchanged).
- POST /api/devices/claim/lookup (JWT) returns the confirm-card data
  without binding. Lookup and claim share a per-account budget (12
  attempts/min, DeviceRateLimiter gained a claim case) — the 1M-code
  space over a 5-minute TTL makes guessing infeasible without it.
- POST /api/devices/register/cancel lets the registering device end its
  own attempt (registration UUID bearer, idempotent, never touches a
  claimed registration).
- ListPendingRegistrations / GetPendingRegistrationsData are
  deprecated-by-emptiness: unclaimed registrations return to nobody.
- RejectDevice and its route are deleted — the broadcast list was its
  only consumer, and there is nothing left for anyone to reject.

Additive-only for pinned surfaces: the register response gains
pairing_code (Android DTO untouched), the four KOReader sync surfaces
and approve keep byte-identical shapes. contract_pins.md changelog
records the widening and the reject removal. Docs, schema registry, and
bruno mirrors move together per the drift gate; bruno_gen.go now keeps
hand-authored bruno/podcasts/ (API docs never existed for them, so the
next -update run would have swept them — latent landmine, defused).

Unit tests: code shape, live-collision avoidance, typed-code
normalization, and the resolve scopes (live/expired/claimed/miss).
2026-11-02 16:11:03 -05:00
John O'Keefe ab5c52c709 docs(dev): developer portal rewritten; tier definition closed in the standard
development.md was a bare link list whose 'implementation guides'
blurb still taught the pre-RLS visibility pattern. Now it's the
developer-tier portal: contributing/development.md as the start-here
(architecture/setup/testing — fresh and maintained), the gated API
reference with its per-area entry points and the openapi.yaml mirror,
the four protocol docs, the under-the-hood guides (RLS visibility,
scanner sidecars, calibre OPF, tag-normalization contract, Android
design), the standards (writing-ui-docs, glossary, contract pins),
and the gates enumerated — routes<->docs, Bruno parity, links, labels,
RLS invariant. writing-ui-docs.md's Developer-tier line closes the
open tone question: how-it-works, source-pinned, external software
linked not paraphrased.
2026-11-02 14:57:18 -05:00
John O'Keefe 35b210951a docs(dev): alpine-patterns rewritten around the CSP build
The March-era page had the right instincts (store state across swaps,
no manual DOM manipulation) but predated the @alpinejs/csp conversion
and never said the load-bearing fact: inline expressions are not
evaluated, so every x-data must reference an Alpine.data component —
which is why the whole attribute value is an expression and why
dynamic arguments go through JSONArg or data-attributes. Rewritten
with the banned list spelled out (inline handlers, hx-on*, js:
hx-vals, stray inline <script>) and each rule tied to the source that
enforces or embodies it (alpine.ts import, header.templ
whole-expression x-data, custom-section-builder data-attribute
pattern, bookPicker store as the living example). Every code sample
verified present in web/src.
2026-11-02 14:56:39 -05:00
John O'Keefe 4bd2788c7d docs(dev): library-visibility rewritten for the RLS era
The page still taught the pre-2026-10-29 app-side pattern —
visibleLibrarySet helpers, hand-rolled sqlc.narg('visible_library_ids')
clauses, per-endpoint 403 checks — which the RLS move made both wrong
and counterproductive: media_items, podcast_feeds, and collections are
now governed by row-level security filtered on the app.user_id GUC
that ScopedDB declares per statement. New media queries need NO
visibility plumbing; detached contexts RAISE unless the identity is
threaded (rls.WithUser); the NEVER list (session-scoped GUC, bypass
flags, superuser connections) and the auditors (TestRlsInvariant,
Playwright sweep, contract pins) are documented with the policy
mechanics they enforce. The series-per-library rule survives — it is
query semantics, not visibility filtering, and the policy makes
'visible' automatic under it.
2026-11-02 14:55:54 -05:00
John O'Keefe e40be80e78 docs(dev): websocket wire doc rewritten from source; endpoint doc ping claim fixed
The Feb-era 700-line page documented client->server JSON messages the
server never acted on (handleMessage logs only), a wrong ping/pong
exchange, and missed every message type added since. Rewritten tight
(~100 lines), every claim pinned: dual auth (JWT ?token= or device
Bearer header), initial_state on connect under the connecting
identity's RLS scope, the full 12-type message catalog (including
collection_updated, found per-user-targeted in the collections handler
and defined as a raw string outside the sync constants), WS-frame
keepalive (30s server ping, 90s read deadline — browsers answer
automatically), and the backpressure contract clients must honor:
bounded per-connection queues skip messages when full, so the socket
is a hint and REST refetch + fresh initial_state is the gap-healer.
Fan-out documented precisely: sync/scan events instance-wide,
collection events per-user.

Also corrected the gated endpoint doc (api/websocket/protocol.md):
its 'client sends {type:ping}, server responds pong' section
contradicted the handler; replaced with the true keepalive story.
Route pin untouched — the drift gates stay green.
2026-11-02 14:55:16 -05:00
John O'Keefe 8a40349fa1 docs(dev): delete developer/testing.md — contradicted current test doctrine
The Feb-era page described a wipe-all-test-users-per-run lifecycle,
which current doctrine explicitly forbids: the shared testuser is a
LIVE reader whose settings and history must survive every suite run,
cleanups are scoped preserve-lists, and hand-probes must be deleted.
Its recipes were also stale (no container runner, no pool rules, no
gates). The canonical home is docs/contributing/development.md
(architecture, setup, testing, project rules — kept fresh, 542 lines,
touched today), so the page was deleted rather than rewritten, with a
docRedirects entry sending old URLs there. No inbound doc links
existed.
2026-11-02 14:53:20 -05:00
John O'Keefe 51613566ac docs(user): koreader install delegated to KOReader's own documentation
Owner ruling: we support the plugin, not the software. The per-device
install walkthroughs (Kindle launcher paths, Kobo .adds layout,
PocketBook package steps) were prose-trusted third-party knowledge —
exactly the kind of content this project can't verify and shouldn't
half-cover, especially since real installs usually involve jailbreaks
and extra launchers. Replaced with an honest scope note (we can't tell
you how to modify your device) plus a link to the KOReader project's
official installation guide, verified live: github.com/koreader/
koreader#installation routes to per-model wiki pages (Android, Kindle,
Kobo, PocketBook, reMarkable, and more). The plugin sections (git-clone
install, server URL, registration, the full menu tree) are unchanged —
that's our side and stays pinned to plugin source.

Also fixed while grounding: the Mobileread forum link pointed at f=271
but KOReader's own README badges f=276 as their subforum; and the
resources section now carries their user-guide link (koreader.rocks/
user_guide) alongside installation.
2026-11-02 14:32:39 -05:00
John O'Keefe 0d4698c0de docs(admin): admin-guide front door + Administration area in the portal
The tenth and final page: the section directory with one-line coverage
per page, the fresh-instance quick path (libraries -> scan -> Base URL
-> users -> devices), and the when-something-looks-wrong triage table
mapping symptoms to pages. The portal (index.md) gains a For
Administrators area listing all nine admin pages; the user guide's
'being expanded separately' line becomes the done-state sentence.
Every control name quoted here exists in the glossary.
2026-11-02 14:17:13 -05:00
John O'Keefe 607e5a1708 docs(admin): settings page — every knob, what it does, and its range
The ninth page and the core of the owner's admin-docs brief: all 29
registry settings plus Base URL, documented setting-by-setting to the
admin tier — key, type, default, valid range, live-vs-restart, and
prose for what it does and what it means when that's confusing
(extractor-version as the gradual backfill lever vs. force rescan;
password rules apply at next set, never retroactively; tombstone TTL
as offline-device protection; rate limits as throttling behavior;
worker pool sizing honesty for small servers). Grouped exactly like
the page renders them, with the three save paths explained up front.
Ranges/defaults/flags from SettingDefaults; exclusion of the three
dedicated-UI keys from the tunables verified in the page handler.
Backfills the Settings links into dashboard.md and users.md now that
the target exists. Glossary gains Save Settings, Save Scan Settings,
both Tunable bucket headings, and Default Timezone.
2026-11-02 14:16:04 -05:00
John O'Keefe 39b0ab03fe docs(admin): users page — accounts, roles, last-admin guards
Eighth admin page. Documents the hidden-by-default create form (and why:
admin creation stays available with registration disabled — the form's
own design comment), live password requirements from active settings,
the card controls with their guards (last admin cannot be demoted or
deleted, dropdowns/buttons disable themselves with explanations), the
admin Edit User Profile modal (profile fields + role + Change Password
without current password — the admin-only difference), and delete
semantics (account + everything owned: devices, sessions, reading
data). Closes the loop on roles: admins get the Administration section;
users see only libraries visible to them, linking libraries.md for the
visibility story. Glossary gains + Create User and Edit User Profile.
(Settings referenced in prose only until that page lands — the first
amend of this commit briefly carried the same forward-link mistake the
dashboard page's amend fixed; pipefail is now the rule.)
2026-11-02 14:13:23 -05:00
John O'Keefe 535847477e docs(admin): all-podcasts page — the maintenance view
Seventh admin page. Documents the disk-usage sort (hogs surface first),
the header totals, each column's meaning (orphaned = no subscribers,
usually a departed user's feed), and the three actions with their real
semantics: Refresh (manual poll vs. the Settings→Podcasts interval),
Delete files (resets episodes to pending, re-download on demand, and
the non-obvious part quoted: a refresh will not re-materialize them),
Remove (instance-wide: rows + files + everyone's subscriptions, unlike
a user's own unsubscribe). Cross-links the user podcast guide for the
listening side. Glossary gains Delete files and a scoped Remove.
2026-11-02 14:12:46 -05:00
John O'Keefe aee1aafc74 docs(admin): hash conflicts page — the duplicate-content queue
Sixth admin page. Documents what creates conflict groups (scanner at
scan time + the hash backfill retroactively), the group card anatomy
(count badge, library + short SHA-256, per-copy rows with usage summary
highlighted when that copy holds reading data), and both resolutions
with verified semantics: Keep this copy (reparents the other copies'
children into the kept copy, deletes their catalog rows, records the
resolver — files on disk never touched, consistent with platform-wide
delete semantics) and Keep both (mark resolved, stop flagging, nothing
merged). Glossary gains Keep both and Keep this copy.
2026-11-02 14:12:10 -05:00
John O'Keefe 3e55ddd88a docs(admin): libraries page — the definition surface, with backlinks
Fifth admin page and the section's hub. Documents library creation
(name/description/type — type fixed at creation, verified in the edit
modal), the card's Force Rescan + Manage controls, and the full panel:
folders (add via Browse or manual path, shallow per-folder scan,
removal-unregisters-only semantics verified in DeleteLibraryFolder —
files and items untouched, archive lifecycle governs the rest; podcast
roots refuse hand scans), User Access visibility checkboxes (default-
allow, DB-enforced), the processing-issues link, and Delete Library's
exact destructive scope (records + reading data gone, files on disk
untouched). Ends with the at-a-glance scan table linking scanning.md.
Adds the Libraries backlinks to the processing-issues and archived
pages. Glossary gains Create Library, Manage, Edit Details, Delete
Library, View Archived, Purge Archived Now, Browse, User Access.
2026-11-02 14:11:25 -05:00
John O'Keefe ecb6a7385a docs(admin): archived items page — the missing-file lifecycle
Fourth admin page. Documents the two-consecutive-scans archive rule,
the history-preserving rationale, ARCHIVE_RETENTION_DAYS (default 90,
0 = manual-purge-only, verified in config.go), and the four actions with
verified semantics: Merge (progress/annotations/collections folded into
the active copy, per-user collisions keep the active copy — mirrors
hash-conflict resolution), Restore (immediate; re-archives if the file
is truly still gone), Delete (item + history, permanent), Purge All
Archived (the clean-slate button, confirm text quoted). Links to
scanning.md for why only library-wide scans archive. Glossary gains
Purge All Archived, Merge, and a scoped generic Delete entry.
2026-11-02 14:10:16 -05:00
John O'Keefe 88e3de606c docs(admin): processing issues page — per-library scan-failure ledger
Third admin page. Documents the severity stat cards (errors/warnings/
info, cards only appear when non-zero), the per-issue card fields, and
the three actions with their real semantics: Retry (per-item scanner
re-run — success closes the issue, failure surfaces the real error),
Retry all (sequential queue with done/total progress, deliberately not
a library-wide scan), Dismiss (warnings/info only, confirm-guarded;
errors cannot be dismissed). Links to scanning.md for the retry-vs-
library-scan distinction. Glossary gains Retry, Retry all, Dismiss.
2026-11-02 14:09:34 -05:00
John O'Keefe b3a8cbd582 fix(admin): library row button renamed 'Scan' -> 'Force Rescan', per owner
Owner ruling that closed the scan-semantics discussion: the code is
correct (per-library scan IS a force scan — hx-vals force:true since
706be09d), but the label hid that. Now the naming invariant holds
everywhere: anything with Force/Rescan in its name re-reads everything
in its scope; plain 'Scan' only exists at folder scope ('Scan this
folder', shallow) and in auto mode. Book-level Rescan verified as the
per-item force path (extractMetadata re-reads sidecars/tags regardless
of file size; recomputeHashInfo follows) — it is the documented answer
to 'I changed this book's sidecar'.

Glossary Scan entry replaced with Force Rescan (old label kept in also:),
scanning.md table + prose pick up the new label. Generated file
regenerated with the Dockerfile-pinned templ version from inside
templates/ so the diff is exactly the label strings.
2026-11-02 14:08:19 -05:00
John O'Keefe ed550fa914 docs(admin): dashboard page — stats, watcher, Quick Actions, scan panel
Second page of the admin tree. Documents the four-card stats grid with
what each number actually counts (instance-wide totals; Books = every
media item including podcast episodes), the File Watcher card's data
source (/api/scanner/watch/status, armed by Auto-Scan) and its
zero-libraries meaning, Force Rescan All Libraries with its real
mechanics (one job per library, skip-on-active, backfill tool vs.
routine shallow refresh), and the Scanning panel's live progress +
Cancel all scans. Links only to pages that exist (scanning.md); the
Settings reference stays prose until that page lands.

The real-tree nav test's Administration assertion loosens further: guide
leads, everything else strictly follows usabilityOrder — durable across
the remaining page landings. Also fixes the amend this commit replaces:
its first version briefly carried a forward link to the unwritten
settings.md (caught by the link gate, now removed) — the gate worked;
the && chain around go test let the bad commit through, which is exactly
why this amend exists.
2026-11-02 13:49:23 -05:00
John O'Keefe 89fc479bdb docs(admin): scanning page — the shallow vs. force taxonomy from source
First page of the admin tree, written to the new admin tier (what the
click actually does). Documents every scan surface with its verified
semantics: Force Rescan All Libraries (dashboard, force, all libraries,
one job each with skip-on-active), the per-library Scan button (ALSO a
force scan — hx-vals force:true since the button's birth commit 706be09d,
which corrects the owner's stated 'other scan buttons are shallow'
belief; flagged to the owner), Scan this folder (shallow, single-folder
scope, archive pass skipped for partial scans, podcast roots refused),
Auto-Scan + Scan Interval (the watcher + poll + startup triggers, all
shallow, default 60s range 1-3600), Processing Issues retry (per-item
sequential, deliberately not a library scan), and book-level Rescan /
Reset to Scanned. Plus the standing rules: one scan per library
(coalescing), background jobs with the dashboard progress panel, and
the two-consecutive-scans archive rule.

Glossary gains Scan and Scan this folder (same-commit rule for controls
cited in prose). The real-tree nav test's Administration assertion now
pins shape (Admin Guide, Libraries prefix + usabilityOrder subsequence)
instead of an exact slice, so pages landing one at a time don't churn it.
2026-11-02 13:47:40 -05:00
John O'Keefe 20754f15e2 docs(standard): admin tier defined; promises require owner approval
Two additions to the screenshot-free writing standard, both owner-
ruled during the admin-docs planning session:

- Tone by audience gains the Administration tier, ending the 'not yet
  decided' limbo for admin docs: plain language like the user docs, plus
  what every control actually does (semantics, cost, consequences)
  before the reader clicks, and per-setting what/does/means/range
  coverage. Calibration in one line: user = what you see, admin = what
  it does, developer = how it works. The Developer section keeps its
  separate tone discussion for the deferred dev-docs pass.

- New 'Promises need approval' section: no coming-soon / under-
  consideration / future-release language anywhere without the owner's
  explicit approval of that exact promise; speculative text found in the
  docs is reported, never quietly kept or quietly deleted. Provenance
  recorded (the 2026-11-02 device-promise removals; the Operations
  placeholders knowingly kept).
2026-11-02 13:40:34 -05:00
John O'Keefe bb141cdffc fix(admin): rename 'Scan All Libraries' to 'Force Rescan All Libraries'
Owner: the dashboard button's label under-promised what it does. The
action is a FORCE rescan (scanner.SetForce(true) via the scan worker):
every file re-read and re-extracted with original-scan semantics, hash
identifiers recomputed — the admin's backfill tool, per the scanner's
own source comments. The shallow scans (per-library button, per-folder
scan, processing-issues retry) deliberately skip unchanged files and
keep their 'Scan' labels; the naming now carries that distinction.

Subtitle updated to state the real semantics ('Re-reads every file and
re-extracts metadata and hashes — slow but thorough') instead of the
old 'Re-scan existing files and detect new items', which described the
shallow path. ui-controls.md gains the canonical glossary entry (old
label recorded under also: so anything still quoting it resolves); no
doc quoted the old label yet, so no doc edits were needed.
2026-11-02 13:39:52 -05:00
John O'Keefe 393a00e8dd docs(nav): Administration section lives in docs/admin/; sidebar ordered by usability
Two structural changes, one owner-approved plan:

- Move the admin guides out of docs/user/ into their own docs/admin/
  area — admin-guide.md becomes the Administration section landing page
  (pinned first), and the 32-line admin-library-management.md stub
  becomes admin/libraries.md, which the admin-docs build-out rebuilds
  into the real Libraries page next. Retired URLs 301 via a new
  docRedirects map in the docs HTTP handler (checked before .md
  canonicalization so even .md spellings one-hop to the new clean URL);
  the two inbound links (docs index, user-guide front door) updated.

- Sidebar direct items now sort by usabilityOrder — an explicit,
  per-section display order — instead of alphabetically: User Guide
  mirrors the 'Where to go' table on user-guide.md, Devices orders
  Android → KOReader → Kobo (alphabetically Kobo used to sort ahead of
  KOReader), and Administration pre-lists its planned pages so each
  lands in place as the admin build writes it. Unlisted docs fall back
  to alphabetical after the listed ones. Display-order only by
  construction: the URL-driven expand/highlight machinery keys on
  containsURL membership and never sees sort order — nav tests updated
  to pin the new orders plus a real-tree test
  (TestBuildNavigationUsabilityOrder) guarding all three sections.
2026-11-02 13:38:01 -05:00
John O'Keefe 7e2e1c4c60 docs: remove speculative promise language, de-brand Android design analogy
Owner rulings from the admin-docs planning session:

- sync-guide: dropped 'Kindle and reMarkable are under consideration' —
  no such plans exist, and promising support for proprietary device
  interfaces we will never build sets false expectations. Device posture
  stays as the table states it: web, Android app, KOReader anywhere
  (including jailbroken Kindle hardware), native Kobo the sole exception.
- calibre-integration: cut 'Custom Calibre Columns (Future)' + 'Stay
  tuned!' — unshipped-feature promises do not belong in docs; calibre
  integration is a by-product of the scanner's sidecar support, not a
  roadmap item. The features list describing Calibre itself stays (that
  is Calibre's promise, not ours).
- android-app: de-branded the two Kindle references per owner's legal
  caution — 'the Kindle app to Kindle cloud' analogy and 'quality bar is
  the Kindle app' are now 'any dedicated reader app to its server' / 'a
  first-class native reader app'; engineering substance unchanged.

Governance rule going forward (codified in writing-ui-docs.md in a
follow-up): no future-improvement or coming-soon language anywhere
without explicit owner approval; anything found gets reported, not
silently kept or silently removed.
2026-11-02 13:34:21 -05:00
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
153 changed files with 5143 additions and 3246 deletions

No files matched your search

+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 -1
View File
@@ -1,7 +1,7 @@
info:
name: Bulk Add Books
type: http
seq: 2
seq: 1
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Bulk Assign Books
type: http
seq: 3
seq: 2
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Bulk Remove Books
type: http
seq: 4
seq: 3
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Create Collection
type: http
seq: 5
seq: 4
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Create Shelf Mapping
type: http
seq: 6
seq: 5
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Delete Collection
type: http
seq: 7
seq: 6
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Delete Shelf Mapping
type: http
seq: 8
seq: 7
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Collection
type: http
seq: 9
seq: 8
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Book Collections
type: http
seq: 10
seq: 9
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: List Collections
type: http
seq: 11
seq: 10
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Preview Collection
type: http
seq: 12
seq: 11
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Reorder Collection Items
type: http
seq: 14
seq: 12
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Collection
type: http
seq: 16
seq: 14
http:
method: PUT
@@ -1,12 +1,18 @@
info:
name: Reject Device Registration
name: Cancel Device Registration
type: http
seq: 16
seq: 3
http:
method: POST
url: '{{base_url}}/api/devices/reject/{{registration_id}}'
url: '{{base_url}}/api/devices/register/cancel'
auth: inherit
body:
type: json
data: |-
{
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
}
settings:
encodeUrl: true
timeout: 0
@@ -23,9 +29,9 @@ runtime:
docs:
content: |-
# Reject Device Registration
# Cancel Device Registration
`POST /api/devices/reject/:registration_id` — auth: jwt.
`POST /api/devices/register/cancel` — auth: none.
Canonical reference: docs/developer/api/devices/reject_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
Canonical reference: docs/developer/api/devices/cancel_device_registration.md (docs site → Developer → API). This request is GENERATED from that doc; edit the doc, then run the drift test with -update.
type: text/markdown
+37
View File
@@ -0,0 +1,37 @@
info:
name: Claim Device (Pairing Code)
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/devices/claim'
auth: inherit
body:
type: json
data: |-
{
"pairing_code": "583912"
}
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: |-
# Claim Device (Pairing Code)
`POST /api/devices/claim` — auth: jwt.
Canonical reference: docs/developer/api/devices/claim_device.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 -1
View File
@@ -1,7 +1,7 @@
info:
name: Clear Shelf
type: http
seq: 3
seq: 5
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Delete Device
type: http
seq: 4
seq: 6
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Download Sidecar Config
type: http
seq: 5
seq: 7
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device
type: http
seq: 6
seq: 8
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device Collection Mappings
type: http
seq: 7
seq: 9
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device / Check Registration Status
type: http
seq: 8
seq: 10
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Shelf
type: http
seq: 9
seq: 11
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Get Device Sidecar Config
type: http
seq: 10
seq: 12
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Link Book
type: http
seq: 11
seq: 13
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: List User Devices
type: http
seq: 12
seq: 14
http:
method: GET
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
type: http
seq: 13
seq: 15
http:
method: GET
+37
View File
@@ -0,0 +1,37 @@
info:
name: Look Up Pairing Code
type: http
seq: 16
http:
method: POST
url: '{{base_url}}/api/devices/claim/lookup'
auth: inherit
body:
type: json
data: |-
{
"pairing_code": "583912"
}
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: |-
# Look Up Pairing Code
`POST /api/devices/claim/lookup` — auth: jwt.
Canonical reference: docs/developer/api/devices/lookup_pairing_code.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 -1
View File
@@ -1,7 +1,7 @@
info:
name: Regenerate Device Token
type: http
seq: 14
seq: 17
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Register Device
type: http
seq: 15
seq: 18
http:
method: POST
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Remove from Shelf
type: http
seq: 17
seq: 19
http:
method: DELETE
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Device
type: http
seq: 18
seq: 20
http:
method: PUT
+1 -1
View File
@@ -1,7 +1,7 @@
info:
name: Update Device Collection Mapping
type: http
seq: 19
seq: 21
http:
method: PUT
+31 -1
View File
@@ -168,7 +168,13 @@ func createContractDeviceNamed(t *testing.T, setup *TestServerSetup, name, devic
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create sync device")
_ = device
// Self-delete at test end: the contract helper attaches to the
// SHARED admin (the web progress push adopts the device name, so
// conflict tests drive a second named source without a separate
// account) and names it for its surface — "koreader" passes every
// name-based sweep, so without this the row survived forever (294
// of them ended up on the dev account's device grid).
t.Cleanup(func() { _ = setup.DB.DeleteDevice(context.Background(), device.ID) })
return token
}
@@ -1208,6 +1214,30 @@ func pinAnnotationLifecycle(t *testing.T, setup *TestServerSetup, fx contractFix
require.NoError(t, json.Unmarshal(raw, &body2))
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
// The restore half of the round trip: what the reader fetches
// when the book is reopened must return the persisted annotation
// with the update applied — written to the DB, read back through
// the same list endpoint the UI loads on open.
t.Run(kind+" GET list restores the annotation", func(t *testing.T) {
resp3 := doReq(t, "GET", annotationURL(setup.Server.URL, fx.itemID, kind, ""), nil, setup.RegularToken)
defer resp3.Body.Close()
require.Equal(t, http.StatusOK, resp3.StatusCode, "list must stay 200")
var list []map[string]interface{}
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&list))
var found map[string]interface{}
for _, row := range list {
if row["id"] == id {
found = row
break
}
}
require.NotNil(t, found, "created %s must come back in the list that restores a reopened book", kind)
updates, _ := updateBody.(map[string]interface{})
for key, want := range updates {
assert.Equal(t, want, found[key], "%s field must round-trip from the DB on restore", key)
}
})
t.Run(kind+" DELETE response", func(t *testing.T) {
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
defer resp3.Body.Close()
+315
View File
@@ -0,0 +1,315 @@
package main
// Pairing-code flow integration tests. The register response mints a
// 6-digit code shown on the registering device's screen; the person
// holding the device types it into THEIR account (POST /api/devices/claim).
// This replaces the retired broadcast-list approve, where
// first-click-wins let any account claim (or reject) a device it could
// see but not name.
//
// Devices claimed here land on the fresh per-run regular user, so the
// harness cascade removes them; identifiers are uuid-suffixed to stay
// clear of the UNIQUE(device_identifier) index across runs.
import (
"bookhoard/internal/database"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// registerPairingDevice initiates a registration and returns its decoded
// response (registration_id, pairing_code, ...).
func registerPairingDevice(t *testing.T, setup *TestServerSetup, identifier string) map[string]interface{} {
t.Helper()
regRequest := map[string]interface{}{
"device_name": "Test Pairing Device",
"device_type": "koreader",
"device_identifier": identifier,
}
regBody, _ := json.Marshal(regRequest)
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
var regResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &regResponse)
return regResponse
}
// pollRegistrationStatus hits the device-side status endpoint (no auth —
// the registration UUID is the bearer).
func pollRegistrationStatus(t *testing.T, setup *TestServerSetup, registrationID string) (int, map[string]interface{}) {
t.Helper()
body, _ := json.Marshal(map[string]string{"registration_id": registrationID})
req := httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
return rec.Code, resp
}
func pairingCodeBody(code string) []byte {
body, _ := json.Marshal(map[string]string{"pairing_code": code})
return body
}
// pairingLookupOrClaim POSTs to the given claim-family endpoint with the
// given bearer token and returns status + decoded body.
func pairingLookupOrClaim(t *testing.T, setup *TestServerSetup, path, token, code string) (int, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest("POST", path, bytes.NewReader(pairingCodeBody(code)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
return rec.Code, resp
}
// wrongPairingCode derives a code guaranteed to differ from the target's
// digits (rotate each digit +1 mod 10).
func wrongPairingCode(code string) string {
rotated := make([]byte, len(code))
for i := 0; i < len(code); i++ {
rotated[i] = byte('0' + (int(code[i]-'0')+1)%10)
}
return string(rotated)
}
// pairingFormRequest POSTs urlencoded form data — the Content-Type the
// browser's htmx forms actually send — with a Bearer token.
func pairingFormRequest(t *testing.T, setup *TestServerSetup, path, token, body string) (int, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
return rec.Code, resp
}
// TestPairingClaimAcceptsBrowserFormEncoding pins the htmx path: the
// confirm card claims via a urlencoded <form>, so echo v5 routes the
// Content-Type to the `form:` binder, which maps by `form:` struct tag
// only. (Regression: PairingCodeRequest shipped with a json-only tag,
// leaving the code empty and every browser claim failing with 400 —
// "how did lookup find the device if the code is wrong?") The JSON path
// stays pinned by the tests above; this covers the form twin.
func TestPairingClaimAcceptsBrowserFormEncoding(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-form-%s", uuid.New().String())
reg := registerPairingDevice(t, setup, identifier)
code := reg["pairing_code"].(string)
// Form-encoded lookup (the same call the API-side confirm card makes).
status, resp := pairingFormRequest(t, setup, "/api/devices/claim/lookup",
setup.RegularToken, "pairing_code="+code)
require.Equal(t, http.StatusOK, status, "form-encoded lookup must resolve the live code")
assert.Equal(t, "Test Pairing Device", resp["device_name"])
// Form-encoded claim — exactly what the browser's Pair device
// button sends (urlencoded hidden input, not JSON).
status, resp = pairingFormRequest(t, setup, "/api/devices/claim",
setup.RegularToken, "pairing_code="+code)
require.Equal(t, http.StatusOK, status, "form-encoded claim must bind the device")
assert.Equal(t, true, resp["approved"])
// The device's own poll delivers credentials — binding really happened.
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
require.Equal(t, http.StatusOK, pollStatus)
assert.Equal(t, "approved", pollResp["status"])
}
func TestPairingClaimHappyPath(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-happy-%s", uuid.New().String())
reg := registerPairingDevice(t, setup, identifier)
code, ok := reg["pairing_code"].(string)
require.True(t, ok, "register response must carry pairing_code")
require.Len(t, code, 6, "pairing code must be 6 digits")
for _, d := range code {
require.GreaterOrEqual(t, d, '0')
require.LessOrEqual(t, d, '9')
}
// Lookup first — the confirm card's data, without binding.
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, " "+code[:3]+" "+code[3:])
require.Equal(t, http.StatusOK, status, "lookup should resolve the live code (spaces normalized)")
assert.Equal(t, "Test Pairing Device", resp["device_name"])
assert.NotEmpty(t, resp["expires_in"])
// Still pending — lookup must not have bound anything.
pollStatus, pollResp := pollRegistrationStatus(t, setup, reg["registration_id"].(string))
require.Equal(t, http.StatusOK, pollStatus)
assert.Equal(t, "pending", pollResp["status"])
// Claim as the fresh regular user (deterministic account per doctrine).
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
require.Equal(t, http.StatusOK, status, "claim should bind the device")
assert.Equal(t, true, resp["approved"])
// The device's own poll now delivers credentials.
pollStatus, pollResp = pollRegistrationStatus(t, setup, reg["registration_id"].(string))
require.Equal(t, http.StatusOK, pollStatus)
assert.Equal(t, "approved", pollResp["status"])
token, ok := pollResp["auth_token"].(string)
require.True(t, ok, "approved poll must deliver the auth token")
require.NotEmpty(t, token)
// The device row belongs to the claimer, and the poll-delivered token
// matches the stored row.
devices, err := setup.DB.ListDevicesByUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(getRegularUserID(t, setup.DB)), Valid: true})
require.NoError(t, err)
var found *database.Devices
for i := range devices {
if devices[i].DeviceIdentifier == identifier {
found = &devices[i]
}
}
require.NotNil(t, found, "device row must exist under the claimer")
assert.Equal(t, token, found.AuthToken, "poll-delivered token must match the stored row")
}
func TestPairingWrongCodeIsGenericAndNonDestructive(t *testing.T) {
setup := setupTestServer(t)
reg := registerPairingDevice(t, setup, fmt.Sprintf("pairing-miss-%s", uuid.New().String()))
code := reg["pairing_code"].(string)
// A wrong code fails with the generic message — no hint whether any
// registration is waiting.
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, wrongPairingCode(code))
require.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, "invalid pairing code", resp["error"])
// The miss must not have killed or claimed the real registration.
status, resp = pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
require.Equal(t, http.StatusOK, status, "the correct code must still claim after a miss")
assert.Equal(t, true, resp["approved"])
}
func TestPairingClaimBindsClaimerOnly(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-scope-%s", uuid.New().String())
reg := registerPairingDevice(t, setup, identifier)
code := reg["pairing_code"].(string)
// The regular user claims it.
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code)
require.Equal(t, http.StatusOK, status)
// The admin (a different account) has no such device — and cannot
// re-claim the now-used code either.
adminDevices, err := setup.DB.ListDevicesByUser(context.Background(),
pgtype.UUID{Bytes: [16]byte(getTestUserID(t, setup.DB)), Valid: true})
require.NoError(t, err)
for _, d := range adminDevices {
assert.NotEqual(t, identifier, d.DeviceIdentifier,
"device must be bound to the claimer, not to every account")
}
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.Token, code)
assert.Equal(t, http.StatusConflict, status, "an already-used code must not bind again")
assert.Contains(t, resp["error"], "already used")
}
func TestPairingReRegisterRetiresPriorCode(t *testing.T) {
setup := setupTestServer(t)
identifier := fmt.Sprintf("pairing-retry-%s", uuid.New().String())
reg1 := registerPairingDevice(t, setup, identifier)
reg2 := registerPairingDevice(t, setup, identifier)
code1, ok := reg1["pairing_code"].(string)
require.True(t, ok)
code2, ok := reg2["pairing_code"].(string)
require.True(t, ok)
require.NotEqual(t, code1, code2, "each registration mints its own code")
// The retired attempt is gone from both the code and UUID channels.
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
assert.Equal(t, http.StatusBadRequest, status, "retired code must not claim")
pollStatus, _ := pollRegistrationStatus(t, setup, reg1["registration_id"].(string))
assert.Equal(t, http.StatusNotFound, pollStatus, "retired registration must not poll")
// The fresh code claims fine.
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
require.Equal(t, http.StatusOK, status)
assert.Equal(t, true, resp["approved"])
}
func TestPairingCancelByRegistrationUUID(t *testing.T) {
setup := setupTestServer(t)
cancelled := registerPairingDevice(t, setup, fmt.Sprintf("pairing-cancel-%s", uuid.New().String()))
kept := registerPairingDevice(t, setup, fmt.Sprintf("pairing-keep-%s", uuid.New().String()))
// Cancel only the first attempt — bearer is its own registration UUID.
body, _ := json.Marshal(map[string]string{"registration_id": cancelled["registration_id"].(string)})
req := httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "cancel should succeed")
// Cancelled: code dead, status poll gone.
code1 := cancelled["pairing_code"].(string)
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code1)
assert.Equal(t, http.StatusBadRequest, status, "cancelled code must not claim")
pollStatus, _ := pollRegistrationStatus(t, setup, cancelled["registration_id"].(string))
assert.Equal(t, http.StatusNotFound, pollStatus, "cancelled registration must not poll")
// Idempotent: cancelling again is still a 200.
req = httptest.NewRequest("POST", "/api/devices/register/cancel", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "cancel must be idempotent")
// The other registration is untouched by its neighbour's cancel.
code2 := kept["pairing_code"].(string)
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim", setup.RegularToken, code2)
require.Equal(t, http.StatusOK, status, "cancel must scope to its own registration only")
assert.Equal(t, true, resp["approved"])
}
func TestPairingAttemptRateLimit(t *testing.T) {
setup := setupTestServer(t)
// The regular user is fresh per setup, so the limiter key
// (userID|ip) is this test's alone — the budget cannot have been
// pre-spent by sibling tests sharing the process-global limiter.
// Burn the budget with bad lookups (12/min); the 13th attempt must
// be refused before the code check.
for i := 0; i < 12; i++ {
code := fmt.Sprintf("%06d", i*7919+13)
status, _ := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, code)
require.Equal(t, http.StatusBadRequest, status, "attempt %d should fail as a plain miss", i+1)
}
status, resp := pairingLookupOrClaim(t, setup, "/api/devices/claim/lookup", setup.RegularToken, "999999")
assert.Equal(t, http.StatusTooManyRequests, status, "13th attempt must hit the budget")
assert.Contains(t, resp["error"], "too many")
}
+44 -14
View File
@@ -19,6 +19,9 @@ import (
func TestDeviceRegistrationFlow(t *testing.T) {
setup := setupTestServer(t)
// Approved through the shared admin's session; the row must not
// outlive the test (name hygiene alone should never carry it).
cleanupDeviceByIdentifier(t, setup, "kindle-test-hw-id-12345")
// Step 1: Initiate device registration
regRequest := map[string]interface{}{
@@ -208,6 +211,7 @@ func TestDeviceAuthentication(t *testing.T) {
// Create a device directly in the database
userID := getTestUserID(t, setup.DB)
cleanupDeviceByIdentifier(t, setup, "test-device-123")
deviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
_, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
@@ -237,10 +241,25 @@ func TestDeviceAuthentication(t *testing.T) {
func TestListPendingRegistrations(t *testing.T) {
setup := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/devices/pending", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
// A live registration must NOT appear in any account's pending list:
// the broadcast is what let any user claim (or reject) someone else's
// device. Pairing codes replaced it — see device_pairing_test.go.
regRequest := map[string]interface{}{
"device_name": "Broadcast Test Device",
"device_type": "koreader",
"device_identifier": fmt.Sprintf("broadcast-test-%s", uuid.New().String()),
}
regBody, _ := json.Marshal(regRequest)
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
req = httptest.NewRequest("GET", "/api/devices/pending", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations")
@@ -248,8 +267,9 @@ func TestListPendingRegistrations(t *testing.T) {
json.Unmarshal(rec.Body.Bytes(), &response)
pending, ok := response["pending_registrations"].([]interface{})
assert.True(t, ok, "Should have pending_registrations array")
assert.NotNil(t, pending, "Pending registrations should not be nil")
require.True(t, ok, "Should have pending_registrations array")
assert.Empty(t, pending, "Unclaimed registrations must be invisible to every account")
assert.Equal(t, float64(0), response["total"], "Total must stay zero while a registration is live")
}
// Regression test for the "approve returns 500 after app reinstall" bug:
@@ -263,6 +283,10 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
userID := getTestUserID(t, setup.DB)
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
// Self-delete: the row is named "Reinstall Device" — the web
// reinstall flow's own label — so the name-based sweeps can never
// safely touch it. The identifier is this test's fixture alone.
cleanupDeviceByIdentifier(t, setup, identifier)
registerAndApprove := func() string {
regRequest := map[string]interface{}{
@@ -336,6 +360,9 @@ func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
func TestApproveDeviceRegistration(t *testing.T) {
setup := setupTestServer(t)
// Approved through the shared admin's session; the row must not
// outlive the test (name hygiene alone should never carry it).
cleanupDeviceByIdentifier(t, setup, "test-approval-12345")
regRequest := map[string]interface{}{
"device_name": "Test Device for Approval",
@@ -370,13 +397,18 @@ func TestApproveDeviceRegistration(t *testing.T) {
assert.True(t, approveResponse["approved"].(bool), "Should confirm approval")
}
func TestRejectDeviceRegistration(t *testing.T) {
// TestRejectDeviceRegistrationRemoved pins the removal: the reject route is
// gone because unclaimed registrations are invisible since pairing codes
// took over — there is nothing left for anyone to reject. See
// device_pairing_test.go for the replacement flow (cancel by the
// registering device itself).
func TestRejectDeviceRegistrationRemoved(t *testing.T) {
setup := setupTestServer(t)
regRequest := map[string]interface{}{
"device_name": "Test Device for Rejection",
"device_type": "koreader",
"device_identifier": "test-rejection-12345",
"device_identifier": fmt.Sprintf("test-rejection-%s", uuid.New().String()),
}
regBody, _ := json.Marshal(regRequest)
@@ -385,25 +417,23 @@ func TestRejectDeviceRegistration(t *testing.T) {
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration")
var regResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &regResponse)
registrationID, ok := regResponse["registration_id"].(string)
assert.True(t, ok, "Should have registration_id")
require.True(t, ok, "Should have registration_id")
req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec = httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration")
var rejectResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &rejectResponse)
assert.Equal(t, "device registration rejected", rejectResponse["message"], "Should confirm rejection message")
// 404 = no route, 405 = path-shaped collision without POST — either
// proves the reject surface is gone.
assert.True(t, rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed,
"Reject route must not exist (registration invisible, nothing to reject); got %d", rec.Code)
}
func TestRegenerateDeviceToken_Success(t *testing.T) {
+9 -1
View File
@@ -438,7 +438,15 @@ func TestMetadataWriteToFile(t *testing.T) {
assert.Equal(t, "Comic Series", series)
})
// Sweep ALL generations of the fixture dirs, not just this
// process's: a killed run never reaches its RemoveAll, and the stale
// root-owned dirs then sit on the user's bind mount forever (seven
// of them were found in uploads/ in Nov 2026, one per past run —
// each surviving run's cleanup skipped them by pid).
matches, _ := filepath.Glob(filepath.Join(uploadsDir, "test-metadata-write-*"))
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
for _, dir := range matches {
_ = os.RemoveAll(dir)
}
})
}
+54 -2
View File
@@ -96,6 +96,43 @@ type TestServerSetup struct {
closed bool
}
// cleanupDeviceByIdentifier deletes any devices rows carrying the given
// identifier at test end. API-register tests need this instead of a
// token-based delete: the status poll is single-use, so a row created
// through register+approve changes tokens mid-test — the stable
// identity is UNIQUE(device_identifier). Use for every device a test
// attaches to the SHARED admin (testuser); See the DOCTRINE comment in
// setupTestServer — rows that survive their test pile up on the live
// dev account's device grid.
func cleanupDeviceByIdentifier(t *testing.T, setup *TestServerSetup, identifier string) {
t.Cleanup(func() {
_, _ = setup.DBPool.Exec(context.Background(),
"DELETE FROM devices WHERE device_identifier = $1", identifier)
})
}
// cleanupCollectionByID deletes one collections row at test end, scoped
// through the RLS bridge to its owner — collections RLS (2026-10-31)
// makes unscoped writes invisible (no-op). Use for a collection a test
// attaches to the shared admin whose name CANNOT carry the "test"
// marker the setup-time name sweep keys on (uuid-suffixed uniqueness
// against stale UNIQUE(user_id, name) rows from past runs). Rows that
// survive their test also get stranded forever — the 10 accumulated
// "Admin Collection <uuid>" rows were exactly this.
func cleanupCollectionByID(t *testing.T, setup *TestServerSetup, ownerID [16]byte, collectionID string) {
t.Cleanup(func() {
cid, err := uuid.Parse(collectionID)
if err != nil {
t.Logf("cleanupCollectionByID: unparsable id %q — %v", collectionID, err)
return
}
scope := rls.WithUser(context.Background(), pgtype.UUID{Bytes: ownerID, Valid: true})
if err := setup.DB.DeleteCollection(scope, pgtype.UUID{Bytes: cid, Valid: true}); err != nil {
t.Logf("cleanupCollectionByID: delete failed for %s — %v", collectionID, err)
}
})
}
// Close cleans up all resources in the correct order
func (s *TestServerSetup) Close() error {
s.mu.Lock()
@@ -481,6 +518,13 @@ func (s *TestDeviceSetup) CreateDevice(t *testing.T, deviceName, deviceType, dev
})
require.NoError(t, err, "Should create device")
// Devices created through this helper self-delete at test end, so
// callers owe nothing to name hygiene: setupDeviceTest attaches to
// the SHARED admin, which the harness deliberately never deletes —
// unqualified rows would pile up on the live dev account forever
// (the "OPDS Folders Device" graveyard was exactly this gap).
t.Cleanup(func() { _ = s.DB.DeleteDevice(context.Background(), device.ID) })
deviceUUID, err := uuid.FromBytes(device.ID.Bytes[0:16])
require.NoError(t, err, "Should parse device ID")
@@ -809,9 +853,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Same passes for test DEVICES (identifier/name containing "test")
// and test SAVED FILTERS (name containing "test"): both accumulated
// across runs and poisoned later runs (duplicate identifiers, 409s,
// non-empty "initially empty" assertions).
// non-empty "initially empty" assertions). The second half of the
// device predicate catches test suites that attach to the shared
// admin with surface-specific names ("koreader" from the contract
// pins, "OPDS Folders Device", "Reinstall Device") — every suite
// now also self-deletes its rows, this is the drift safety net:
// dev_contract_% auth tokens, contract-% / opds-folders-% /
// repro-reinstall-% identifiers are unambiguously harness-owned.
_, derr := dbPool.Exec(ctx,
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%' "+
"OR device_identifier ILIKE 'contract-%' OR device_identifier ILIKE 'opds-folders-%' OR device_identifier ILIKE 'repro-reinstall-%' "+
"OR auth_token LIKE 'dev_contract_%')",
adminUUID)
if derr != nil {
t.Logf("device cleanup failed: %v", derr)
+4
View File
@@ -241,6 +241,10 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
require.NoError(t, err)
collectionID := collectionResult["id"].(string)
// The uuid suffix keeps the name off every run's UNIQUE(user_id,name),
// which also keeps it out of the name-based cleanup sweep — self-delete
// instead (owner-scoped, exactly who created it).
cleanupCollectionByID(t, setup, getTestUserID(t, setup.DB), collectionID)
// Create a test book via API
bookID := createTestMediaItemID(t, setup)
+54
View File
@@ -0,0 +1,54 @@
<!-- Ground truth: the Administration section itself (docs/admin/), the app
sidebar's Administration group (templates/header.templ: Dashboard,
Libraries, Podcasts, Hash Conflicts, Users, Settings), and the
per-page ground-truth comments in this directory. This page is a
directory; the pages it links to carry their own source pins. -->
# Admin Guide
Everything an instance administrator runs lives in this section. Like the
rest of Bookhoard's docs, pages tell you what to click — but admin pages
add the layer users don't need: **what each control actually does** before
you click it, what it costs, and what it changes. (The calibration, if
you're curious: *user docs = what you see, admin docs = what it does,
developer docs = how it works*.)
## The Administration section
| Page | What it covers |
| --- | --- |
| [Dashboard](dashboard.md) | The `/admin` landing page: stats, file-watcher status, **Force Rescan All Libraries**, live scan progress |
| [Libraries](libraries.md) | Creating libraries, folders, per-user visibility, per-library scans, deletion semantics |
| [Processing Issues](library-processing-issues.md) | Per-library ledger of items the scanner couldn't process — Retry, Retry all, Dismiss |
| [Archived Items](archived-items.md) | The missing-file lifecycle: two-strikes archiving, Merge/Restore/Delete, retention |
| [Podcasts](podcasts.md) | The all-feeds maintenance view: disk usage, refresh, delete files, remove for everyone |
| [Hash Conflicts](hash-conflicts.md) | Byte-identical duplicates and their resolution (merge reading data or keep both) |
| [Users](users.md) | Accounts, roles, password resets, the last-admin guards |
| [Settings](settings.md) | Every server-wide setting: key, default, range, what it does, restart or live |
| [Scanning](scanning.md) | The deep-dive: shallow vs. force, and what every scan button actually does |
The app's own sidebar shows the first six as the **Administration** group;
Processing Issues and Archived Items hang off the Libraries page.
## If you're setting up a fresh instance
The shortest sane path, in order:
1. **[Libraries](libraries.md)** → Create Library, add its folders
2. Let the initial scan populate it (or **Force Rescan** to be sure)
3. **[Settings](settings.md)** → set the **Base URL** to the address other
devices will use, and check **Auto-Scan** is on
4. **[Users](users.md)** → create accounts (or leave public registration
on), set roles
5. Point devices at the server — the [device guides](../user/user-guide.md)
walk each one
## When something looks wrong
- Books vanished from the library? [Archived Items](archived-items.md) —
they're almost certainly there, history intact.
- A book's metadata is stale or wrong? Open the book → **Rescan**
(re-extracts that one item, sidecars included).
- Duplicates? [Hash Conflicts](hash-conflicts.md).
- A scan didn't pick up your changes? [Scanning](scanning.md) explains
which scans skip unchanged files — and which never do.
+53
View File
@@ -0,0 +1,53 @@
<!-- Ground truth: templates/admin_archived.templ (page copy, Merge/
Restore/Delete row actions, Purge All Archived, retention sentence),
internal/config/config.go (ARCHIVE_RETENTION_DAYS, default 90, 0 =
manual purge only), internal/handlers/media.go (MergeArchivedMediaItem
— reading progress/annotations/collections merge, per-user collisions
keep the active copy), templates/admin_library.templ (entry banner).
Re-verify against these when the page changes. -->
# Archived Items
Archived items are books whose files went missing from disk. Bookhoard
doesn't delete them on the first absence: a library-wide scan that can't
find a file marks the item missing, and only when the file is still absent
for **two consecutive scans** does the item move to the archive. Until
then — or after — the reader's history is preserved, so a file that comes
back (or a move the scanner detects automatically) restores the item with
its reading data intact.
You reach this page from the [Libraries](libraries.md) page, which shows
an **Archived items** banner with the count whenever anything is archived.
## What the page shows
A table of every archived item: book title and author, the file path that
went missing, its library, and a status column. When the list is empty you
get the good news instead: "Nothing is archived or missing. All clear."
## Retention
By default archived items are **permanently deleted after 90 days**
(configured with the `ARCHIVE_RETENTION_DAYS` environment variable; set it
to `0` to keep archived items until you purge them by hand). The page
header states your instance's current rule.
## Actions
| Action | What it actually does |
| --- | --- |
| **Merge** (only when a matching active book exists) | Folds this archived copy's reading data — progress, annotations, collections — into the active copy and removes the archived entry. If the same user has reading data on both copies, the active copy's data wins. This is the right choice when the duplicate is a leftover twin and the real book lives on. |
| **Restore** | Puts the item back into the libraries immediately. If the file is genuinely still gone, the next scan archives it again — useful when you know the file just came back or is about to. |
| **Delete** | Deletes the item **and its reading history** permanently. No archive, no undo. |
### Purge All Archived
The header button wipes every archived item at once — permanently, reading
history included (a confirm dialog says exactly that). It exists for the
"clean slate" moments; per-item **Delete** is the scalpel.
## Related
- [Scanning](scanning.md) — how scans decide what's missing, and why only
library-wide scans (shallow or force) archive; a single-folder scan
never marks its sibling folders' items missing.
+72
View File
@@ -0,0 +1,72 @@
<!-- Ground truth: templates/admin.templ (stats grid, File Watcher card,
Quick Actions, Scanning panel), internal/router/frontend.go (/admin
handler), internal/router/admin_library.go (getAdminStats),
web/src/admin.ts (scanAllLibraries one-job-per-library, watch status
fetch, progress rows), internal/handlers/scanner.go (watch status,
coalescing 409). Re-verify against these when the page changes. -->
# Admin Dashboard
The dashboard at `/admin` is the admin landing page: a four-number health
check, the file-watcher status, and the place where library-wide scans are
launched and watched.
## The stats grid
Four counts, straight from the database, refreshed on every page load:
| Card | What the number counts |
| --- | --- |
| **Libraries** | Every library — book, comic, audiobook, and podcast libraries alike |
| **Books** | Every item across all of them: books, comics, audiobooks, and podcast episodes |
| **Users** | Every account on the instance |
| **Devices** | Every registered sync device (Android, KOReader, Kobo) |
These are instance-wide totals — they don't filter by what any individual
user can see.
## File Watcher
The **File Watcher** card shows how many libraries are currently under
live watch for file changes. The watcher exists when **Auto-Scan** is
enabled on the Settings page; the number updates on page load.
- Watching **0** libraries usually means auto-scan is off — turn it on in
[Settings](settings.md) → Scanning, and the watcher arms on the next
server start.
- Watched libraries get shallow scans as files change, plus a periodic
poll on the scan interval — see [Scanning](scanning.md) for exactly what
those do.
## Quick Actions
### Force Rescan All Libraries
The big one. This is a **force rescan of every library**: each file is
re-read and re-extracted with the scanner's current parsing, and hash
identifiers are recomputed — nothing unchanged is skipped. Use it as the
backfill tool after mass edits or scanner improvements; don't use it as a
routine refresh (that's what the watcher and [per-library
scans](scanning.md) are for, which skip unchanged files).
Mechanically it starts **one scan job per library** and reports per
library: started, or skipped because that library already had a scan
running (only one scan per library can run at a time).
### Manage Libraries
A shortcut to [Libraries](libraries.md), where folders, access, and
per-library scans live.
## The Scanning panel
Whenever scans are running, a **Scanning Libraries** panel appears with a
live progress row per library (progress bar, current file counts, and a
per-library cancel button), plus:
- **Cancel all scans** — stops every queued or running scan job
- The close button — hides the panel; scans keep running without it
The panel appears for any scan — the dashboard's own force rescan, or one
started from the Libraries page. Progress updates live while the page is
open; the jobs themselves run on the server either way.
+56
View File
@@ -0,0 +1,56 @@
<!-- Ground truth: templates/admin_hash_conflicts.templ (cards, actions,
confirms), internal/handlers/hash_conflicts.go (ResolveHashConflict:
keep_all marks resolved; keep reparents children to the kept copy,
deletes the other catalog rows, records who resolved), internal/
services/media_scanner.go (recordHashConflictIfAny), internal/services/
hash_backfill.go (backfill-created conflicts). Re-verify against these
when the page changes. -->
# Hash Conflicts
Hash Conflicts is the duplicate-content queue: groups of catalog items
whose files are **byte-identical** (same SHA-256) but stored at more than
one path in the same library. The scanner flags these as it goes — and
the hash backfill job flags them retroactively for content that predates
hash tracking — so both fresh scans and old libraries surface their
duplicates here.
Duplicates usually mean the same book was copied into two folders, or a
library was reorganized and both the old and new copies got scanned. The
harm is quiet but real: split reading histories, double-counted library
stats, and two entries for one book.
## What a conflict looks like
One card per group: a **Duplicate content** heading, a badge with the
copy count, the library and a short fingerprint of the shared hash, and
one row per copy — title, author, full path, file size, and a usage
summary that's **highlighted when that copy holds reading data** (someone
has actually read it).
## Resolving a group
Two outcomes, both confirm-guarded:
### Keep this copy
Per-copy button. Keeps the copy you chose and folds the **other copies'
reading data into it** (their catalog rows are removed; the underlying
files on disk are never touched — this platform's deletes act on the
catalog, not your files). The row-level usage summaries exist precisely
so you can pick the copy that already carries the reading history.
### Keep both
The group-level button, for duplicates that are intentional. It records
the resolution and stops flagging this group — every copy stays in the
catalog exactly as it is, nothing merged, nothing removed.
After either choice the card is replaced with the outcome, and the group
never comes back (unless the same duplicate situation re-arises from a
*new* scan, which creates a fresh group).
## When the page is empty
"No content duplicates detected." — the queue drains from the top; groups
appear as scans and the backfill find them.
+108
View File
@@ -0,0 +1,108 @@
<!-- Ground truth: templates/admin_library.templ (page, modals, panel,
delete-library warnings), internal/router/admin_library.go (create/
edit/delete, folders, browse, visibility endpoints), internal/router/
library.go (per-library scan route), internal/services/library_service.go
(DeleteLibraryFolder removes the mapping only; SetLibraryVisibility),
templates/utils.go (hxValsFolderScan), templates/admin_archived.templ.
Re-verify against these when the page changes. -->
# Libraries
The Libraries page is where a Bookhoard instance is defined: what
libraries exist, which folders on the server they read from, who can see
them, and how they get scanned. Everything here is admin-only — regular
users just see the result.
## Creating a library
**Create Library** opens a modal with three fields:
- **Library Name** (required) — what users see in their sidebar
- **Description** (optional)
- **Library Type** (required) — book, comic, audiobook, podcast; the type
drives how the scanner parses files and how the reader presents them
A library with no folders can't scan — add at least one folder (below)
before expecting anything to appear in it.
## The library card
Each library renders as a card: name, description, a type chip, a folder
count, and two controls:
- **Force Rescan** — starts a force scan of this one library: every file
re-read and re-extracted, hashes recomputed. This is the heavy,
thorough pass ([Scanning](scanning.md) explains force vs. shallow and
when each is worth it). If a scan is already running for the library,
the click tells you instead of stacking a second one.
- **Manage** — opens the library's panel, where everything else lives.
## The library panel
### Folders
Folders are the server-side directories the scanner walks. Each one shows
its path with two icon actions: **Scan this folder** (a shallow scan
scoped to just that folder's subtree — the quick "I just dropped files
here" button) and remove-from-library (confirm-guarded).
Adding one: type a path, or click **Browse** to walk the server's
filesystem in a modal (up with `..`, into a folder by clicking it,
**Select This Folder** to fill the input), then **Add**.
Two things worth knowing:
- **Removing a folder only unregisters it.** The mapping is deleted;
files on disk and existing catalog items are untouched. If no remaining
folder covers those files, later library-wide scans will treat them as
missing — the [Archived Items](archived-items.md) lifecycle takes over,
history kept.
- **Podcast download folders are feed-managed.** The scanner refuses to
scan them by hand; the podcast feed system owns their contents.
### User Access
One checkbox per user. Checked (the default) means the library is visible
to them; unchecking hides it from their browsing, apps, and OPDS —
enforced in the database, so every listing obeys. Hiding a library never
destroys anything: the user simply can't see it.
### Processing issues
When the library has items the scanner couldn't process, a link here
shows the count and leads to its
[Processing Issues](library-processing-issues.md) page.
### Edit Details and Delete Library
**Edit Details** changes the name and description. The library type is
fixed at creation — it can't be edited later.
**Delete Library** is the destructive one, and its confirm dialog says
precisely what it does:
- **Permanently removed, no archive or undo:** the library, its folder
mappings, and all book records — with their reading progress,
highlights, notes, bookmarks, and ratings.
- **Not touched:** media files on disk. Delete the library, keep the
files; re-create the library later and scan them fresh (with empty
reading history).
## The archived banner
When any library has archived (missing-file) items, this page shows a
banner with the total count and two shortcuts: **View Archived** (the
[Archived Items](archived-items.md) page) and **Purge Archived Now** (the
same permanent wipe the archived page's Purge All Archived performs).
## Every scan entry point, at a glance
| Where | Control | Kind |
| --- | --- | --- |
| This page, library card | **Force Rescan** | Force, this library |
| This page, folder row | **Scan this folder** | Shallow, that folder |
| Admin dashboard | **Force Rescan All Libraries** | Force, everything |
| Settings → Scanning | **Auto-Scan** | Shallow, automatic |
| A book's page | **Rescan** | Force, that one item |
Full semantics for each: [Scanning](scanning.md).
+62
View File
@@ -0,0 +1,62 @@
<!-- Ground truth: templates/admin_processing_issues.templ (stats cards,
Retry all queue, per-issue Retry/Dismiss, severity badges),
internal/handlers/processing_issues.go (RetryProcessingIssue — success
closes the issue, failure returns the real error; resolve path),
internal/router/library.go (issue routes), the deliberately-not-a-
library-rescan design comment in the template. Re-verify against these
when the page changes. -->
# Processing Issues
Processing Issues is each library's ledger of items the scanner could not
fully process — a corrupt file, an unsupported format, metadata that
wouldn't parse. It is per-library: you reach it from that library's panel
on the [Libraries](libraries.md) page (the link only appears when the
library has issues).
## What's on the page
Three count cards — **Errors**, **Warnings**, **Info** — show how many
open issues sit at each severity (a card only appears when that severity
has issues). Below them, one card per issue showing:
- the item's **title** and a plain-language **description** of what went
wrong
- **Type**, **Format**, **File** (full path), and **Library** type
- a severity badge: red for errors, amber for warnings, neutral for info
## Fixing an issue
### Retry
Runs that one item back through the scanner. This is a per-item scan —
the item's file is re-read and re-extracted exactly like a book-page
[Rescan](scanning.md), not a pass over the library. If it succeeds, the
issue closes itself; if it fails again, the page shows the *real* error,
so you know why it still fails instead of staring at a stale complaint.
### Retry all
Queues every issue currently on the page and works through them **one at
a time** — the button shows progress as "done / total" while the queue
drains. This is deliberately not a whole-library scan: fixing a handful of
bad files never re-reads the good ones. Per-issue **Retry** buttons are
disabled while the queue is running.
When the queue finishes, the page refreshes itself from the server —
closed issues disappear, still-failing ones stay with fresh error text.
### Dismiss
Closes an issue without re-processing — for warnings and info items you've
looked at and judged fine (a confirm dialog guards the click). **Errors
cannot be dismissed**: they must be fixed and successfully retried. The
Dismiss button simply doesn't exist on an error card.
## How issues get here and leave
The scanner creates an issue whenever it fails to process an item during a
scan. Issues leave the page three ways: a successful **Retry** (or any
later scan that processes the item cleanly), a **Dismiss** you chose for a
warning or info item, or the file's problem being fixed on disk and the
next scan handling the item cleanly.
+51
View File
@@ -0,0 +1,51 @@
<!-- Ground truth: templates/admin_podcasts.templ (table, actions, orphaned
badge, header totals, design comment: sorted by disk usage), internal/
router/frontend.go (/admin/podcasts handler), podcast polling/download
settings in internal/database/settings_registry.go (SettingsDefaults,
Podcasts group). Re-verify against these when the page changes. -->
# All Podcasts
This is the maintenance view for the podcast subsystem: **every feed from
every user**, sorted by disk usage, so the biggest disk hogs surface
first. (The listening side — subscribing, playing, downloading episodes —
lives in the [user podcast guide](../user/podcasts.md); this page is
about running the thing.)
## The header totals
Above the table: how many feeds exist, how many episodes are downloaded
versus total, and how much disk the downloads occupy instance-wide.
## The table
One row per feed:
| Column | Meaning |
| --- | --- |
| **Feed** | Title (links to its podcast page) and host |
| **Library** | Which library holds it |
| **Subscribers** | How many users subscribe (hover for names). **orphaned** means nobody does — usually a departed user's feed |
| **Downloaded** | Episodes on disk / episodes the feed has published |
| **Disk** | Space the downloaded files occupy |
| **Last fetch** | When the feed was last checked for new episodes |
## Actions, per feed
- **Refresh** — checks the feed for new episodes right now, instead of
waiting for the automatic poll (its interval lives in Settings →
Podcasts).
- **Delete files** (only when episodes are downloaded) — frees the disk
and resets those episodes to *pending*. They re-download on demand when
someone plays them. The confirm says the important part: **a feed
refresh will not re-materialize them** — deleting files is safe and
reversible by playback, not by refresh.
- **Remove** — the admin delete: removes the feed, its episode rows, its
downloaded files, and everyone's subscriptions. This is instance-wide
(the confirm says so); a user removing their own subscription from the
listening side only affects that user.
## When the page is empty
"No podcast feeds exist yet." — feeds appear when users add them (or when
you do, from the podcast section of the app).
+89
View File
@@ -0,0 +1,89 @@
<!-- Ground truth: internal/handlers/scanner.go (ScanLibrary, StartScanner,
per-folder validation, coalescing), internal/services/media_scanner.go
(forceRescan semantics, steady-skip path, watcher/poll/initial
triggers, enqueueLibraryScan force:false), internal/services/worker.go
(SetForce), templates/admin.templ + web/src/admin.ts (dashboard
button, one job per library), templates/admin_library.templ +
templates/utils.go (per-library Scan force:true, per-folder scan),
templates/admin_settings.templ (Auto-Scan, Scan Interval).
Re-verify against these when scanning behavior changes. -->
# Scanning
Scanning is how Bookhoard's server reads your library folders and keeps the
catalog in step with the files on disk. This page covers every scan an
admin can trigger, what each one actually does, and what it costs — so you
can pick the right one before clicking.
## The one distinction: shallow vs. force
Every scan is one of two kinds:
- **Shallow** (the default). The scanner walks the folder and skips any
file whose size and modification time are unchanged since it was last
seen. New and changed files get processed; untouched files cost nothing.
Fast, and the right choice for day-to-day keeping-up.
- **Force**. Nothing is skipped. Every file is re-read and re-extracted
with the scanner's current parsing, and each item's hash identifiers
(the SHA-256 fingerprints used for duplicate detection and device book
matching) are recomputed. This is the backfill tool: run it after
scanner improvements you want applied to already-scanned books, after
mass edits in Calibre or on disk, or when metadata looks stale anywhere
in a library. It is deliberately slow on big libraries.
## Every scan control, and what it does
| Control | Where | Kind | Scope |
| --- | --- | --- | --- |
| **Force Rescan All Libraries** | Admin dashboard → Quick Actions | Force | Every library |
| **Force Rescan** (library row) | Admin → Libraries | Force | That library |
| **Scan this folder** (folder-row icon) | Admin → Libraries → a library's panel | Shallow | That folder's subtree |
| **Auto-Scan** + **Scan Interval (seconds)** | Admin → Settings → Scanning | Shallow | Every library |
| **Retry** / **Retry all** | Admin → Libraries → Processing Issues | Per-item | Failed items only |
| **Rescan** / **Reset to Scanned** | A book's detail page | Per-item | That one item |
The details behind the table:
- **Force Rescan All Libraries** loops over every library and starts a
force scan job for each. Libraries that already have a scan running are
left alone — the button reports which libraries started and which were
skipped because a scan was already in flight.
- **Force Rescan** on a library row is the same force rescan, scoped to
that one library. It shares the dashboard button's semantics exactly.
- **Scan this folder** is the only folder-sized scan, and the only shallow
one an admin triggers by hand: unchanged files are skipped, and only
that folder's subtree is walked. Because a partial scan can't vouch for
what it didn't look at, it never marks items from the library's *other*
folders as missing. Podcast download folders refuse this button — they
are managed by the feed system, not the scanner.
- **Auto-Scan** arms the two automatic triggers, both shallow: a
filesystem watcher that fires as files change, and a periodic poll that
runs every **Scan Interval (seconds)** (default 60, allowed 1–3600).
The same shallow pass also runs once at startup when auto-scan is on.
- **Retry** and **Retry all** on a library's Processing Issues page send
failed items back through the scanner one at a time — deliberately not
a library-wide scan, so fixing a few bad files never re-reads the whole
library.
- **Rescan** on a book's page re-extracts that item from its file and
recomputes its hashes. **Reset to Scanned** clears your manual metadata
edits first, so what lands is purely what the scanner derives from the
file.
## Rules worth knowing
- **One scan per library at a time.** Starting a second scan for a library
that already has one pending or running is refused — the dashboard
tells you a scan is active rather than stacking a duplicate. The
automatic triggers skip silently instead.
- **Scans run in the background.** The dashboard's Scanning panel shows
per-library progress while jobs run; **Cancel all scans** stops the ones
still queued or running, and you can keep working meanwhile.
- **Missing files archive after two consecutive scans.** When a
library-wide scan (shallow or force) can't find a file twice in a row,
the item is archived — reading history is kept in case the file comes
back. The Archived Items page in this section manages the results.
- **Metadata comes from the files themselves.** The scanner reads sidecar
files (Calibre's `metadata.opf` in particular), embedded tags, and
filenames — [Calibre Integration](../user/calibre-integration.md) covers
the sidecar story. What a scan extracts is exactly what a force rescan
re-extracts.
+234
View File
@@ -0,0 +1,234 @@
<!-- Ground truth: templates/admin_settings.templ (page structure, save
buttons, live/restart buckets), internal/database/settings_registry.go
(SettingDefaults: keys, types, defaults, min/max, restart flags,
groups — the source of truth), internal/handlers/system_settings.go
(ApplySetting validates type+bounds then reloads; UpdateScanSettings
validates 1-3600), internal/router/frontend.go (settings page handler,
dedicatedUI exclusion list). Re-verify against these when settings
change — a new key lands in SettingDefaults AND schema.sql AND here. -->
# System Settings
The Settings page is every server-wide knob in one place. Each setting
below lists its **key** (the name shown on the page and stored in the
database), its **default**, its **valid range**, and whether it applies
live or needs a restart.
## How saving works
The page has three separate save paths, each with its own button:
- **Save Settings** (top card) — commits the Base URL and Default
Timezone together.
- **Save Scan Settings** — commits the two scanning controls.
- A per-row **Save** for each tunable setting — you change one value and
save exactly that one.
Live tunables apply immediately. The restart bucket saves your value
right away but only takes effect the next time the server restarts — the
page marks that bucket in amber so it can't be missed. Invalid values are
refused with an error; nothing silently clamps.
---
## Base URL
**base_url** — string, no default. The public URL of your Bookhoard
instance (e.g. `https://books.example.com`). This is the address Bookhoard
hands out to devices and OPDS clients — device sync, OPDS, and API
endpoint references are built from it — so it must be the URL your other
machines can actually reach, not `localhost`. The **URL Paths** card
underneath shows the three addresses derived from it (`/opds`, `/api`,
`/api/sync`) as a sanity check.
## System Defaults
**default_timezone** — string, default `UTC`. The timezone used for any
user who hasn't picked their own (each user's profile can override it).
This governs how the server renders dates and times for that user —
history, stats day boundaries, timestamps. The dropdown offers a fixed
list of zones.
## Scanning
Two of these live in their own card with **Save Scan Settings**; the last
two appear among the Live tunables under a "Scanning" heading. All four
are documented together here.
**auto_scan_enabled** — bool, default on. **Auto-Scan** in the UI. The
system-wide switch for automatic scanning: when on, a filesystem watcher
fires scans as files change, a periodic poll runs on the interval below,
and a shallow scan runs at startup. When off, libraries only change when
you scan them by hand. See [Scanning](scanning.md) for shallow vs. force.
**scan_poll_interval_seconds** — int, default `60`, range 1–3600.
**Scan Interval (seconds)** in the UI. How often the automatic poll
re-checks every library for changes. Lower is more responsive, higher is
less background work; every tick is a shallow scan, so unchanged files
cost almost nothing.
**auto_playlist_collections** — bool, default on. Live tunable. When a
book's sidecar metadata marks its series as a *reading order*, Bookhoard
automatically creates a collection that gathers the series in reading
order. Turn it off to stop creating these during future scans (existing
collections stay).
**scan_extractor_version** — int, default `1`, range 1–1000000. Live
tunable. The parser version stamped on each book's scan manifest. This is
the gradual backfill lever: raise it after a scanner improvement, and
books scanned under the older parser get re-extracted a bounded batch per
scan until all are caught up — no manual force rescan required. Leave it
alone unless you know a parser change shipped; don't set it far ahead of
the real version.
## Session
**session_duration_seconds** — int, default `604800` (7 days), range 300
(5 minutes)–31536000 (1 year). How long a login session stays valid.
Shorter is safer for shared machines; longer means fewer re-logins on
personal devices.
## Password Quality
Five settings that together define every new password — set at
registration, created by an admin, or changed by a user. Existing
passwords are never retroactively invalidated; the rules apply when a
password is next set.
**password_min_length** — int, default `8`, range 1–128. Minimum
character count.
**password_require_upper** — bool, default on. At least one uppercase
letter (A-Z).
**password_require_lower** — bool, default on. At least one lowercase
letter (a-z).
**password_require_number** — bool, default on. At least one digit (0-9).
**password_require_special** — bool, default on. At least one special
character.
The Users page's create form and the change-password form build their
live requirement checklists from these exact values.
## Public Registration
**registration_enabled** — bool, default on. Whether visitors can create
their own accounts from the sign-up page. Off, the instance is
invite-by-admin: accounts are created only from the
[Users](users.md) page (admins can always create accounts).
## OPDS Catalog
Page-size controls for the OPDS catalog (what apps like KOReader use to
browse your library over the network).
**opds_default_page_size** — int, default `50`, range 1–500. How many
entries a catalog page contains when the client doesn't ask for a
specific size.
**opds_max_page_size** — int, default `200`, range 1–1000. The ceiling
on what a client may request — a protection against one app asking for
enormous pages.
## Device Rate Limits
Per-device request ceilings, per minute, by kind of request. These
protect the server from a misbehaving or runaway device; a device that
hits its limit is throttled until the minute rolls over. Defaults are
sized for normal reading — raise them for very large libraries whose
initial device sync needs more headroom.
**device_rate_sync_per_min** — int, default `60`, range 1–10000. Sync
requests per minute (annotations, bookmarks, reading-state sync).
**device_rate_progress_per_min** — int, default `120`, range 1–10000.
Progress updates per minute (position saves as you read).
**device_rate_metadata_per_min** — int, default `30`, range 1–10000.
Metadata requests per minute.
## Annotation Retention
**annotation_tombstone_ttl_days** — int, default `30`, range 1–3650.
When an annotation (highlight, note, bookmark) is deleted, it isn't
destroyed instantly — a tombstone records the deletion so every other
device learns about it on their next sync. After this many days the
tombstone is purged for good. Longer is safer if some of your devices
stay offline for weeks; shorter reclaims rows sooner.
## Conversion Cache
**conversion_cache_ttl_hours** — int, default `24`, range 1–720. How
long converted files (Kobo-optimized KEPUB, for example) are kept cached
after conversion. Longer saves repeat conversion work at the cost of
disk; the conversion is regenerated when the cache entry expires.
## Auth Rate Limiting
*Restart required.*
**auth_rate_limit_per_min** — int, default `10`, range 1–10000. Global
cap on authentication requests per minute — the brute-force damper on
the login endpoints, server-wide. Raise it only if legitimate logins are
being throttled (many users behind one NAT, for example).
## Login Lockout
*Restart required.* Two settings that work as a pair: after
**login_max_attempts** consecutive failed attempts, that account waits
**login_lockout_minutes** before accepting more tries.
**login_max_attempts** — int, default `5`, range 1–100.
**login_lockout_minutes** — int, default `15`, range 1–10080 (up to one
week).
## Sync Queue
*Restart required.* Device-originated sync changes flow through a
background queue instead of being written the instant they arrive.
**sync_queue_interval_seconds** — int, default `5`, range 1–3600. How
often the queue flushes.
**sync_queue_batch_size** — int, default `50`, range 1–10000. The
maximum number of queued items processed per flush.
## Worker Pool
*Restart required.* Background jobs — scans, sync-queue flushes, and
other async work — run on a fixed pool of workers.
**worker_pool_size** — int, default `3`, range 1–100. How many jobs run
concurrently. More workers chew through queued work faster at the cost
of CPU and database load; on a small home server, more is not better.
**worker_queue_cap** — int, default `100`, range 1–10000. How many jobs
may wait in line when every worker is busy. Work offered beyond the cap
is refused rather than queued — a large force rescan of many libraries
wants headroom here.
## Podcasts
How often feeds are checked and how downloads behave server-wide.
**podcast_poll_interval_seconds** — int, default `3600` (one hour),
range 300 (5 minutes)–604800 (one week). How often Bookhoard checks
feeds for new episodes. Feeds marked *complete* (podcasts that have
ended) back off instead of being checked hourly forever.
**podcast_download_concurrency** — int, default `4`, range 1–16. Episode
downloads in flight server-wide; fair-share scheduling spreads them
across feeds so one feed can't hog the slots.
**podcast_download_speed_mbps** — float, default `0` (unlimited), range
0–10000. A shared bandwidth cap, in megabits per second, across all
podcast downloads — useful when the server's connection is also serving
readers.
## About
The version of Bookhoard this server is running. Worth checking before
filing a problem — and after an update, to confirm the new build landed.
+65
View File
@@ -0,0 +1,65 @@
<!-- Ground truth: templates/admin_users.templ (create form, user cards,
last-admin guards), templates/profile_form.templ + profile_modal.templ
(admin edit fields), internal/router/auth.go (admin user endpoints),
internal/router/frontend.go (profile-modal route), user delete cascade
(reading data is owned by the account and goes with it). Re-verify
against these when the page changes. -->
# Users
User Management is where accounts are created, roles are assigned, and
accounts are removed. Changes here are immediate — there is no approval
flow.
## Creating an account
**+ Create User** opens the creation form (it stays hidden until clicked,
so the page doesn't shout account creation at you — and so admins can
still create accounts even when public self-registration is switched off
in [Settings](settings.md) → Public Registration).
Fields: **Username** (3–50 characters), **Email**, **Password** plus a
confirm, optional **First name** / **Last name**, and a **Role** of User
or Admin. The **Password Requirements** list is generated from the
password rules currently configured in Settings — the same rules users
face when changing their own passwords — and the **Create User** button
stays disabled until everything validates.
## The user cards
Every account renders as a card: username (with a **You** badge on your
own), email, the creation date shown in your timezone, and the controls:
### Role
A dropdown — **User** or **Admin** — that applies the moment you change
it. One guard exists: the **last admin cannot be demoted** (the dropdown
disables itself with that explanation while only one admin exists).
Admins see and use the entire Administration section — libraries,
scans, settings, this page.
### Edit
Opens an **Edit User Profile** modal with the same fields the user
controls on their own profile — username, email, first/last name,
**Theme**, **Default Playback Speed**, **Resume Rewind** — plus the Role
dropdown, and a **Change Password** section. The admin version doesn't
ask for the user's current password (only the user's own profile does).
### Delete
Removes the account permanently — and with it everything the account
owns: devices, sessions, and reading data (progress, annotations,
collections). The confirm dialog makes the irreversibility explicit.
Same last-admin guard as the role dropdown: the final admin cannot be
deleted.
## Roles, briefly
- **User** — browses, reads, listens, syncs devices; sees only the
libraries visible to them (visibility is set per-library on the
[Libraries](libraries.md) page).
- **Admin** — everything a user has, plus the Administration section:
libraries and scanning, users, system settings, hash conflicts,
podcasts maintenance.
+30
View File
@@ -737,3 +737,33 @@ you see, chained back-to-back within the live briefing set.
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.
## 2026-11-02 — pairing codes replace the registration broadcast
- `POST /api/devices/register` gained one ADDITIVE response key:
`pairing_code` (string, 6 digits) — the claim credential the
registering device displays. Existing keys (`registration_id`,
`auth_url`, `qr_code`, `expires_in`, `poll_interval`,
`setup_instructions`) unchanged, so the Android app's DTO is
untouched. Request parse set unchanged.
- The four pinned KOReader sync surfaces (progress push/pull, library,
bookmarks push) and the Kobo surfaces are byte-identical — pairing
codes only touch the registration/approval path, never sync.
- `GET /api/devices/pending` keeps its response SHAPE but is
deprecated-by-emptiness: unclaimed registrations return to no
account (the broadcast was the first-approve-wins hole). The keys
`registrations`, `pending_registrations`, `total` still emit.
- New unpinned surfaces (no prior shape to freeze):
`POST /api/devices/claim` (pairing-code → bind to caller's account;
HTMX callers get `HX-Refresh` instead of JSON),
`POST /api/devices/claim/lookup` (confirm-card data, no binding),
`POST /api/devices/register/cancel` (the registering device ends its
own attempt; registration UUID bearer).
- `POST /api/devices/reject/:registration_id` REMOVED — the broadcast
list was its only consumer; unclaimed registrations are invisible by
design, so there is nothing left for anyone to reject. Removal
recorded here per rule 4 (user-approved plan; the route was the
theft vector's cleanup half).
- `GET /api/devices/approve/:registration_id` (Android self-approval)
keeps its response shape; the registration UUID is now only ever
known to the device that created it, since no list publishes it.
+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
+12 -3
View File
@@ -287,6 +287,15 @@ Test-created data rules (harness cleanup depends on them): users use
`@tests.bookhoard.internal` / `@example.com` emails, libraries and
collections carry "test"-prefixed names, and cleanups delete test
libraries (cascades handle the rest) rather than table-wide deletes.
Devices attach to the per-run regular user when it doesn't matter, or
self-delete via `TestDeviceSetup.CreateDevice` / `createContractDevice`
/ `cleanupDeviceByIdentifier` when they attach to the shared admin —
names lacking "test" (device-type names like "koreader", the
"Reinstall Device" label) escaped the name sweeps for weeks and piled
476 rows onto the live dev account; every suite now cleans its own
rows at test end, with the harness sweep's identifier/token patterns
(`contract-%`, `opds-folders-%`, `repro-reinstall-%`,
`dev_contract_*`) as the drift net.
### Media Fixtures (audio/books)
@@ -510,9 +519,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/)
+94 -83
View File
@@ -1,92 +1,103 @@
# Alpine.js Patterns in Bookhoard
## Book Picker Modal Pattern (SSR + HTMX + Alpine.store)
For modals that need state persistence across HTMX swaps:
### TypeScript
<!-- Ground truth: web/src/alpine.ts (the @alpinejs/csp build — this is
load-bearing for every rule below), component registrations across
web/src (Alpine.data in admin.ts, api.ts, audio.ts, book-detail.ts,
bookPicker.ts), the bookPicker store (web/src/bookPicker.ts), the
dynamic-attribute patterns in templates/header.templ
(x-data="sidebarShell('…')" whole-expression values) and
web/src/custom-section-builder.ts (data-attributes instead of inline
handlers — the CSP build refuses them). Re-verify when the frontend
stack changes. -->
# Alpine.js patterns in Bookhoard
Bookhoard's Alpine runs the **`@alpinejs/csp` build** — the CSP build, so
the app's `script-src` can stay a hash allow-list with no
`'unsafe-eval'`. That single fact dictates most of this page: inline
JavaScript in HTML is not evaluated, so everything expressive lives in
registered TypeScript components.
## Rule one: components are registered, not inlined
Every `x-data` references a component registered with `Alpine.data` in
`web/src` — the attribute value is the component name (optionally with
arguments), never an object literal:
```typescript
// web/src/admin.ts
Alpine.data("admin", () => ({ /* state + methods */ }));
```
```html
<!-- templates/admin.templ -->
<body x-data="admin" class="theme-tokyo-night">
```
Arguments work, but the **whole attribute value must be the expression**
— templ does not interpolate `{ expr }` inside quoted attribute values,
so `x-data="sidebarShell('{ col.ID }')"` would ship a literal `{ col.ID }`
to the browser:
```html
<div x-data={ "sidebarShell('" + string(mode) + "')" }>
```
Free-text arguments that could contain quotes go through
`templates.JSONArg`, never string concatenation.
## Stores: state that survives htmx swaps
Alpine's reactivity is DOM-local; when htmx swaps a node, its `x-data`
state dies with it. State that must survive swaps lives in a store —
`web/src/bookPicker.ts` is the canonical example:
```typescript
// web/src/bookPicker.ts
Alpine.store("bookPicker", {
isOpen: false,
selectedBooks: new Set<string>(),
open() {
this.isOpen = true;
this.selectedBooks.clear();
this.loadBooks();
},
close() {
this.isOpen = false;
this.selectedBooks.clear();
},
toggleBook(id: string) {
if (this.selectedBooks.has(id)) {
this.selectedBooks.delete(id);
} else {
this.selectedBooks.add(id);
}
// Trigger reactivity
this.selectedBooks = new Set(this.selectedBooks);
},
isSelected(id: string): boolean {
return this.selectedBooks.has(id);
},
get selectedCount(): number {
return this.selectedBooks.size;
}
open() { /* … */ },
toggleBook(id: string) { /* … */ },
});
Template
<!-- Open button -->
```
```html
<button @click="$store.bookPicker.open()">Add Books</button>
<!-- Modal -->
<div x-show="$store.bookPicker.isOpen"
x-transition
@click.self="$store.bookPicker.close()"
style="display: none;">
<!-- Search with HTMX -->
<input @input.debounce.300ms="searchBooks($el.value)"
hx-get="/api/media-items/filtered"
hx-target="#book-picker-grid"
hx-include="#filter-form" />
<!-- Book grid with checkboxes -->
<div id="book-picker-grid" x-init="loadBooks()">
<!-- Server returns HTML with checkboxes -->
</div>
<!-- Selected count -->
<span x-text="$store.bookPicker.selectedCount"></span>
<!-- Submit -->
<button @click="$store.bookPicker.submit()">Add Selected</button>
</div>
Key Principles
- ✅ Alpine.store persists state across HTMX DOM swaps
- ✅ Server returns HTML with checkbox :checked attributes
- ✅ No client-side rendering or manual DOM manipulation
- ✅ HTMX handles dynamic content updates
- ✅ Alpine manages UI state only
Dropdown Pattern (Local x-data)
<span x-text="$store.bookPicker.selectedBooks.size"></span>
```
The server returns HTML (checked checkboxes included); the store tracks
selection across swaps; nobody rebuilds markup from JSON.
## Local state: plain `x-data` on the smallest element
Ephemeral UI (a dropdown, a panel toggle) needs no store and no
round-trip:
```html
<div x-data="{ dropdownOpen: false }">
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
<div x-show="dropdownOpen"
@click.outside="dropdownOpen = false"
x-transition
style="display: none;">
<!-- Dropdown content -->
</div>
<div x-show="dropdownOpen" @click.outside="dropdownOpen = false">…</div>
</div>
Best Practices
- ✅ State lives in template (x-data)
- ✅ UI updates automatically (x-show)
- ✅ No manual DOM manipulation in TypeScript
- ✅ Pure business logic only in TypeScript functions
- ✅ Use Alpine.store for global state
- ✅ Use local x-data for component state
- ✅ Use x-init for setup only (no data fetch for SSR pages)
- ❌ Never use classList in TypeScript
- ❌ Never use getElementById for show/hide
- ❌ Never fetch data in x-init (for SSR pages)
```
## The banned list — all enforced by the CSP build
- **No inline handlers**, in any form: `onclick=`, `onerror=`,
templ-brace variants — hash-mode CSP refuses them unconditionally.
Rows generated in TypeScript carry `data-*` attributes and bind
listeners after swap (see `custom-section-builder.ts`).
- **No `hx-on*` attributes and no `js:`-prefixed `hx-vals`** — both
evaluate strings, which the CSP build cannot do. Plain JSON `hx-vals`
only (`templates/utils.go` has the helper).
- **No inline `<script>` outside `theme_flash_guard.templ`** (the one
sanctioned pre-paint script); everything else bundles from `web/src`.
## Division of labor
The full ownership rule lives in
[Writing UI documentation without screenshots](writing-ui-docs.md) and
the repo's AGENTS.md: **templ decides what things look like; TypeScript
decides what shows.** Content derived from server data is rendered by
templ (fetch a partial with `htmx.ajax` when it changes); TypeScript
handles pure client-local state — open/closed panels, validation,
toasts. `classList`/`getElementById` show-hide from TypeScript is a
defect, not a shortcut.
+2 -2
View File
@@ -1,6 +1,6 @@
# Android App — Design & Roadmap
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as any dedicated reader app to its server: the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
**Status**: Planning / pre-development
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
@@ -32,7 +32,7 @@ This document describes the planned native Android client for Bookhoard: a thin,
### Why native Android
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
- The quality bar is a first-class native reader app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
+5 -3
View File
@@ -144,9 +144,11 @@ See [Device Registration & Sync](devices/get_devices.md)
- GET /api/devices/:id - Get device
- PUT /api/devices/:id - Update device
- DELETE /api/devices/:id - Delete device
- GET /api/devices/pending - List pending registrations (admin)
- GET /api/devices/approve/:registration_id - Approve registration (admin)
- POST /api/devices/reject/:registration_id - Reject registration (admin)
- GET /api/devices/pending - List pending registrations (always empty — pairing codes replaced the broadcast list)
- GET /api/devices/approve/:registration_id - Approve registration by UUID (Android self-approval path)
- POST /api/devices/claim - Claim a registration by its pairing code ([Pairing codes](devices/claim_device.md))
- POST /api/devices/claim/lookup - Resolve a pairing code without binding ([Lookup](devices/lookup_pairing_code.md))
- POST /api/devices/register/cancel - The registering device cancels its own pairing attempt ([Cancel](devices/cancel_device_registration.md))
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
- GET /api/devices/:id/shelves - Get shelf contents
- DELETE /api/devices/:id/shelves - Remove from shelf
@@ -0,0 +1,42 @@
---
method: POST
path: /api/devices/register/cancel
auth: none
area: devices
---
<!-- source: internal/handlers/devices.go DeviceHandler.CancelRegistration -->
# Cancel Device Registration
Let the REGISTERING device end its own pairing attempt — the plugin calls this
when the user dismisses its waiting dialog. The `registration_id` is the
bearer credential (same trust as the status poll): a device can only ever
cancel its own registration, and already-claimed registrations are left alone
so their token delivery to the device is never disrupted. Idempotent: the
response is the same whether or not the registration still exists.
**Endpoint**: `POST /api/devices/register/cancel`
**Auth**: Not required (registration UUID bearer, like the status poll)
**Content-Type**: `application/json`
## Request Body
| Field | Type | Required | Description |
| --------------- | ------ | -------- | ------------------------------ |
| registration_id | string | Yes | UUID from the register response |
### Example Request
```json example-request
{
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
## Response (200 OK)
```json example-response
{
"message": "registration cancelled"
}
```
@@ -0,0 +1,66 @@
---
method: POST
path: /api/devices/claim
auth: jwt
area: devices
---
<!-- source: internal/handlers/devices.go DeviceHandler.ClaimDevice -->
# Claim Device (Pairing Code)
Bind a pending device registration to the CALLER's account by entering the
6-digit pairing code shown on the registering device's screen. This is the
web-side replacement for the retired broadcast-list approve: unclaimed
registrations appear in no account's pending list, and only whoever reads the
code — the person holding the device — can claim it.
**Endpoint**: `POST /api/devices/claim`
**Auth**: JWT
**Content-Type**: `application/json`
The code is normalized before matching: spaces and dashes typed around the
digits ("583 912") are stripped. Lookups and claims share a per-account rate
limit (12 attempts/minute).
## Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | -------------------------------------------- |
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
### Example Request
```json example-request
{
"pairing_code": "583912"
}
```
## Response (200 OK)
```json example-response
{
"message": "device paired successfully",
"device_name": "My Kindle Paperwhite",
"device_type": "koreader",
"approved": true
}
```
HTMX callers (the Devices page confirm button) receive `HX-Refresh` instead of
a JSON body. On success the registering device's status poll returns the fresh
`auth_token` and sync endpoints, exactly as in the old approve flow.
## Error Responses
| Code | Description |
| ---- | ------------------------------------------------------ |
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
| 409 | Pairing code already used, or the device identifier is registered to another user |
| 410 | Pairing code expired |
| 429 | Too many pairing attempts |
## Related
- `POST /api/devices/claim/lookup` — the confirm card's data, without binding
- `POST /api/devices/register` — starts the registration and mints the code
@@ -4,21 +4,19 @@ path: /api/devices/pending
auth: jwt
area: devices
---
<!-- source: internal/handlers/devices.go:703 DeviceHandler.ListPendingRegistrations -->
<!-- source: internal/handlers/devices.go DeviceHandler.ListPendingRegistrations -->
# List Pending Device Registrations:<|end|><|start|><|start|>assistant to=edit<|channel|>analysis <|constrain|>xml<|message|><rfl:parameter name="path">
/home/nymusicman/Code/bookhoard/docs/developer/api/devices/reject_device_registration.md
# List Pending Device Registrations
List all pending device registration requests.
Always empty by design. Unclaimed device registrations are invisible to every
account since pairing codes took over the approval flow: first-approve-wins on
a broadcast list let any account claim (or reject) a device it could not even
name. Instead, the registering device shows a 6-digit pairing code, and the
person holding the device types it into their own account's Devices page —
`POST /api/devices/claim`.
**Endpoint**: `GET /api/devices/pending`
**Auth**: Required (Admin only)
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ----------------------------------- |
| Authorization | string | Yes | Bearer token (must have admin role) |
**Auth**: Required (JWT)
### Example Request
@@ -31,23 +29,16 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```json example-response
{
"registrations": [
{
"id": "uuid",
"device_name": "My Kobo",
"device_type": "kobo",
"user_email": "user@example.com",
"created_at": "2026-02-08T10:00:00Z"
}
],
"total": 3,
"pending_registrations": null
"registrations": [],
"pending_registrations": [],
"total": 0
}
```
## Error Responses
Both `registrations` and `pending_registrations` key spellings are emitted for
API compatibility.
| Code | Description |
| ---- | ----------------------------------- |
| 401 | Invalid or expired token |
| 403 | User does not have admin privileges |
## Related
- [Register Device](register_device.md) — mints the pairing code
- [Claim Device](claim_device.md) — the code-driven replacement for list-and-approve
@@ -0,0 +1,51 @@
---
method: POST
path: /api/devices/claim/lookup
auth: jwt
area: devices
---
<!-- source: internal/handlers/devices.go DeviceHandler.LookupPairingCode -->
# Look Up Pairing Code
Resolve a pairing code to its pending registration WITHOUT binding it — the
data the Devices page confirm card shows ("Pair 'My Kindle Paperwhite'?").
Shares the per-account attempt budget with `POST /api/devices/claim`.
**Endpoint**: `POST /api/devices/claim/lookup`
**Auth**: JWT
**Content-Type**: `application/json`
## Request Body
| Field | Type | Required | Description |
| ------------ | ------ | -------- | -------------------------------------------- |
| pairing_code | string | Yes | The 6-digit code shown on the device's screen |
### Example Request
```json example-request
{
"pairing_code": "583912"
}
```
## Response (200 OK)
```json example-response
{
"device_name": "My Kindle Paperwhite",
"device_type": "koreader",
"device_identifier": "koreader-hostname",
"expires_in": 245
}
```
## Error Responses
| Code | Description |
| ---- | ------------------------------------------------------ |
| 400 | Invalid pairing code (generic — never confirms whether a registration exists) |
| 409 | Pairing code already used |
| 410 | Pairing code expired |
| 429 | Too many pairing attempts |
@@ -40,6 +40,7 @@ Register a new device for sync.
"registration_id": "registration-uuid",
"auth_url": "https://bookhoard.com/devices/approve/abc123",
"qr_code": "data:image/png;base64,iVBORw0KG...",
"pairing_code": "583912",
"expires_in": 300,
"poll_interval": 3,
"setup_instructions": {
@@ -48,7 +49,14 @@ Register a new device for sync.
}
```
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
`pairing_code` is the claim credential: the registering device displays it, and the
only person who can pair the device is whoever reads it (they type it into the
Devices page of THEIR account — `POST /api/devices/claim`). Unclaimed registrations
appear in no account's pending list, so there is no first-approver race.
Registrations expire after 5 minutes; re-registering the same `device_identifier`
retires the earlier attempt. Poll `POST /api/devices/register/status` at
`poll_interval` seconds until `status` is `approved`, at which point the response
includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
## Error Responses
@@ -1,50 +0,0 @@
---
method: POST
path: /api/devices/reject/:registration_id
auth: jwt
area: devices
---
<!-- source: internal/handlers/devices.go:664 DeviceHandler.RejectDevice -->
# Reject Device Registration
Reject a pending device registration request.
**Endpoint**: `POST /api/devices/reject/:registration_id`
**Auth**: Required (Admin only)
## Path Parameters
| Parameter | Type | Required | Description |
| --------------- | ------------- | -------- | ------------------------- |
| registration_id | string (UUID) | Yes | Registration request UUID |
## Request Headers
| Header | Type | Required | Description |
| ------------- | ------ | -------- | ----------------------------------- |
| Authorization | string | Yes | Bearer token (must have admin role) |
### Example Request
```http
POST /api/devices/reject/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## Response (200 OK)
```json example-response
{
"message": "device registration rejected"
}
```
## Error Responses
| Code | Description |
| ---- | ----------------------------------- |
| 401 | Invalid or expired token |
| 403 | User does not have admin privileges |
| 404 | Registration not found |
| 400 | Registration already processed |
+10 -8
View File
@@ -1,6 +1,7 @@
# WebSocket Protocol
Real-time sync events broadcast to connected clients.
Real-time sync events, delivered only to the acting user's connected
devices.
## Connect to WebSocket
@@ -24,15 +25,16 @@ All messages are JSON objects with a `type` field.
### Client → Server Messages
#### Ping (Heartbeat)
None are interpreted. The socket is a one-directional push feed — the
server logs (but ignores) anything a client sends. All reads and writes
go through the REST API.
```json
{
"type": "ping"
}
```
#### Keepalive
Keep connection alive. Server responds with `pong`.
The server sends a WebSocket **ping frame every 30 seconds**; a
connection that misses pongs for 90 seconds is removed. Standard
WebSocket clients (browsers included) answer pongs automatically —
there is nothing to implement.
### Server → Client Messages
+73 -78
View File
@@ -1,95 +1,90 @@
# Developer Documentation
Welcome to the Bookhoard developer documentation. This section contains API references, protocol specifications, and technical documentation for integrating with Bookhoard.
The developer tier of Bookhoard's docs: **how it works** — wire formats,
data access, pipelines, and the rules the codebase enforces on you.
(User docs tell you what things do; admin docs what the controls do;
this section is the machinery itself.) Everything here is written
against the source and kept honest by gates — if a doc and the code
disagree, one of them fails `make test`.
## 📚 API Documentation
## Start here: architecture, setup, testing
### Complete API Reference
**[Development Guide](../contributing/development.md)** — the working
developer's page: directory structure, backend components, database
schema, tech stack, local setup, code generation (sqlc / templ /
vite), build targets, and the full testing doctrine with its rules.
- **[API Reference](api/api-reference.md)** - Complete REST API reference
- Authentication
- User management
- Libraries
- Media items
- Reading progress
- Notes & highlights
- Analytics
- Book matching
- OPDS
- Sync protocols (KOReader, Kobo)
- WebSocket
## API Reference
### Split Endpoint Documentation
Two hundred endpoint documents, one per route, generated-adjacent and
gate-checked against the router (a route without a doc fails the build,
and so does a doc whose request examples drift from the Bruno
collection):
Browse individual endpoint documentation:
- **[API Reference overview](api/api-reference.md)** — every area, auth
model, and conventions
- Entry points: [Authentication](api/authentication/login.md),
[Users](api/users/get_profile.md),
[Libraries](api/libraries/list_libraries.md),
[Media Items](api/media-items/get_media_item.md),
[Progress](api/progress/get_media_progress.md),
[Notes](api/notes/get_notes.md) ·
[Highlights](api/highlights/get_highlights.md) ·
[Ratings](api/ratings/get_ratings.md),
[Devices](api/devices/get_devices.md),
[Collections](api/collections/list_collections.md),
[Analytics](api/analytics/get_analytics.md),
[Book Matching](api/book-matching/search_books.md),
[OPDS](api/opds/feeds.md),
[Scanner](api/scanner/overview.md),
[Admin](api/admin/list_users.md)
- The machine-readable mirror lives at `docs/developer/openapi.yaml`
(regenerated by the parity test, never hand-edited).
- **[API Documentation](api/api-reference.md)** - Split endpoint docs
- [Authentication](api/authentication/login.md) - Register, login, tokens
- [Users](api/users/get_profile.md) - Profile management
- [Libraries](api/libraries/list_libraries.md) - Library operations
- [Media Items](api/media-items/get_media_item.md) - Book/ebook management
- [Progress](api/progress/get_media_progress.md) - Reading progress tracking
- [Notes](api/notes/get_notes.md) - User notes
- [Highlights](api/highlights/get_highlights.md) - Book highlights
- [Ratings](api/ratings/get_ratings.md) - Book ratings
- [Devices](api/devices/get_devices.md) - Device management
- [Analytics](api/analytics/get_analytics.md) - Usage statistics
- [Book Matching](api/book-matching/search_books.md) - Search and link books
- [Collections](api/collections/list_collections.md) - Collection management
- [OPDS](api/opds/feeds.md) - Open Publication Distribution
- [Sync Protocols](api/sync/koreader-protocol.md) - KOReader and Kobo sync
- [WebSocket](api/websocket/protocol.md) - Real-time events
## Protocols
### Collections API
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** — progress,
metadata, conflicts; the plugin's server side
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** — native Kobo
sync (server-complete; device rollout pending — use
[KOReader](api/sync/koreader-protocol.md) on Kobo hardware today)
- **[WebSocket wire format](websocket-api.md)** — real-time events:
message catalog, keepalive, fan-out and backpressure contracts
- **[WebSocket endpoint](api/websocket/protocol.md)** — connection
parameters and endpoint-level reference
- **[Collections API](api/collections/list_collections.md)** - Complete collections API reference
- Create and manage collections
- Auto-assign rules
- Test rules
- Bulk operations
- Device shelf mappings
## Under the hood
## 🔌 Protocol Specifications
- **[Library Visibility](library-visibility.md)** — how per-user data
isolation actually works: Postgres RLS, the `app.user_id` GUC,
ScopedDB, and the rules new code must follow (the database enforces;
the app declares identity)
- **[Scanner audio metadata](scanner/audio-metadata.md)** — the sidecar
and tag extraction priority for audiobooks
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** —
how Calibre sidecar metadata becomes catalog rows
- **[Frontend Integration Notes](../FRONTEND_INTEGRATION.md)** — the
tag & contributor normalization contract (display fields vs. search
fields, and why both exist)
- **[Android App — Design & Roadmap](android-app.md)** — the native
client's design goals and platform posture
### Sync Protocols
## Standards this docs tree enforces
- **[Kobo Sync Protocol](api/sync/kobo-protocol.md)** - Kobo device sync protocol
- Kobo markup sync
- Library fetch
- Authentication
- **[Writing UI documentation without screenshots](writing-ui-docs.md)** —
the standard for every UI page in this repo
- **[UI control glossary](ui-controls.md)** — the canonical spelling of
every control the docs cite (the label gate checks it against the
templates)
- **[Contract Pins](../contract_pins.md)** — the API contract changelog;
loosening a pinned assertion requires an entry there
- **[KOReader Sync Protocol](api/sync/koreader-protocol.md)** - KOReader sync protocol
- Progress sync
- Metadata fetch
- Conflict handling
### WebSocket Protocol
- **[WebSocket API](websocket-api.md)** - Real-time sync and events
- Connection flow
- Message format
- Event types
- Authentication
## 🛠 Implementation Guides
### Library Visibility
- **[Library Visibility](library-visibility.md)** - Per-user library
visibility: the family-lens rule. New media endpoints MUST thread the
visible-library set (helpers, SQL pattern, series per-library).
### Calibre Integration
- **[Calibre OPF Implementation](../development/calibre-opf-implementation.md)** - Technical implementation details
- Architecture and design
- Data structures and functions
- Database schema mapping
- Testing strategy
- Error handling
- Performance considerations
The gates behind all of it: routes ↔ docs coverage, docs ↔ Bruno
parity, relative-link resolution, UI-label existence, and the RLS
invariant test. Adding a route without a doc — or renaming a button the
docs quote — fails the suite, by design.
---
**Looking for user documentation?** See the [User Portal](../user/user-guide.md)
**Need to deploy?** See the [Operations Portal](../operations/operations.md)
**Looking for user documentation?** See the [User Portal](../user/user-guide.md).
**Need to deploy or operate?** See the [Operations Portal](../operations/operations.md).
+75 -41
View File
@@ -1,54 +1,88 @@
# Library Visibility (family-lens rule)
<!-- Ground truth: database/schema/schema.sql (RLS policy block: p_media_items_*, p_podcast_feeds_*, app_can_read_collection / app_can_write_collection — labeled, idempotent, re-run every boot), internal/database/scoped.go (ScopedDB: per-statement app.user_id GUC, seed fallback = test-harness only), internal/rls (identity helpers), the three identity bridges (router JWT + cookie/device, middleware/device_auth.go), TestRlsInvariant (internal docs gate suite / API black-box). Re-verify against these when the data-access model changes. -->
Every endpoint that returns media metadata or content must respect
per-user **library visibility**: the `library_visibility` table maps
`(user_id, library_id, is_visible)` and defaults to **ALLOW** — every
user sees every library unless an admin explicitly hides one from them.
The point of the system is the family case: an admin hides the adult
library from a child's account, and no trace of that library (titles,
covers, series, page images) may reach the child's account.
# Library Visibility — how per-user data isolation actually works
## The rule for new endpoints
Every user sees every library **unless an admin hides it from them**
(`library_visibility` table, default **ALLOW** — the family-lens case:
hide the adult library from a child's account, and no trace of it —
titles, covers, series, page images — may reach that account).
Any endpoint that lists, filters, or details media/series/books:
The one thing to understand before writing any code that touches media:
1. Filter by the requesting user's visible-library set.
2. Reject an explicit `library_id` outside that set with **403** —
never silently ignore or fall back.
3. Content endpoints (file bytes, page images) gate per item, like
`ReaderHandler.ensureItemVisible`.
> **Enforcement lives in the DATABASE, as Postgres row-level security.
> The application declares *who* is asking; it never computes *what*
> they may see.**
If you forget, hidden libraries leak. This exact leak shipped: the
series endpoints were name-only across ALL libraries and the PDF
thumbnail endpoint served page images of hidden books (fixed 2026-10-13,
bookhoard `431fec0`).
## The engine
## Helpers — do not hand-roll
`database/schema/schema.sql` carries a labeled RLS block (re-run
idempotently on every boot). Governed tables — `media_items`,
`podcast_feeds`, `collections`, `collection_items` — have policies that
filter every row against the `library_visibility` mapping using the
`app.user_id` GUC: a per-transaction `SET LOCAL` declared by
`ScopedDB` (`internal/database/scoped.go`), which wraps the pool so
**every sqlc statement** runs inside a transaction that declares the
acting user.
- `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — returns a membership map plus
the id slice (map for param checks, slice for SQL `ANY()`).
- `router.visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (router/helpers.go) only honors a requested
`library_id` that is inside the caller's visible set.
- SQL pattern (see `ListMediaItems`, `GetDistinctSeries`,
`SearchMediaItems`):
- A statement whose context carries an identity sees only that user's
visible rows.
- A statement with **no identity raises** on a governed table — the
engine fails loud instead of leaking. Ungoverned tables (auth lookups,
settings, sessions) are unaffected and keep working.
- Collections use predicate functions (`app_can_read_collection`,
`app_can_write_collection`) that are PURE over their arguments — reads
are owner-or-admin, writes are owner (admin escape for SYSTEM
collections only). Future sharing = one EXISTS branch inside the
predicate, no policy migration.
```sql
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL
OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
```
## The rules for new code
## Known-safe surfaces
1. **New media-listing queries need NO visibility clause and NO
plumbing.** If the statement goes through `ScopedDB` with a declared
identity, the policy already filters it.
2. **The app declares identity; it never computes visibility.** Three
bridges put the identity on the request context: the JWT middleware,
the cookie/device middleware, and the KOReader/Kobo device auth
middleware. Everything downstream just carries the context.
3. **A media read on a detached `context.Background()` RAISES.** If you
need detached work (background jobs, websocket fan-in), thread the
acting identity explicitly — `rls.WithUser(ctx, userID)` — the way
scan jobs carry the library owner (first-admin fallback) and the
websocket `initial_state` carries the connecting user.
4. **NEVER**: session-scoped GUCs (pooled-connection identity bleed), a
bypass flag, or connecting the app as a superuser (superusers bypass
RLS unconditionally — the app runs as `bookhoard_srv`, and the
`db-init` compose service provisions that role at startup).
5. **Schema changes go in `schema.sql` in place**, idempotent
(`DROP .. IF EXISTS` + `CREATE`) — it re-runs on every boot. The
migration transaction declares a synthetic identity because the
schema's own dedup pass reads `media_items`.
- OPDS device catalogs filter by the device owner's visible libraries.
- Search (`SearchMediaItems`) is visibility-scoped.
- File download, reader page render, and page content gate per item.
## How this gets audited
- **`TestRlsInvariant`** — API black-box: two users, one hidden library,
every media-returning surface checked. New media-returning surfaces
must be added to it.
- **The Playwright sweep** (`tests/ui/`) — user-visible pages checked
against the coverage list.
- **`docs/contract_pins.md`** — contract-level pins (a loosening requires
a changelog entry there).
## Why the rule is written in blood
The pre-RLS app-side approach shipped a real leak (2026-10-13,
`431fec0`): series endpoints were name-only across ALL libraries and the
PDF thumbnail endpoint served page images of hidden books — every
hand-rolled filter had to be remembered at every call site, and one
forgotten clause leaked. On 2026-10-29 enforcement moved into the
database, and the invariant test went red → green to prove the old
helpers were no longer needed. If you find yourself writing a
`visible_library_ids` filter by hand, you are re-creating the bug.
## Series are per-library
Series are grouped by `(library_id, series)` — the same series name in
two libraries is two entries (manga adaptations, LN splits). Do not
re-merge them cross-library. `/api/series/books` REQUIRES a visible
`library_id`; `/api/series` without one aggregates only the caller's
visible libraries.
Series group by `(library_id, series)` — the same series name in two
libraries is two entries (manga adaptations, LN splits). Do not re-merge
them cross-library. `/api/series/books` requires a visible `library_id`;
`/api/series` without one aggregates only the caller's visible libraries
(the policy makes "visible" automatic).
+49 -11
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Bookhoard API
version: '2026.10.27'
version: '2026.11.02'
description: |
Generated from the canonical API docs (docs/developer/api/**) by
internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).
@@ -395,7 +395,7 @@ paths:
}
responses:
'200':
description: OK
description: Success
"/api/conflicts":
get:
tags: [conflicts]
@@ -557,6 +557,38 @@ paths:
responses:
'200':
description: OK
"/api/devices/claim":
post:
tags: [devices]
summary: "Claim Device (Pairing Code)"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"pairing_code": "583912"
}
responses:
'200':
description: OK
"/api/devices/claim/lookup":
post:
tags: [devices]
summary: "Look Up Pairing Code"
security:
- bearerAuth: []
requestBody:
content:
application/json:
example: |-
{
"pairing_code": "583912"
}
responses:
'200':
description: OK
"/api/devices/pending":
get:
tags: [devices]
@@ -583,6 +615,21 @@ paths:
responses:
'201':
description: Created
"/api/devices/register/cancel":
post:
tags: [devices]
summary: "Cancel Device Registration"
security: []
requestBody:
content:
application/json:
example: |-
{
"registration_id": "550e8400-e29b-41d4-a716-446655440000"
}
responses:
'200':
description: OK
"/api/devices/register/status":
post:
tags: [devices]
@@ -598,15 +645,6 @@ paths:
responses:
'200':
description: OK
"/api/devices/reject/{registration_id}":
post:
tags: [devices]
summary: "Reject Device Registration"
security:
- bearerAuth: []
responses:
'200':
description: OK
"/api/devices/{deviceId}/sync/link-book":
post:
tags: [book-matching]
-115
View File
@@ -1,115 +0,0 @@
# Testing
This document describes how to run and write tests for Bookhoard.
## Test Users and Email Domains
### Test Email Domain
All integration tests use the email domain **`@tests.bookhoard.internal`** for test users. This domain is reserved exclusively for testing and will never be used in production.
**Why this domain?**
- Clear indication it's for testing only
- Won't conflict with real user emails (which use real domains like `@gmail.com`, `@example.com`, etc.)
- Safe cleanup: Tests can delete all users with this domain without risk to production data
- If a real user somehow uses `@tests.bookhoard.internal`, they are explicitly opting into test data deletion
### Standard Test Users
The test suite creates these standard users:
1. **Admin User**
- Email: `testuser@tests.bookhoard.internal`
- Username: `testuser`
- Password: `Test@Pass123!`
- Role: `admin`
2. **Regular User**
- Email: `testregularuser@tests.bookhoard.internal`
- Username: `testregularuser`
- Password: `Test@Pass123!`
- Role: `user`
### Test Lifecycle and Cleanup
Each test run:
1. **Deletes** all existing users with `@tests.bookhoard.internal` email
2. **Creates** fresh standard test users
3. **Runs** the test with clean state
4. **Cleans up** via `t.Cleanup()` to delete users created during the test
This ensures complete test isolation - no state leaks between tests.
### Writing Tests
When writing new tests:
**✅ DO:**
- Use the standard test users from `setupTestServer(t)`
- Use `createRegularUserOnce(t, setup.DB)` for additional test users
- Let the test framework handle cleanup
- Use the `@tests.bookhoard.internal` domain if creating custom test users
**❌ DON'T:**
- Use real email domains like `@example.com` or `@gmail.com`
- Manually manage test user deletion (unless absolutely necessary)
- Assume test users persist between test runs
- Hardcode user IDs (always look up by email/username)
## Running Tests
### Run All Tests
```bash
make test
```
### Run Integration Tests Only
```bash
make test-integration
```
### Run Specific Test
```bash
go test -v -run TestUpdateProfile ./cmd/server/tests/
```
### Run with Race Detection
```bash
go test -race ./cmd/server/tests/
```
## Test Organization
Tests are located in `cmd/server/tests/`:
- `test_helpers.go` - Test utilities and setup functions
- `*_test.go` - Test files organized by feature
- `user_test.go` - User management tests
- `auth_test.go` - Authentication tests
- `media_test.go` - Media item tests
- etc.
### Key Test Helpers
- `setupTestServer(t *testing.T)` - Creates test server with clean DB state
- `createTestUserOnce(t, db)` - Gets/creates standard admin user
- `createRegularUserOnce(t, db)` - Gets/creates additional test users
- `loginWithCredentials(t, server, email, password)` - Returns auth token
## Bruno API Tests
API integration tests in `bruno/` also use the test email domain:
- Login test: `testuser@tests.bookhoard.internal`
- Register test: `testuser@tests.bookhoard.internal`
- Profile update test: `updated@tests.bookhoard.internal`
See: [Bruno Documentation](https://docs.usebruno.com/)
+350
View File
@@ -0,0 +1,350 @@
<!-- 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: Find device
where: Devices page → Add New Device modal
does: Looks up the pairing code shown on the registering e-reader and renders the confirm card.
label: Pair device
where: Devices page → Add New Device modal → confirm card
does: Binds the device behind the pairing code to the signed-in account.
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: Force Rescan All Libraries
where: /admin dashboard → Quick Actions
does: Force-rescans every library — re-reads each file, re-extracts metadata with original-scan semantics, and recomputes hash identifiers. Unlike the shallow per-library and per-folder scans, nothing unchanged is skipped; this is the admin's backfill tool.
also: Scan All Libraries
note: renamed 2026-11-02 — the old label under-promised a heavy operation
label: Force Rescan
where: Admin → Libraries → a library's card row
does: Force-rescans that one library — re-reads and re-extracts every file in it. Same semantics as Force Rescan All Libraries, scoped to a single library.
also: Scan
note: renamed 2026-11-02 per owner — the label now carries the force semantics instead of hiding them
label: Scan this folder
where: Admin → Libraries → a library's panel → a folder row
does: Shallow-scans only that folder's subtree — unchanged files are skipped, and items in the library's other folders are never marked missing by a partial scan.
label: Retry
where: Admin → Libraries → a library's Processing Issues page → an issue card
does: Re-runs that one item through the scanner (a per-item scan, not a library pass). Success closes the issue; failure shows the real error.
label: Retry all
where: Admin → Libraries → a library's Processing Issues page → beside the severity cards
does: Queues every issue on the page and re-runs them one at a time, showing done/total progress. Deliberately not a whole-library scan.
label: Dismiss
where: Admin → Libraries → Processing Issues → warning and info cards only
does: Closes the issue without re-processing, after a confirm. Error-severity issues cannot be dismissed.
label: Purge All Archived
where: Admin → Archived Items → page header
does: Permanently deletes every archived item and its reading history, after a confirm. The per-item alternative is Delete.
label: Merge
where: Admin → Archived Items → rows with a matching active book
does: Folds the archived copy's reading data (progress, annotations, collections) into the active copy and removes the archived entry; per-user collisions keep the active copy's data.
label: Delete
where: Admin → Archived Items rows, and other admin destructive rows
does: Permanently removes the item and its reading history — no archive, no undo. Every Delete is confirm-guarded and states the scope.
label: Create Library
where: Admin → Libraries → page header
does: Opens the create-library modal (name, optional description, library type). Type is fixed at creation — it cannot be edited later.
label: Manage
where: Admin → Libraries → a library's card row
does: Opens that library's panel: folders, user access, processing issues, edit/delete.
label: Edit Details
where: Admin → Libraries → a library's panel
does: Edits the library's name and description. Library type is not editable.
label: Delete Library
where: Admin → Libraries → a library's panel
does: Permanently deletes the library, its folder mappings, and all book records with reading progress, highlights, notes, bookmarks, and ratings. Files on disk are not touched.
label: View Archived
where: Admin → Libraries → the archived banner
does: Opens the Archived Items page.
label: Purge Archived Now
where: Admin → Libraries → the archived banner
does: Same permanent wipe as Purge All Archived on the Archived Items page, offered inline.
label: Browse
where: Admin → Libraries → a library's panel → folder form
does: Opens the server-side folder browser: walk the filesystem, Select This Folder fills the path input.
label: User Access
where: Admin → Libraries → a library's panel → section heading
does: Per-user visibility checkboxes — checked (default) shows the library to that user; unchecked hides it everywhere for them, enforced in the database.
label: Keep both
where: Admin → Hash Conflicts → a conflict group card
does: Records the resolution and stops flagging the group; every copy stays, nothing merged or removed. For intentional duplicates.
label: Keep this copy
where: Admin → Hash Conflicts → a copy row
does: Keeps that copy, folds the other copies' reading data into it, and removes their catalog rows. Files on disk are never touched.
label: Delete files
where: Admin → All Podcasts → a feed row
does: Frees the disk and resets downloaded episodes to pending; they re-download on demand. A feed refresh will NOT re-materialize them.
label: Remove
where: Admin → All Podcasts → a feed row
does: Admin-wide feed removal — episode rows, downloaded files, and every user's subscription are deleted. Instance-wide, unlike a user's own unsubscribe.
label: + Create User
where: Admin → Users → page header
does: Opens the account-creation form (hidden until clicked). Available even when public registration is disabled; password rules mirror the live Settings.
label: Edit User Profile
where: Admin → Users → a user card → Edit
does: The admin edit modal — profile fields, role, and Change Password without requiring the user's current password.
label: Save Settings
where: Admin → Settings → top card
does: Commits the Base URL and Default Timezone together.
label: Save Scan Settings
where: Admin → Settings → Scanning card
does: Commits Auto-Scan and the Scan Interval.
label: Tunable Settings — Live
where: Admin → Settings → section heading
does: Settings that apply immediately on save, grouped by area.
label: Tunable Settings — Restart Required
where: Admin → Settings → section heading
does: Settings whose saves take effect only after the server restarts; the bucket is marked in amber.
label: Default Timezone
where: Admin → Settings → System Defaults card
does: The timezone used for any user who hasn't chosen their own; governs how dates and times render for them.
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"
```
+75 -685
View File
@@ -1,700 +1,90 @@
# WebSocket API Documentation
<!-- Ground truth: internal/sync/websocket.go (ConnectionManager, message
constants, broadcast fan-out + drop-on-full backpressure),
internal/handlers/websocket.go (dual auth, initial_state on connect,
30s ping / 90s read deadline, handleMessage logs-only),
internal/router/sync.go (route group), internal/services/worker.go
(scan event emitters). Re-verify against these when the wire
changes. Rewritten 2026-11-02 from source; the Feb-era version
documented client->server messages that the server never acted on. -->
**Base URL**: `ws://localhost:8765/ws/sync`
**Protocol**: WebSocket (RFC 6455)
**Authentication**: Required (JWT token or device token)
# WebSocket API (real-time events)
---
One channel, one direction: `/ws/sync` is a **server → client push feed**
for sync and scan events. It is not a command channel — clients do all
reading and writing through the REST API, and the server logs (but does
not act on) anything a client sends. The one client obligation is the
standard WebSocket pong, below.
## Table of Contents
## Connecting
1. [Authentication](#authentication)
2. [Connection](#connection)
3. [Message Format](#message-format)
4. [Message Types](#message-types)
5. [Client Implementation Guide](#client-implementation-guide)
6. [Examples](#examples)
7. [Troubleshooting](#troubleshooting)
`WS /ws/sync` — two accepted identities:
---
| Method | How |
| --- | --- |
| User (JWT) | `?token=<jwt>` query parameter |
| Device (KOReader, Kobo) | `Authorization: Bearer <device-token>` header |
## Authentication
No token → `401`. On connect the server immediately sends one
**`initial_state`** message: the connecting identity's media items for
sync, read under that identity's own data visibility.
### Option 1: JWT Token (Web/Mobile Clients)
## Message envelope
Pass JWT token as query parameter:
```
ws://localhost:8765/ws/sync?token=<your_jwt_token>
```
**How to get JWT token**:
```bash
curl -X POST http://localhost:8765/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password"}'
```
Response:
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "user-uuid",
"email": "user@example.com"
}
}
```
### Option 2: Device Token (Kobo, KOReader, etc.)
Pass device token in Authorization header:
```
ws://localhost:8765/ws/sync?token=any-value
Authorization: Bearer <device_token>
```
**How to get device token**:
Device tokens are generated when devices are registered via the API.
---
## Connection
### Step 1: Connect to WebSocket
**JavaScript Example**:
```javascript
const token = "your-jwt-token";
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
ws.onopen = () => {
console.log("Connected to Bookhoard WebSocket");
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
ws.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
};
```
**Go Example**:
```go
import (
"github.com/gorilla/websocket"
)
url := "ws://localhost:8765/ws/sync?token=" + token
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
log.Fatal("Dial error:", err)
}
defer ws.Close()
```
**Python Example**:
```python
import websocket
token = "your-jwt-token"
url = f"ws://localhost:8765/ws/sync?token={token}"
def on_message(ws, message):
print(f"Received: {message}")
def on_error(ws, error):
print(f"Error: {error}")
def on_close(ws, close_status_code, close_msg):
print("Closed")
def on_open(ws):
print("Connected")
ws = websocket.WebSocketApp(
url,
on_open=on_open,
on_message=on_message,
on_error=on_error,
on_close=on_close
)
ws.run_forever()
```
### Step 2: Handle Initial State
Immediately after connecting, you'll receive the initial state:
```javascript
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "initial_state") {
console.log("Initial progress:", message.data.progress);
console.log("Connected devices:", message.data.devices);
// Store initial state
initialState = message.data;
}
};
```
### Step 3: Handle Real-time Updates
Listen for subsequent updates:
```javascript
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case "progress_update":
handleProgressUpdate(message);
break;
case "annotation_update":
handleAnnotationUpdate(message);
break;
case "conflict":
handleConflict(message);
break;
case "heartbeat":
// Server ping, ignore
break;
default:
console.log("Unknown message type:", message.type);
}
};
function handleProgressUpdate(message) {
const { book_id, percentage } = message.data;
const source = message.source_device;
console.log(`Progress update for ${book_id}: ${percentage * 100}%`);
console.log(`Source: ${source.name} (${source.type})`);
// Update UI
updateProgressBar(book_id, percentage);
}
function handleAnnotationUpdate(message) {
const { book_id, annotation_type, data } = message.data;
const source = message.source_device;
console.log(`Annotation update for ${book_id}: ${annotation_type}`);
console.log(`Source: ${source.name}`);
// Update UI
showAnnotation(book_id, data);
}
function handleConflict(message) {
const { book_id, notification_type, conflict_id } = message.data;
console.log(`Conflict detected for ${book_id}: ${notification_type}`);
// Show conflict resolution UI
showConflictDialog(book_id, conflict_id);
}
```
---
## Message Format
All messages follow this structure:
```json
{
"type": "message_type",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
// Message-specific data
},
"source_device": {
"id": "device-uuid",
"name": "Device Name",
"type": "kobo|koreader|web|mobile"
}
}
```
**Fields**:
- `type` (string, required): Message type identifier
- `timestamp` (string, required): ISO 8601 timestamp
- `data` (object, required): Message payload
- `source_device` (object, optional): Device that sent the update (not present for initial_state)
---
## Message Types
### 1. initial_state
**When**: Immediately after connection
**Purpose**: Send current progress for all user's books
**Data Structure**:
```json
{
"type": "initial_state",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
"progress": {
"<book_uuid>": {
"percentage": 0.5,
"current_page": 150,
"total_pages": 300,
"last_read": "2026-02-01T11:30:00Z"
}
},
"devices": {
"kobo": 2,
"koreader": 1,
"web": 3
}
}
}
```
**Fields**:
- `progress` (object): Map of book UUID → progress data
- `percentage` (number): 0.0 to 1.0
- `current_page` (number): Current page number
- `total_pages` (number): Total pages in book
- `last_read` (string): ISO 8601 timestamp of last read
- `devices` (object): Connection statistics by device type
### 2. progress_update
**When**: Any device syncs reading progress
**Purpose**: Notify all connected clients of progress change
**Data Structure**:
Every server message is a JSON object:
```json
{
"type": "progress_update",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
"book_id": "book-uuid-1",
"percentage": 0.75
},
"source_device": {
"id": "device-uuid-1",
"name": "My Kobo Clara",
"type": "kobo"
}
"timestamp": "2026-11-02T12:00:00Z",
"data": { "...": "..." },
"source_device": { "id": "…", "name": "Living-room Kobo", "type": "kobo" }
}
```
**Fields**:
- `book_id` (string): UUID of book with updated progress
- `percentage` (number): New progress value (0.0 to 1.0)
- `source_device` (object): Device that sent the update
**Use Cases**:
- Update progress bar in real-time
- Sync reading position across devices
- Update "currently reading" lists
### 3. annotation_update
**When**: Any device syncs annotations, bookmarks, or highlights
**Purpose**: Share annotations across devices
**Data Structure**:
```json
{
"type": "annotation_update",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
"book_id": "book-uuid-1",
"annotation_type": "bookmark",
"data": {
"page": 150,
"text": "Great quote on page 150",
"chapter": 5,
"created_at": "2026-02-01T12:00:00Z"
}
},
"source_device": {
"id": "device-uuid-1",
"name": "My Kobo Clara",
"type": "kobo"
}
}
```
**Fields**:
- `book_id` (string): UUID of book
- `annotation_type` (string): Type of annotation (bookmark, highlight, note)
- `data` (object): Annotation-specific data
- `page` (number): Page number
- `text` (string): Annotated text or note
- `chapter` (number): Chapter number (optional)
- `created_at` (string): ISO 8601 timestamp
- `source_device` (object): Device that created the annotation
**Use Cases**:
- Show bookmarks on all devices
- Share highlights between devices
- Display reading notes
### 4. conflict
**When**: Sync conflict is detected
**Purpose**: Notify user of conflicting updates
**Data Structure**:
```json
{
"type": "conflict",
"timestamp": "2026-02-01T12:00:00Z",
"data": {
"book_id": "book-uuid-1",
"notification_type": "progress_conflict",
"conflict_id": "conflict-uuid-1"
}
}
```
**Fields**:
- `book_id` (string): UUID of book with conflict
- `notification_type` (string): Type of conflict (progress_conflict, annotation_conflict)
- `conflict_id` (string): UUID of conflict record
**Use Cases**:
- Prompt user to resolve conflict
- Show conflict resolution UI
- Log conflict for manual review
### 5. heartbeat
**When**: Server sends ping every 30 seconds
**Purpose**: Keep connection alive
**Data Structure**:
```json
{
"type": "heartbeat",
"timestamp": "2026-02-01T12:00:00Z"
}
```
**Handling**: Clients should respond automatically (handled by WebSocket protocol)
---
## Client Implementation Guide
### Reconnection Strategy
Implement exponential backoff for reconnection:
```javascript
let reconnectAttempts = 0;
const maxReconnectAttempts = 10;
const baseReconnectDelay = 1000; // 1 second
function connect() {
const token = getAuthToken();
const ws = new WebSocket(`ws://localhost:8765/ws/sync?token=${token}`);
ws.onopen = () => {
console.log("Connected");
reconnectAttempts = 0; // Reset on successful connection
};
ws.onclose = (event) => {
if (reconnectAttempts < maxReconnectAttempts) {
const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
} else {
console.error("Max reconnection attempts reached");
}
};
ws.onmessage = handleIncomingMessage;
return ws;
}
```
### Error Handling
Handle common errors:
```javascript
ws.onerror = (error) => {
console.error("WebSocket error:", error);
// Check specific error types
if (error.code === 1006) {
console.error("Abnormal closure - server may be down");
} else if (error.code === 1008) {
console.error("Policy violation - check authentication");
}
};
```
### Message Queueing
Queue messages while disconnected:
```javascript
let messageQueue = [];
let isConnected = false;
function queueMessage(message) {
if (isConnected) {
ws.send(JSON.stringify(message));
} else {
messageQueue.push(message);
}
}
ws.onopen = () => {
isConnected = true;
// Send queued messages
while (messageQueue.length > 0) {
const message = messageQueue.shift();
ws.send(JSON.stringify(message));
}
};
ws.onclose = () => {
isConnected = false;
};
```
---
## Examples
### Complete JavaScript Client
```javascript
class BookhoardWebSocket {
constructor(token) {
this.token = token;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.messageHandlers = {};
}
connect() {
const url = `ws://localhost:8765/ws/sync?token=${this.token}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("Connected to Bookhoard");
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
this.ws.onclose = () => {
this.reconnect();
};
}
handleMessage(message) {
const handler = this.messageHandlers[message.type];
if (handler) {
handler(message);
}
}
on(messageType, callback) {
this.messageHandlers[messageType] = callback;
}
reconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = 1000 * Math.pow(2, this.reconnectAttempts);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
}
disconnect() {
if (this.ws) {
this.ws.close();
}
}
}
// Usage
const token = "your-jwt-token";
const client = new BookhoardWebSocket(token);
client.on("initial_state", (message) => {
console.log("Initial state received:", message.data);
updateUIWithInitialState(message.data);
});
client.on("progress_update", (message) => {
console.log("Progress updated:", message.data);
updateProgressBar(message.data.book_id, message.data.percentage);
});
client.on("annotation_update", (message) => {
console.log("Annotation updated:", message.data);
showNotification("New annotation from " + message.source_device.name);
});
client.on("conflict", (message) => {
console.log("Conflict detected:", message.data);
showConflictDialog(message.data);
});
client.connect();
```
---
## Troubleshooting
### Connection Fails
**Problem**: Can't connect to WebSocket
**Solutions**:
1. Check JWT token is valid
2. Verify server is running: `curl http://localhost:8765/health`
3. Check firewall settings
4. Verify WebSocket URL format
### Connection Drops Frequently
**Problem**: WebSocket disconnects unexpectedly
**Solutions**:
1. Check network stability
2. Verify server keepalive settings
3. Implement reconnection logic (see above)
4. Check server logs for errors
### No Messages Received
**Problem**: Connected but no messages
**Solutions**:
1. Check onmessage handler is registered
2. Verify initial_state message received
3. Test with manual progress update via API
4. Check browser console for errors
### Authentication Errors
**Problem**: 401 Unauthorized
**Solutions**:
1. Verify JWT token is not expired
2. Check token has correct claims (user_id)
3. For device tokens, verify Authorization header format
4. Regenerate token via `/api/auth/login`
### High Memory Usage
**Problem**: Client memory increases over time
**Solutions**:
1. Clean up old messages
2. Don't store entire message history
3. Use weak references for large data
4. Implement message pagination
---
## Performance Tips
1. **Debounce UI Updates**: Don't update DOM on every message
```javascript
const debouncedUpdate = debounce(updateUI, 100);
client.on("progress_update", (message) => {
debouncedUpdate(message);
});
```
2. **Use Web Workers** for heavy processing
3. **Limit Message History**: Keep only last N messages
4. **Virtual Scrolling**: For large lists of updates
5. **Connection Pooling**: Reuse connections across tabs
---
## Security Best Practices
1. **Use WSS in Production**: Always use secure WebSocket (wss://) in production
2. **Validate Tokens**: Always verify JWT tokens on server
3. **Rate Limiting**: Implement message rate limiting per connection
4. **Input Sanitization**: Sanitize all message data before displaying
5. **Origin Checks**: Validate WebSocket origin on server
---
## Support
For issues or questions:
- GitHub Issues: [Bookhoard Repository]
- Documentation: [Bookhoard Docs]
- API Reference: [Bookhoard API Docs]
---
**Last Updated**: 2026-02-01
**Version**: 1.0.0
**Protocol**: WebSocket (RFC 6455)
`source_device` is present on events that originate from a device
(progress and annotation updates), so clients can ignore their own echo.
## Message catalog
All ten types defined by `internal/sync/websocket.go`:
| Type | Emitted | `data` carries |
| --- | --- | --- |
| `initial_state` | Once, on connect | The connecting user's media items for sync |
| `progress_update` | On any device's progress save | `book_id`, `percentage` |
| `annotation_update` | On annotation changes (highlights, notes, bookmarks) | `book_id`, `annotation_type`, payload |
| `conflict` | When a sync conflict is detected | `book_id`, `notification_type`, `conflict_id` |
| `scan_progress` | During scan jobs | Job progress for the admin scan panel |
| `scan_complete` | When a scan job finishes | Job summary |
| `scan_error` | When a scan job fails | Job error |
| `scan_cancelled` | When a scan job is cancelled | Job identity |
| `collection_updated` | When books are added to / removed from one of *your* collections | `collection_id`, `action` (`books_added` / `book_removed` / `books_removed`), affected book ids |
| `heartbeat` | *Defined, not currently emitted* | — |
| `sync_complete` | *Defined, not currently emitted* | — |
(`collection_updated` is currently the only message type defined as a raw
string in the collections handler rather than in the sync package's
constants block.)
## Keepalive
The server sends a WebSocket **ping frame every 30 seconds** and expects
the standard pong within a **90-second read deadline**. Browsers and
every mainstream WebSocket library answer pings automatically — there is
nothing to implement. A connection that stops answering is removed.
## Fan-out and backpressure — read this before building a client
- **Every event carries an explicit audience.** Progress, annotation,
conflict, and collection events are delivered only to the acting
user's connections — the ConnectionManager has no accidental
fan-out, and the compiler forces every emitter to name its audience
(2026-11-02, after cross-user delivery was found and fixed). The one
sanctioned instance-wide fan-out is **scan events**
(`BroadcastToAll`): they carry job ids and counts, never user data,
and the sitewide scan indicator in every browser runs on them.
- Every connection has a bounded send queue. **If a client falls behind,
messages for that connection are skipped, not queued.** Clients must
therefore treat the socket as a *hint* — on reconnect (or any gap),
refetch real state over REST. The fresh `initial_state` on reconnect is
designed to be that gap-healer.
+144
View File
@@ -0,0 +1,144 @@
# 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.
- **Administration (`docs/admin/`) — plain language, one layer deeper.**
Same bones as the user docs: task-first, what to click for what, exact
quoted labels. Added: every control also states what the click *actually
does* — semantics, cost, and consequences — before the reader clicks it.
The canonical example: **Force Rescan All Libraries** re-reads and
re-extracts every file and recomputes hashes, while the other scan
buttons are shallow scans that skip unchanged files — an admin should
know that before clicking, not after. Every setting states what it is,
what it does, what it means when that is confusing, and its valid range.
No API-client detail: request and response schemas live in the developer
docs. The calibration in one line: **user = what you see, admin = what
it does, developer = how it works.**
- **Developer section — source-pinned and wire-accurate.** The
developer tier is *how it works*: wire formats, data access, pipeline
mechanics, and enforced rules, every claim grounded in source the way
admin docs ground behavior. External software (device installs,
third-party tooling) is linked to its project's documentation, not
paraphrased. (Decided 2026-11-02, closing the earlier open question.)
## 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.
## Promises need approval
Docs never promise future behavior on the owner's behalf. No "coming soon",
"under consideration", "in a future release", or any other forward-looking
commitment gets written without the owner explicitly approving that exact
promise — and anything speculative found in the docs gets reported, not
quietly kept and not quietly deleted. (Rule origin, 2026-11-02: the owner
ruled out "Kindle and reMarkable are under consideration" and a "Custom
Calibre Columns (Future)" section — no such plans existed — while the
Operations guide's pre-existing "Coming Soon" placeholders were reported
and knowingly kept for the future operations-docs pass.)
## 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).
+53 -81
View File
@@ -13,6 +13,7 @@ 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
@@ -22,53 +23,64 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
- [Filter cookbook](user/filters/cookbook.md) - ready-made recipes, exact clicks
- [Every filter field](user/filters/fields.md) - the generated, always-current reference
- **Frontend & Settings** (Coming Soon)
- Frontend Guide
- User Areas Guide
- Settings Guide
- Admin Guide
- **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)
### 🛡 For Administrators
**[Admin Guide](admin/admin-guide.md)** - running a Bookhoard instance: every admin page, what each control actually does
- **Administration**
- [Admin Dashboard](admin/dashboard.md) - stats, Force Rescan All Libraries, scan progress
- [Libraries](admin/libraries.md) - create libraries, folders, user access, scanning
- [Processing Issues](admin/library-processing-issues.md) - items the scanner couldn't process
- [Archived Items](admin/archived-items.md) - missing files, history-preserving archive
- [All Podcasts](admin/podcasts.md) - the maintenance view for every feed
- [Hash Conflicts](admin/hash-conflicts.md) - duplicate content resolution
- [Users](admin/users.md) - accounts, roles, password resets
- [System Settings](admin/settings.md) - every setting: key, default, range, meaning
- [Scanning](admin/scanning.md) - shallow vs. force: what every scan button does
### 👨‍💻 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
- [Troubleshooting Guide](operations/troubleshooting.md) - Common deployment issues and solutions
### 🤝 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
---
@@ -76,11 +88,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) |
@@ -92,65 +104,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_
-16
View File
@@ -1,16 +0,0 @@
# Admin Guide
**Coming Soon**
This guide will cover:
- User management
- Library management
- System monitoring
- Analytics and reporting
- Security settings
- Backup and restore
---
_In the meantime, check out our [Operations Portal](../operations/)_
-32
View File
@@ -1,32 +0,0 @@
# Admin Library Management
## Adding Library Folders
When creating or managing a library, you can add folders containing your media files (ebooks, comics, manga).
### Using the Folder Browser
The admin library page includes a folder browser to help you select folders on the server:
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
2. Click a library in the list to expand its panel
3. Find the **Folders** section
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
5. Navigate through the server's filesystem
6. Select a folder; it fills the path input, then click **Add**
### Security
- The folder browser only shows directories (not files)
- Path traversal is protected (cannot access parent directories with `..`)
- Only admin users can browse the filesystem
### Manual Entry
Alternatively, you can manually type the full server path if you know it:
```
/home/user/books
/media/external/ebooks
/var/lib/manga
```
+4 -15
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
@@ -248,16 +248,6 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
## Advanced Usage
### Custom Calibre Columns (Future)
Bookhoard may support Calibre custom columns in a future release. This would allow you to import:
- Read status
- Favorite flags
- Custom metadata fields
- User-defined categories
Stay tuned for updates!
### OPDS Integration
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
@@ -332,11 +322,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)
---
+179 -74
View File
@@ -1,83 +1,82 @@
<!-- Ground truth: bookhoard.koplugin main.lua (menu tree, defaults, pairing
flow) + the server-side koreader endpoints. Re-verify against the plugin
source when the plugin changes. KOReader ITSELF is out of scope: this
guide supports the plugin only, and installation is delegated to the
KOReader project's own documentation (README #installation, verified
2026-11-02 — per-device wiki pages for Android, Kindle, Kobo,
PocketBook, reMarkable, and more). -->
# 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. One scope note up front: this guide is
about the **Bookhoard sync plugin**. Installing KOReader itself is a
different project's documentation, covered below.
## 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
Before you begin, make sure you have:
- ✅ A Bookhoard instance running and accessible on your network
- ✅ A web browser logged in to your Bookhoard account (for device approval)
- ✅ A KOReader-compatible e-reader device
- ✅ A web browser logged in to your Bookhoard account (to enter the pairing code)
- ✅ KOReader installed and running on your e-reader (see next section)
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
## Installing KOReader
### Kindle Devices
One honest note first: **Bookhoard supports the Bookhoard sync plugin —
not KOReader itself.** Getting KOReader onto an e-reader usually involves
device-specific steps (jailbreaks, alternative launchers, one-click
packages) that vary by model and firmware. That's the KOReader project's
domain, not ours — we can't tell you how to modify your particular device,
and we won't pretend otherwise with half-remembered instructions.
1. **Install KOReader**
- Download the latest KOReader release from [koreader.rocks](https://koreader.rocks/)
- Extract the `koreader` folder to your Kindle's root directory
- Safely eject your Kindle from your computer
The good news: KOReader documents this properly. Their installation guide
routes you to model-specific steps for Android, Kindle, Kobo, PocketBook,
reMarkable, and more:
2. **Launch KOReader**
- On your Kindle, go to Home → Settings → Device Options → Personalize Your Kindle
- Select `koreader` from the launcher options
- Alternatively, you can use the `linkjail` method to access KOReader directly
**→ [KOReader installation guide](https://github.com/koreader/koreader#installation)**
3. **Enable Wi-Fi**
- In KOReader, tap the network icon in the top menu
- Connect to your Wi-Fi network
### Kobo Devices
1. **Install KOReader**
- Download the latest KOReader Kobo package from [koreader.rocks](https://koreader.rocks/)
- Copy the `koreader` folder to your Kobo's `.adds/` directory
- Safely eject your Kobo from your computer
2. **Launch KOReader**
- Eject and disconnect your Kobo
- Kobo will restart and you'll see KOReader as an option
- Alternatively, create a shortcut on your home screen
3. **Enable Wi-Fi**
- In KOReader, tap the network icon
- Connect to your Wi-Fi network
### PocketBook Devices
1. **Install KOReader**
- Download the PocketBook version from [koreader.rocks](https://koreader.rocks/)
- Copy to your device and install via the package manager
2. **Launch KOReader**
- Open KOReader from your apps menu
- Enable Wi-Fi in the network settings
Once KOReader itself is installed and running, everything below applies,
whatever device you're on.
## 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**: your e-reader shows a pairing code, and you
type it into your own account's Devices page — 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 +86,120 @@ 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. Your e-reader displays a **6-digit pairing code**
3. On your computer or phone, open Bookhoard — logged into **your** account —
and go to the **Devices** page (sidebar navigation)
4. Type the code into the **Pair a Device** field and click **Find device**,
then confirm on the card that appears
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 code is the only way to claim the registration, and only someone holding
the device can read it — so the device always lands in the account of the
person who registered it. The plugin waits on the device and finishes by
itself the moment you confirm. Once paired, 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:** Pairing codes expire after 5 minutes. If yours expires
> ("Registration expired"), just tap **Register device** again for a fresh
> one. Dismissing the waiting dialog on the device cancels the attempt.
### 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 pairing), 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 pairing 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 pairing, 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,20 +214,33 @@ 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
### Pending Registration Never Appears
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
**Problem**: You entered the Server URL, but no pairing code shows on the device
**Solutions**:
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
### Pairing Code Not Accepted
**Problem**: The Devices page says "No waiting device matches that code"
**Solutions**:
1. Re-read the code on the device — it refreshes on every **Register device** tap
2. Check you typed all 6 digits (spacing doesn't matter)
3. Codes expire after 5 minutes — register again on the device and type the new code
4. "Pairing code already used" means the device already paired (check the device list); otherwise register again
5. Too many attempts lock new tries for a minute — wait and retry
### Connection Refused
@@ -155,17 +253,24 @@ Books downloaded via OPDS are automatically matched to your library, so their pr
- Ensure the device is on the same Wi-Fi network as the server
- Use the server's LAN IP instead of `localhost`
### Sync Not Working After Approval
### Sync Not Working After Pairing
**Problem**: Device shows as approved but changes don't appear in Bookhoard
**Problem**: Device shows as paired but changes don't appear in Bookhoard
**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,23 +279,23 @@ 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
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
3. **Device Authorization**: Only approve pending registrations you initiated
3. **Claim only codes you can see**: a pairing code is readable only on the
registering device's screen — never claim a code someone merely told you
if you can't see the device showing it
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
## Additional Resources
- [KOReader Documentation](https://github.com/koreader/koreader)
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
- [KOReader](https://github.com/koreader/koreader) — [installation](https://github.com/koreader/koreader#installation) · [user guide](https://koreader.rocks/user_guide/)
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=276) — the project's official Mobileread subforum
- [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
-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.
-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/)_
+101 -555
View File
@@ -1,591 +1,137 @@
# 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?
## Getting a device into the circle
**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.
1. Connect the device to your server (each guide above walks its own path)
2. Pair it on the **Devices** page — the Android app registers itself on
first login; KOReader shows a 6-digit pairing code that you type into
**Pair a Device** on the page
3. Done. Sync starts as soon as a device is paired
### Key Features
Only the person holding the device can read its pairing code, so a device
always lands in the account of whoever registered it — even on a family
server where more than one person is logged in.
🔄 **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 paired 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 paired; 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
enter the fresh pairing code.
**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/admin-guide.md) is the door, and the
Administration docs section covers every admin page.
## 📚 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).
+1 -1
View File
@@ -26,7 +26,7 @@ const docsRoot = "../../docs/developer/api"
// brunoKeepDir / brunoKeepSub name the hand-authored parts of the
// collection that generation must never touch or delete.
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true}
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true, "podcasts": true}
var brunoKeepSub = []string{"/scenarios/", "/filters/", "/search/"}
+29
View File
@@ -18,6 +18,25 @@ type HTTPHandler struct {
docs *DocsHandler
}
// docRedirects maps retired doc paths (as passed to ShowDocumentation —
// no leading /docs, .md suffix optional) to their current paths. Docs move
// rarely; when one moves, add it here so old links 301 to the new home
// instead of 404ing. Checked BEFORE .md canonicalization so a moved page's
// .md spelling also one-hops straight to the new clean URL.
// 2026-11-02: admin guides moved from docs/user/ to docs/admin/, and
// admin-library-management.md became admin/libraries.md (it will be
// rebuilt as the Libraries page).
var docRedirects = map[string]string{
"user/admin-guide": "admin/admin-guide",
"user/admin-library-management": "admin/libraries",
// developer/testing.md (Feb-era) was deleted 2026-11-02: its
// wipe-test-users lifecycle contradicted current doctrine (live
// testuser is protected, cleanups are scoped) and its recipes were
// superseded by docs/contributing/development.md (architecture,
// setup, testing, project rules — kept fresh).
"developer/testing": "contributing/development",
}
// NewHTTPHandler creates a new docs HTTP handler
func NewHTTPHandler(docsPath string) *HTTPHandler {
return &HTTPHandler{
@@ -76,6 +95,16 @@ func (h *HTTPHandler) ShowDocumentation(c *echo.Context) error {
// Get the document path from URL parameter
docPath := c.Param("*")
// Retired doc paths 301 to their current homes (see docRedirects)
// before any load attempt.
if target, moved := docRedirects[strings.TrimSuffix(docPath, ".md")]; moved {
loc := "/docs/" + target
if c.Request().URL.RawQuery != "" {
loc += "?" + c.Request().URL.RawQuery
}
return c.Redirect(http.StatusMovedPermanently, loc)
}
// Canonicalize ".md" URLs to the clean form. Markdown links across the
// docs tree point at `area/file.md`; the handler loads them fine, but
// the sidebar logic keys on the URL path (contains-URL expansion, the
+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]+)\)`)
var handsOffFilterDocs = []string{
"user/filters/",
"user/searching-and-filtering.md",
}
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)
}
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
+64 -6
View File
@@ -28,6 +28,47 @@ var sectionOrder = []struct {
{Title: "Contributing", Icon: "heart"},
}
// usabilityOrder pins an explicit, usability-first display order for a
// section's direct nav items, overriding the alphabetical fallback (owner
// request 2026-11-02: order the menu by how people work, not by letter —
// the User Guide list mirrors the "Where to go" table on user-guide.md).
// Values are doc paths without the .md suffix; anything in the section not
// listed sorts alphabetically AFTER the listed items. Display-only: the
// open/highlight machinery keys on URL membership (containsURL) and is
// order-blind, so URL-driven expansion is unaffected. Administration lists
// its planned pages up front so each lands in place as it is written.
var usabilityOrder = map[string][]string{
"User Guide": {
"user/dashboard",
"user/library-browsing",
"user/collections",
"user/searching-and-filtering",
"user/podcasts",
"user/reader-guide",
"user/themes",
"user/profile-guide",
"user/sync-guide",
"user/calibre-integration",
},
"Devices": {
"user/devices/android-app",
"user/devices/koreader-setup",
"user/devices/kobo-setup",
},
"Administration": {
"admin/admin-guide",
"admin/dashboard",
"admin/libraries",
"admin/library-processing-issues",
"admin/archived-items",
"admin/podcasts",
"admin/hash-conflicts",
"admin/users",
"admin/settings",
"admin/scanning",
},
}
// groupTitleOverrides fixes casing that naive title-casing gets wrong.
var groupTitleOverrides = map[string]string{
"admin": "Admin",
@@ -104,12 +145,16 @@ func placeDoc(docPath string) navPlacement {
if rest == "user-guide.md" {
return navPlacement{section: "User Guide", pin: -50}
}
// Admin-facing guides are their own top-level section too.
if strings.HasPrefix(rest, "admin-") {
return navPlacement{section: "Administration"}
}
return navPlacement{section: "User Guide"}
case strings.HasPrefix(docPath, "admin/"):
// Admin docs live in their own area (docs/admin/); the guide is
// the section landing page.
if docPath == "admin/admin-guide.md" {
return navPlacement{section: "Administration", pin: -50}
}
return navPlacement{section: "Administration"}
case strings.HasPrefix(docPath, "developer/api/"):
rest := strings.TrimPrefix(docPath, "developer/api/")
if dir, _, ok := strings.Cut(rest, "/"); ok {
@@ -256,14 +301,27 @@ func (h *DocsHandler) BuildNavigation(currentPath string) *templates.Navigation
build := builds[spec.Title]
section := templates.NavSection{Title: spec.Title, Icon: spec.Icon}
// Direct items: pinned landing pages lead, then alphabetical
// (case-insensitive — KOReader must not sort before Kobo).
// Direct items: pinned landing pages lead, then usability order
// (see usabilityOrder), then alphabetical among anything unlisted.
order := usabilityOrder[spec.Title]
rankOf := func(url string) int {
for i, p := range order {
if docURL(p) == url {
return i
}
}
return len(order)
}
items := append([]templates.NavItem(nil), build.items...)
sort.SliceStable(items, func(i, j int) bool {
pi, pj := build.pinned[items[i].URL], build.pinned[items[j].URL]
if pi != pj {
return pi
}
ri, rj := rankOf(items[i].URL), rankOf(items[j].URL)
if ri != rj {
return ri < rj
}
return strings.ToLower(items[i].Title) < strings.ToLower(items[j].Title)
})
section.Items = items
+79 -6
View File
@@ -3,6 +3,7 @@ package docs
import (
"os"
"path/filepath"
"reflect"
"testing"
"bookhoard/templates"
@@ -18,7 +19,8 @@ func TestPlaceDoc(t *testing.T) {
{"index.md", "Getting Started", "", true},
{"user/user-guide.md", "User Guide", "", true},
{"user/dashboard.md", "User Guide", "", false},
{"user/admin-guide.md", "Administration", "", false},
{"admin/admin-guide.md", "Administration", "", true},
{"admin/libraries.md", "Administration", "", false},
{"user/devices/kobo-setup.md", "Devices", "", false},
{"developer/api/api-reference.md", "API Reference", "", true},
{"developer/api/dashboard.md", "API Reference", "", false},
@@ -52,6 +54,7 @@ func TestDocTitle(t *testing.T) {
tests := map[string]string{
"index.md": "Overview",
"user/user-guide.md": "User Guide",
"admin/admin-guide.md": "Admin Guide",
"user/searching-and-filtering.md": "Searching and Filtering",
"user/devices/koreader-setup.md": "KOReader Setup",
"developer/development.md": "Developer Portal",
@@ -86,7 +89,7 @@ func buildTestDocs(t *testing.T) *DocsHandler {
"index.md",
"user/user-guide.md",
"user/dashboard.md",
"user/admin-guide.md",
"admin/admin-guide.md",
"user/devices/kobo-setup.md",
"user/devices/koreader-setup.md",
"developer/api/api-reference.md",
@@ -148,8 +151,11 @@ func TestBuildNavigation(t *testing.T) {
t.Errorf("User Guide direct items = %+v, want User Guide pinned first", userGuide.Items)
}
// Devices: own top-level section, expanded, no groups. (Administration
// sits above it in the order; covered by the collapsed sweep below.)
// Devices: own top-level section, expanded, no groups. KOReader sorts
// ahead of Kobo because usabilityOrder ranks the device guides by how
// the docs front door presents them (Android → KOReader → Kobo), not
// alphabetically. (Administration sits above it in the section order;
// covered by the collapsed sweep below.)
devices := nav.Sections[3]
if devices.Collapsed {
t.Error("Devices section should be expanded: it contains the current page")
@@ -157,8 +163,8 @@ func TestBuildNavigation(t *testing.T) {
if len(devices.Groups) != 0 {
t.Errorf("Devices groups = %+v, want none", devices.Groups)
}
if len(devices.Items) != 2 || devices.Items[0].Title != "Kobo Setup" {
t.Errorf("Devices items = %+v, want Kobo Setup + KOReader Setup", devices.Items)
if len(devices.Items) != 2 || devices.Items[0].Title != "KOReader Setup" {
t.Errorf("Devices items = %+v, want KOReader Setup before Kobo Setup (usability order)", devices.Items)
}
// Everything else starts collapsed (Administration included).
@@ -194,6 +200,73 @@ func TestBuildNavigation(t *testing.T) {
}
}
// TestBuildNavigationUsabilityOrder (2026-11-02): the REAL docs tree must
// present its direct nav items in usability order (usabilityOrder), not
// alphabetically — User Guide mirrors the "Where to go" table on
// user-guide.md, Devices orders Android → KOReader → Kobo, Administration
// mirrors the app's own admin sidebar. Display-order only: expansion and
// highlighting stay URL-driven (order-blind) by construction.
func TestBuildNavigationUsabilityOrder(t *testing.T) {
handler := NewDocsHandler("../../docs")
nav := handler.BuildNavigation("/docs")
find := func(title string) *templates.NavSection {
for i := range nav.Sections {
if nav.Sections[i].Title == title {
return &nav.Sections[i]
}
}
t.Fatalf("section %q missing", title)
return nil
}
titles := func(s *templates.NavSection) []string {
out := make([]string, 0, len(s.Items))
for _, item := range s.Items {
out = append(out, item.Title)
}
return out
}
wantUserGuide := []string{
"User Guide", "Dashboard", "Library Browsing", "Collections",
"Searching and Filtering", "Podcasts", "Reader Guide", "Themes",
"Profile Guide", "Sync Guide", "Calibre Integration",
}
if got := titles(find("User Guide")); !reflect.DeepEqual(got, wantUserGuide) {
t.Errorf("User Guide order = %v, want %v", got, wantUserGuide)
}
wantDevices := []string{"Android App", "KOReader Setup", "Kobo Setup"}
if got := titles(find("Devices")); !reflect.DeepEqual(got, wantDevices) {
t.Errorf("Devices order = %v, want %v", got, wantDevices)
}
// Administration grows as its pages are written; pin its shape, not
// its size: the guide leads (pinned landing), and every other item
// must appear in usabilityOrder's declared relative order.
admin := find("Administration")
if len(admin.Items) < 1 || admin.Items[0].Title != "Admin Guide" {
t.Fatalf("Administration must lead with the Admin Guide, got %+v", admin.Items)
}
pos := map[string]int{}
for i, p := range usabilityOrder["Administration"] {
pos[docURL(p)] = i
}
last := -1
for _, item := range admin.Items[1:] {
r, ok := pos[item.URL]
if !ok {
t.Errorf("Administration item %q (%s) is not in usabilityOrder", item.Title, item.URL)
continue
}
if r <= last {
t.Errorf("Administration item %q breaks usabilityOrder sequence", item.Title)
}
last = r
}
}
func TestBuildNavigationNothingCurrent(t *testing.T) {
h := buildTestDocs(t)
nav := h.BuildNavigation("/docs/nonexistent")
+24 -5
View File
@@ -443,6 +443,25 @@ var Schemas = map[string]Schema{
Request: handlers.DeviceAuthStatusRequest{},
Response: handlers.DeviceAuthStatusResponse{},
},
"POST /api/devices/register/cancel": {
// The registering device ends its own pairing attempt; idempotent
// (same body whether or not the registration still exists).
Request: handlers.CancelRegistrationRequest{},
Response: json.RawMessage(`{"message": ""}`),
},
"POST /api/devices/claim": {
// Pairing-code claim: binds the registration behind the code to
// the caller's account. HTMX callers get HX-Refresh, not JSON.
Request: handlers.PairingCodeRequest{},
Response: json.RawMessage(`{
"message": "", "device_name": "", "device_type": "", "approved": false
}`),
},
"POST /api/devices/claim/lookup": {
// Confirm-card data for a pairing code, without binding.
Request: handlers.PairingCodeRequest{},
Response: handlers.PairingLookupResponse{},
},
// --- Beam batch 4: media-items core ---
"GET /api/media-items": {
@@ -664,8 +683,9 @@ var Schemas = map[string]Schema{
// --- Beam batch 8: devices remaining + auth remainder ---
"GET /api/devices/pending": {
// Device registrations live in an in-memory map; both key spellings
// are emitted for compatibility.
// Deprecated-by-emptiness: unclaimed registrations are invisible
// since pairing codes took over approval (see POST
// /api/devices/claim). Both key spellings kept for compatibility.
Response: json.RawMessage(`{
"registrations": [], "pending_registrations": [], "total": 0
}`),
@@ -673,14 +693,13 @@ var Schemas = map[string]Schema{
"GET /api/devices/approve/:registration_id": {
// Already-approved variant omits registration_id. Credentials ride
// the registration map polled elsewhere — NOT this JSON body.
// Android self-approval path: the app registers, then approves its
// OWN registration UUID with its JWT.
Response: json.RawMessage(`{
"message": "", "device_name": "", "device_type": "",
"registration_id": "", "approved": false
}`),
},
"POST /api/devices/reject/:registration_id": {
Response: json.RawMessage(`{"message": ""}`),
},
"POST /api/auth/users": {
Request: handlers.RegisterRequest{},
Response: 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)
}
}
}
+4 -2
View File
@@ -124,8 +124,10 @@ 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
+407 -138
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/middleware"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
@@ -10,6 +12,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -47,6 +50,7 @@ type DeviceRegistrationResponse struct {
RegistrationID string `json:"registration_id"`
AuthURL string `json:"auth_url"`
QRCode string `json:"qr_code"`
PairingCode string `json:"pairing_code"`
ExpiresIn int `json:"expires_in"`
PollInterval int `json:"poll_interval"`
SetupInstructions map[string]string `json:"setup_instructions"`
@@ -97,11 +101,33 @@ type DeviceApprovalRequest struct {
Approve bool `json:"approve"`
}
// PairingCodeRequest carries the 6-digit code shown on a registering
// device's screen — the claim credential for the pairing flow.
type PairingCodeRequest struct {
// form tag mirrors json: htmx posts urlencoded, the API posts JSON —
// echo v5's form binder maps by `form:` tag only (auth.go convention).
PairingCode string `form:"pairing_code" json:"pairing_code"`
}
// PairingLookupResponse describes a live registration without binding it —
// the confirm card's data ("Pair 'My Kindle'?").
type PairingLookupResponse struct {
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
DeviceIdentifier string `json:"device_identifier"`
ExpiresIn int `json:"expires_in"`
}
type CancelRegistrationRequest struct {
RegistrationID string `form:"registration_id" json:"registration_id" validate:"required"`
}
type PendingRegistration struct {
RegistrationID string
DeviceName string
DeviceType string
DeviceIdentifier string
PairingCode string
UserID uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
@@ -120,6 +146,119 @@ var (
pendingMu sync.Mutex
)
// pairingRateLimiter throttles claim-code lookups per account: the code is
// the only secret guarding a registration, so guessing must not be free.
// The per-registration 5-minute TTL bounds each code's exposure; this
// bounds the guessing rate against the whole live-code space.
var pairingRateLimiter = middleware.NewDeviceRateLimiter()
const (
// pairingCodeDigits: six, the Netflix/YouTube TV pairing shape —
// nothing to type on e-ink hardware, and a million-code space that a
// rate-limited guesser cannot walk inside the 5-minute TTL.
pairingCodeDigits = 6
)
// pairingRateLimitConfig caps lookup/claim attempts per account per
// minute (lookups and claims share the budget — a pairing is two calls:
// lookup, then confirm).
var pairingRateLimitConfig = middleware.DeviceRateLimitConfig{
ClaimAttemptsPerMinute: 12,
}
// generatePairingCode returns a uniformly random pairingCodeDigits-digit
// code, unique among the currently live registrations. The code is a claim
// credential — never log it.
func generatePairingCode() (string, error) {
space := 1
for i := 0; i < pairingCodeDigits; i++ {
space *= 10
}
// Rejection-sample: n%space over the full 24-bit range would bias low
// codes (16777216 is not a multiple of 10^6), so values at or above
// the largest whole multiple of the code space are discarded.
max := ((1 << 24) / space) * space
for attempt := 0; attempt < 10; attempt++ {
b := make([]byte, 3)
if _, err := rand.Read(b); err != nil {
return "", err
}
n := int(b[0])<<16 | int(b[1])<<8 | int(b[2])
if n >= max {
continue
}
code := fmt.Sprintf("%0*d", pairingCodeDigits, n%space)
if !pairingCodeInUse(code) {
return code, nil
}
}
return "", errors.New("pairing code space exhausted")
}
// pairingCodeInUse reports whether any live registration already holds the
// code. Callers race on mint→insert; the next mint's collision check
// covers the gap well enough for a family-scale server.
func pairingCodeInUse(code string) bool {
pendingMu.Lock()
defer pendingMu.Unlock()
for _, reg := range pendingRegistrations {
if reg.PairingCode == code {
return true
}
}
return false
}
// normalizePairingCode reduces human-typed input to its digits — the
// device shows "583 912", users type "583 912", "583912", "583-912".
func normalizePairingCode(raw string) string {
var b strings.Builder
for _, r := range raw {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
// errPairingInvalid marks a wrong code (generic by design — no hint
// whether the code exists), errPairingExpired a live-registration TTL
// miss, errPairingClaimed a code whose registration was already bound.
var (
errPairingInvalid = errors.New("invalid pairing code")
errPairingExpired = errors.New("pairing code expired")
errPairingClaimed = errors.New("pairing code already used")
)
// registrationByPairingCode resolves a normalized code to its live,
// unclaimed registration, reaping expired entries on sight.
func registrationByPairingCode(code string) (*PendingRegistration, error) {
pendingMu.Lock()
defer pendingMu.Unlock()
for _, reg := range pendingRegistrations {
if reg.PairingCode != code {
continue
}
if time.Now().After(reg.ExpiresAt) {
delete(pendingRegistrations, reg.RegistrationID)
return nil, errPairingExpired
}
if reg.Approved {
return nil, errPairingClaimed
}
return reg, nil
}
return nil, errPairingInvalid
}
// claimAllowed applies the per-account pairing attempt budget. userID
// keys the window; IP is folded in so a token shared across browsers
// still bounds per-origin abuse.
func claimAllowed(userID string, ip string) bool {
return pairingRateLimiter.CheckRateLimit(userID+"|"+ip, "claim", pairingRateLimitConfig)
}
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
req := DeviceRegistrationRequest{}
if err := c.Bind(&req); err != nil {
@@ -133,16 +272,31 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
registrationID := uuid.New().String()
expiresAt := time.Now().Add(5 * time.Minute)
pairingCode, err := generatePairingCode()
if err != nil {
slog.Error("pairing code generation failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing code"})
}
registration := &PendingRegistration{
RegistrationID: registrationID,
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
DeviceIdentifier: req.DeviceIdentifier,
PairingCode: pairingCode,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}
pendingMu.Lock()
// One live registration per device: a re-register (cancel-then-retry,
// double-tap) retires the previous unclaimed attempt so exactly one
// code is ever claimable for this identifier.
for id, existing := range pendingRegistrations {
if existing.DeviceIdentifier == req.DeviceIdentifier && !existing.Approved {
delete(pendingRegistrations, id)
}
}
pendingRegistrations[registrationID] = registration
pendingMu.Unlock()
@@ -167,6 +321,7 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
RegistrationID: registrationID,
AuthURL: authURL,
QRCode: "data:image/png;base64," + qrCodeBase64,
PairingCode: pairingCode,
ExpiresIn: 300,
PollInterval: 3,
SetupInstructions: setupInstructions,
@@ -519,6 +674,95 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
})
}
// errIdentifierOwnedByOther marks the cross-account case: the device
// identifier's existing row belongs to a different user, so neither
// approval nor claim may touch it.
var errIdentifierOwnedByOther = errors.New("device identifier registered to another user")
// bindRegistrationToUser creates or rotates the devices row for a pending
// registration and returns the row, its fresh token, and the sync
// endpoints for the device type. Shared by ApproveDevice (Android
// self-approval by registration UUID) and the claim-by-code endpoint;
// callers publish the result under pendingMu themselves.
func (h *DeviceHandler) bindRegistrationToUser(ctx context.Context, registration *PendingRegistration, pgUserID pgtype.UUID) (database.Devices, string, map[string]string, error) {
authToken, err := generateDeviceToken()
if err != nil {
return database.Devices{}, "", nil, fmt.Errorf("generate token: %w", err)
}
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
// installDebug over an existing install) re-register with the SAME
// device_identifier, but the devices row from the previous install
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
// with a unique violation (the historical "approve returns 500 after
// reinstall" bug). Look the device up first and rotate its token
// instead; a fresh install MUST get fresh credentials, so the old
// token is invalidated either way.
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
// The identifier is globally unique; a row owned by another user
// means cross-account identifier reuse — refuse it.
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
return existing, false, errIdentifierOwnedByOther
}
updated, err := h.db.UpdateDeviceAuthToken(ctx,
database.UpdateDeviceAuthTokenParams{
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
AuthToken: authToken,
})
return updated, true, err
}
existing, err := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier)
var device database.Devices
switch {
case err == nil:
// Known device: rotate the token, keep the row (id unchanged —
// highlights/bookmarks/progress anchored to it stay valid).
device, _, err = rotateExisting(existing)
if err != nil {
return database.Devices{}, "", nil, err
}
case errors.Is(err, pgx.ErrNoRows):
device, err = h.db.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
if err != nil {
// Concurrent approve racing us onto the unique index: fall
// through to the rotate path for the winner's row.
var pgErr *pgconn.PgError
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
if winner, lerr := h.db.GetDeviceByIdentifier(ctx, registration.DeviceIdentifier); lerr == nil {
device, _, err = rotateExisting(winner)
}
}
}
if err != nil {
return database.Devices{}, "", nil, fmt.Errorf("create device: %w", err)
}
default:
return database.Devices{}, "", nil, fmt.Errorf("look up device: %w", err)
}
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
}
return device, authToken, syncEndpoints, nil
}
func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
@@ -552,102 +796,17 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
}
pendingMu.Unlock()
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
// installDebug over an existing install) re-register with the SAME
// device_identifier, but the devices row from the previous install
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
// with a unique violation (the historical "approve returns 500 after
// reinstall" bug). Look the device up first and rotate its token
// instead; a fresh install MUST get fresh credentials, so the old
// token is invalidated either way.
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
// The identifier is globally unique; a row owned by another user
// means cross-account identifier reuse — refuse it.
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
return existing, false, nil
}
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
database.UpdateDeviceAuthTokenParams{
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
AuthToken: authToken,
})
return updated, true, err
}
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
registration.DeviceIdentifier)
var device database.Devices
switch {
case err == nil:
// Known device: rotate the token, keep the row (id unchanged —
// highlights/bookmarks/progress anchored to it stay valid).
var ok bool
device, ok, err = rotateExisting(existing)
if err != nil {
slog.Error("device re-approval failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to approve device"})
}
if !ok {
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
if err != nil {
if errors.Is(err, errIdentifierOwnedByOther) {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
case errors.Is(err, pgx.ErrNoRows):
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
if err != nil {
// Concurrent approve racing us onto the unique index: fall
// through to the rotate path for the winner's row.
var pgErr *pgconn.PgError
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
if winner, lerr := h.db.GetDeviceByIdentifier(
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
var ok bool
device, ok, err = rotateExisting(winner)
if err == nil && !ok {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
}
}
}
if err != nil {
slog.Error("device creation failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to create device"})
}
default:
slog.Error("device lookup failed",
slog.Error("device approval bind failed",
"registration_id", registrationID, "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to look up device"})
}
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints = koreaderSyncEndpoints(h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
map[string]string{"error": "failed to approve device"})
}
// Publish the credentials under the map lock: the status poller
@@ -670,73 +829,183 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
})
}
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
registrationID := c.Param("registration_id")
// errPairingRateLimited marks an exhausted per-account attempt budget.
var errPairingRateLimited = errors.New("too many pairing attempts")
pendingMu.Lock()
_, exists := pendingRegistrations[registrationID]
if !exists {
pendingMu.Unlock()
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
// resolvePairingCode is the shared lookup core for the JSON API and the
// SSR confirm-card partial: apply the per-account budget, normalize the
// typed code, resolve to a live unclaimed registration.
func resolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
if !claimAllowed(userID, ip) {
return nil, errPairingRateLimited
}
return registrationByPairingCode(normalizePairingCode(rawCode))
}
// pairingHTTPError maps a resolvePairingCode error to status + message.
// The invalid-code case is deliberately generic: it must not reveal
// whether any registration is waiting.
func pairingHTTPError(err error) (int, string) {
switch {
case errors.Is(err, errPairingRateLimited):
return http.StatusTooManyRequests, "too many pairing attempts; try again in a minute"
case errors.Is(err, errPairingExpired):
return http.StatusGone, "pairing code expired — register the device again"
case errors.Is(err, errPairingClaimed):
return http.StatusConflict, "pairing code already used"
default:
return http.StatusBadRequest, "invalid pairing code"
}
}
// ResolvePairingCode is the exported lookup core for the router's SSR
// confirm-card partial (the router renders templ; handlers stay JSON).
func ResolvePairingCode(userID, ip, rawCode string) (*PendingRegistration, error) {
return resolvePairingCode(userID, ip, rawCode)
}
// PairingErrorStatus maps a ResolvePairingCode error to the status key
// consumed by templates.PairingLookupResult.
func PairingErrorStatus(err error) string {
switch {
case errors.Is(err, errPairingRateLimited):
return "rate_limited"
case errors.Is(err, errPairingExpired):
return "expired"
case errors.Is(err, errPairingClaimed):
return "claimed"
default:
return "invalid"
}
}
// LookupPairingCode resolves a pairing code to its pending registration
// WITHOUT binding it — the confirm card's data. Lookups and claims share
// the per-account attempt budget.
func (h *DeviceHandler) LookupPairingCode(c *echo.Context) error {
userID := c.Get("user_id").(string)
req := PairingCodeRequest{}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
}
delete(pendingRegistrations, registrationID)
pendingMu.Unlock()
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
if err != nil {
status, msg := pairingHTTPError(err)
return c.JSON(status, map[string]string{"error": msg})
}
return c.JSON(http.StatusOK, map[string]string{
"message": "device registration rejected",
return c.JSON(http.StatusOK, PairingLookupResponse{
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
})
}
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations {
if reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
"device_type": reg.DeviceType,
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": false,
})
}
}
pendingMu.Unlock()
return registrations, nil
}
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
// ClaimDevice binds the registration behind a pairing code to the
// CALLER's account — the web-side replacement for the broadcast-list
// approve. The device's own poll (registration UUID bearer) then delivers
// the fresh token to the plugin, exactly as in the old approve flow.
func (h *DeviceHandler) ClaimDevice(c *echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID"})
}
registrations := []map[string]interface{}{}
pendingMu.Lock()
for _, reg := range pendingRegistrations {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID,
"device_name": reg.DeviceName,
"device_type": reg.DeviceType,
"device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt,
"is_approved": reg.UserID != (uuid.UUID{}),
})
if !claimAllowed(userID, c.RealIP()) {
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": "too many pairing attempts; try again in a minute"})
}
req := PairingCodeRequest{}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
}
registration, err := resolvePairingCode(userID, c.RealIP(), req.PairingCode)
if err != nil {
status, msg := pairingHTTPError(err)
return c.JSON(status, map[string]string{"error": msg})
}
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
device, authToken, syncEndpoints, err := h.bindRegistrationToUser(c.Request().Context(), registration, pgUserID)
if err != nil {
if errors.Is(err, errIdentifierOwnedByOther) {
return c.JSON(http.StatusConflict,
map[string]string{"error": "device identifier already registered to another user"})
}
slog.Error("device claim bind failed", "error", err)
return c.JSON(http.StatusInternalServerError,
map[string]string{"error": "failed to pair device"})
}
// Publish under the map lock: the device's status poller snapshots
// these fields only after Approved flips, so the token can never be
// read half-written.
pendingMu.Lock()
registration.UserID = userUUID
registration.Approved = true
registration.AuthToken = authToken
registration.DeviceID = device.ID.Bytes
registration.SyncEndpoints = syncEndpoints
pendingMu.Unlock()
// HTMX confirm button: htmx reloads the page natively (CSP — no
// inline scripts in the partial).
if c.Request().Header.Get("HX-Request") == "true" {
c.Response().Header().Set("HX-Refresh", "true")
return c.HTML(http.StatusOK, `<div class="text-green-500">Paired! Reloading …</div>`)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device paired successfully",
"device_name": registration.DeviceName,
"device_type": registration.DeviceType,
"approved": true,
})
}
// CancelRegistration lets the REGISTERING device end its own pairing
// attempt (dismissed waiting dialog). The registration UUID is the
// bearer — same trust as the status poll — so a device can only ever
// cancel its own registration, and a claimed one is left alone (its
// poller is mid-delivery of the token).
func (h *DeviceHandler) CancelRegistration(c *echo.Context) error {
req := CancelRegistrationRequest{}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
}
pendingMu.Lock()
if reg, exists := pendingRegistrations[req.RegistrationID]; exists && !reg.Approved {
delete(pendingRegistrations, req.RegistrationID)
}
pendingMu.Unlock()
return c.JSON(http.StatusOK, map[string]string{"message": "registration cancelled"})
}
// GetPendingRegistrationsData returns nothing, deliberately. Unclaimed
// registrations are invisible since pairing codes took over the approval
// flow: first-approve-wins on a broadcast list let any account claim (or
// reject) a device it could not even name. Claiming happens by typing the
// code shown on the registering device's screen. Kept as an always-empty
// shape so API consumers (and the Android app's docs) keep compiling.
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
return []map[string]interface{}{}, nil
}
// ListPendingRegistrations — see GetPendingRegistrationsData: the
// broadcast is dead; unclaimed registrations belong to no one's list.
func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
registrations := []map[string]interface{}{}
return c.JSON(http.StatusOK, map[string]interface{}{
"registrations": registrations,
"pending_registrations": registrations, // Fixed: Add for test compatibility and API clarity
"total": len(registrations),
"pending_registrations": registrations, // test/API-shape compatibility
"total": 0,
})
}
+98
View File
@@ -0,0 +1,98 @@
package handlers
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeneratePairingCodeShape(t *testing.T) {
for i := 0; i < 50; i++ {
code, err := generatePairingCode()
require.NoError(t, err)
assert.Len(t, code, pairingCodeDigits)
assert.Regexp(t, `^\d{6}$`, code, "codes are 6 digits")
}
}
func TestGeneratePairingCodeAvoidsLiveCollisions(t *testing.T) {
reg := &PendingRegistration{RegistrationID: uuid.NewString(), PairingCode: "000000"}
pendingMu.Lock()
pendingRegistrations[reg.RegistrationID] = reg
pendingMu.Unlock()
t.Cleanup(func() {
pendingMu.Lock()
delete(pendingRegistrations, reg.RegistrationID)
pendingMu.Unlock()
})
for i := 0; i < 200; i++ {
code, err := generatePairingCode()
require.NoError(t, err)
assert.NotEqual(t, "000000", code, "mint must not collide with a live registration's code")
}
}
func TestNormalizePairingCode(t *testing.T) {
assert.Equal(t, "583912", normalizePairingCode("583 912"), "device shows spaced digits")
assert.Equal(t, "583912", normalizePairingCode("583-912"))
assert.Equal(t, "583912", normalizePairingCode("583912"))
assert.Equal(t, "583912", normalizePairingCode(" 58 39 12 "), "stray whitespace tolerated")
assert.Equal(t, "", normalizePairingCode("no digits here"))
}
func TestRegistrationByPairingCodeScopes(t *testing.T) {
live := &PendingRegistration{
RegistrationID: uuid.NewString(),
PairingCode: "111111",
DeviceName: "Live Device",
ExpiresAt: time.Now().Add(time.Minute),
}
expired := &PendingRegistration{
RegistrationID: uuid.NewString(),
PairingCode: "222222",
ExpiresAt: time.Now().Add(-time.Minute),
}
claimed := &PendingRegistration{
RegistrationID: uuid.NewString(),
PairingCode: "333333",
ExpiresAt: time.Now().Add(time.Minute),
Approved: true,
}
pendingMu.Lock()
for _, reg := range []*PendingRegistration{live, expired, claimed} {
pendingRegistrations[reg.RegistrationID] = reg
}
pendingMu.Unlock()
t.Cleanup(func() {
pendingMu.Lock()
for _, reg := range []*PendingRegistration{live, expired, claimed} {
delete(pendingRegistrations, reg.RegistrationID)
}
pendingMu.Unlock()
})
// Live code resolves.
got, err := registrationByPairingCode("111111")
require.NoError(t, err)
assert.Equal(t, "Live Device", got.DeviceName)
// Expired code is reaped on sight.
_, err = registrationByPairingCode("222222")
assert.ErrorIs(t, err, errPairingExpired)
pendingMu.Lock()
_, stillThere := pendingRegistrations[expired.RegistrationID]
pendingMu.Unlock()
assert.False(t, stillThere, "expired registration must be reaped")
// Claimed code is refused.
_, err = registrationByPairingCode("333333")
assert.ErrorIs(t, err, errPairingClaimed)
// Unknown code is the generic miss.
_, err = registrationByPairingCode("999999")
assert.ErrorIs(t, err, errPairingInvalid)
}
+14 -1
View File
@@ -21,12 +21,14 @@ type DeviceRateLimitConfig struct {
SyncRequestsPerMinute int
ProgressUpdatesPerMinute int
MetadataRequestsPerMinute int
ClaimAttemptsPerMinute int
}
const (
DefaultSyncRequestsPerMinute = 60
DefaultProgressUpdatesPerMinute = 120
DefaultMetadataRequestsPerMinute = 30
DefaultClaimAttemptsPerMinute = 12
CleanupInterval = 5 * time.Minute
RequestWindow = 1 * time.Minute
)
@@ -67,12 +69,23 @@ func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string
limit = config.ProgressUpdatesPerMinute
case "metadata":
limit = config.MetadataRequestsPerMinute
case "claim":
limit = config.ClaimAttemptsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
if limit == 0 {
limit = DefaultSyncRequestsPerMinute
switch requestType {
case "progress":
limit = DefaultProgressUpdatesPerMinute
case "metadata":
limit = DefaultMetadataRequestsPerMinute
case "claim":
limit = DefaultClaimAttemptsPerMinute
default:
limit = DefaultSyncRequestsPerMinute
}
}
info.Requests = append(info.Requests, now)
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more